diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index 6e3a4e7..b4baea0 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -33,7 +33,8 @@ - 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더 - 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보 - 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다) -- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다). +- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`) +- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · 조건 검색의 `from` · `to` · `subject` · `body`(14단계) · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다). 앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]). **예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다 diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 2ca0983..2f5ff5d 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -180,7 +180,7 @@ sudo docker compose --env-file .env.production logs -f backend `X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등). **프록시가 다른 서버에 있으면** 주소 목록 `,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다). - **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다. - "직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다. + "직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`, 메일을 열 때마다 부르는 같은 대화 `GET /api/messages/{id}/conversation` — 14단계)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다. 외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개). - **백엔드의 바깥 연결**(운영 확인 항목, 9단계): 외부 이미지 중계는 backend 컨테이너가 바깥 80 · 443 으로 나가 이미지를 받아 온다 — 호스트 방화벽 · 사내 프록시가 막으면 이미지 자리가 비어 보인다(다른 기능은 영향 없음). 사설 · 내부 주소로는 백엔드가 스스로 나가지 않는다(SSRF 막기 — 계약 2절 "외부 이미지 보기"). @@ -484,7 +484,7 @@ node --env-file=.env.development mail/scripts/setup.mjs - **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다. DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다. **매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다). -- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`. +- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 한 달에 한 번 `drill`(mail/README "백업 › 복원" 이 기준). - **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로. - **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다. - 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`. @@ -569,6 +569,9 @@ node --env-file=.env.development mail/scripts/setup.mjs `nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고 `deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다). - **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로). +- **db 메모리 한도는 1g · 공유 버퍼(`shared_buffers`) 256MB** (14단계 — 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있어야 1초 안이다, 캐시가 비면 3~8배). + 공유 버퍼는 compose 의 db `command`(`postgres -c …` — 명령줄 값이 데이터 폴더의 설정보다 이긴다)이고 개발도 같은 값이다(dev 오버라이드가 덮어쓰지 않는다). + 8GB 서버의 한도 합 · 운영에 올리는 차례는 mail/README.md "메모리 예산". - **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게). watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다. ⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는 diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 1a05a9a..e734265 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -43,6 +43,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 | | 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` | | 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM `, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it -backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열한 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 이전 마치기: 앱 비밀번호 → 세션 → 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더(알림 없음) / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) | +| 메일 검색 색인 (14단계 1차 — 색인 바탕 · 2차 — 검색 · 강조 · 3차 — 대화) | `modules/mail-search` · `modules/mail/jmap/jmap-index-source.ts` · `shared/utils/search-text.ts` · `subject-base.ts` | 표 둘(마이그레이션 `AddMailSearchIndex`): `mail_search_messages`(직원 주소 + 엔진 메일 id 마다 한 줄 — 원문(NFC) · 접기 글 `fold_*` · 글자 두 개 묶음 `grams` · 메일함 · 답장 관계(GIN 둘 — 직원 주소(lower 식) · 묶음 · 메일함을 한 색인에 담은 찾기 GIN `IDX_mail_search_messages_user_grams_mailboxes` 와 refs GIN. 마이그레이션이 손으로 만들고 엔티티에는 `synchronize: false` 로 이름만)) · `mail_search_states`(직원마다 한 줄 — building · ready, 엔진 Email state, 채우기 자리, 멈춘 까닭, 실패 수). 포트 `MAIL_INDEX_SOURCE`(`mail-index-source.ts` — 엔진 교체는 `mail-search.module.ts` 의 useClass 만): 어댑터는 textBody 의 글자 부분만 읽는다(첨부 안의 글은 읽지 않는다) — 본문 값은 부분 256KiB · 한 통 1MiB, 한 요청의 메일 전체 크기 합 32MiB · 16MiB 넘는 메일은 혼자. 접기 `foldText`(NFC 뒤 코드 포인트 하나씩 — 전각 → 반각 · 소문자, 원문과 위치가 같다) · 묶음 `gramsOf` · 맞은 곳 `matchRanges` · 말머리 떼기 `subjectBase` — **짝: frontend `shared/utils/search-match.ts`**, 같은 표본 표 `backend/test/search-text-samples.ts`(한쪽을 고치면 둘 다). 상태 줄은 로그인(`AuthService`) · 비밀번호 바꾸기 · `GET /api/search-index` 가 만들고 멈춤을 푼다(로그인한 적 없는 계정은 색인하지 않는다). 맞추기 `mail-search-sync.service.ts`(Email/changes — 실시간 state 소식 300ms 모음 · 5분 cron · 검색 바로 전 1.5초, 직원마다 하나(`MailSearchRuns`) · 서버 전체 4(`ConcurrencyGate`), cannotCalculate → 전체 대조), 채우기 `mail-search-build.service.ts`(밤 창 KST 22:00~02:45 · 05:00~07:30 — 1분 cron, 최신부터 50통씩 anchor, 다 읽으면 새 차례의 처음에 따라잡기 → 전체 대조 → ready). **시간 끝은 실패가 아니다** — `IndexDeadlineError`(이어 하기 — 실패 수 · 경고 없음). 색인 쓰기는 상태 줄을 `FOR UPDATE` 로 잠근 한 트랜잭션 — 퇴사 마무리(`ops-data.service.ts`)는 상태 줄 → 색인 줄 차례로 지운다. 맞추고 채우는 직원은 사내 도메인(`MAIL_DOMAIN`) 주소뿐이다(같은 DB 의 e2e 줄은 건드리지 않는다). cron 은 `timeZone: 'Asia/Seoul'`. 묶음 크기 · 시간 한도는 `mail-search.constants.ts` 한 곳(잰 뒤 바꾼다). 로그는 코드 · 사유 · 가린 주소만(메일 글 · 제목 · 엔진 설명 문장 없음), 조건 검색 쿼리(`from` · `to` · `subject` · `body`)는 `maskUrlForLog` 가 길이만, 개발의 DB 쿼리 로그도 끈다(`database.config.ts`). **2차(검색 · 강조)**: `GET /api/messages` 의 조건 `from` · `to`(참조 포함) · `subject` · `body`(검색어와 같은 검사 — `dto/search-query-fields.ts` 의 `SearchTextField`) · `after` · `before`(UTC) · `hasAttachment=true`. 목록 · 검색은 `modules/mail/message-list.service.ts` — 색인이 준비됐으면(ready · 지금 판 `INDEX_FORMAT` — 판이 다르면 서버가 뜰 때 building 으로 되돌려 밤에 다시 채운다) 색인, 아니면 엔진(조건은 `jmap/search-terms.ts` 의 `searchFieldConditions`), 쪽을 넘기는 중에는 커서의 방식대로(`mail-search/search-cursor.ts` — 색인 커서 2판 `{v:2,m:'i'}`, 엔진 커서는 1판). 첫 쪽의 색인 찾기가 DB 장애면 엔진으로 찾는다. 색인 찾기: 계획 `search-plan.ts`(낱말 · 칸 · 한 글자 낱말 `SINGLE_CHAR_TERMS_SKIP_BODY` · 두 글자 낱말뿐이면 묶음만으로 `exactByGrams`) → SQL `search-sql.ts` → `mail-search-query.service.ts`. **후보는 찾기 GIN 하나에서만 거른다** — `IDX_mail_search_messages_user_grams_mailboxes`(마이그레이션 `SearchIndexUserGramsMailboxes`, 2026-11-06 실사용 시험)가 직원 · 묶음 · 메일함을 한 번에 맞춰 맞은 줄만 읽는다. 주소는 `lower("user_email")` 식(그대로면 btree 로 그 직원 줄을 다 읽고 묶음 배열(TOAST)을 풀었다 — 260~700ms → 10ms), 메일함은 `"mailbox_ids" @> $n` 그대로(`= ANY` 면 그 직원의 묶음 줄을 모두 읽는다). **메일함만 담은 GIN 을 다시 만들지 않는다** — 메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체인데, 계획기가 그것만으로 메일함 줄을 다 읽고 묶음 배열을 줄마다(다른 직원 줄까지) 풀었다(1만 통 메일함 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1초 — 고친 뒤 15ms · 1ms). e2e 가 EXPLAIN(그 색인 하나 · 색인 조건에 메일함)과 GIN 목록을 본다. 확인(strpos — 접은 본문을 풀어 한 줄 0.15~0.3ms)은 후보 id 의 줄만, 후보가 많으면 100줄부터 4배씩 쪽이 찰 때까지 · 한 요청 3,000줄까지(넘으면 이어 찾기 커서 — 쪽이 limit 보다 적어도 hasNext), total 은 첫 쪽에서 1,000까지(넘거나 다 세지 못하면 null). 상수는 `mail-search.constants.ts`. 강조: 목록 `search-highlight.ts`(제목 · 본문에서 맞은 곳 둘레의 미리 보기 — 색인 방식에서만. 미리 보기는 인용 줄(`>`) 밖의 맞은 곳 먼저 → 인용 안에서만 맞고 검색어 낱말이 모두 색인의 다른 칸(접은 칸 넷 — `highlightTextsSql`)에도 있으면 인용 밖 첫 글 → 아니면 `>` 를 뗀 인용 조각. 본문 강조는 두 글자 이상 낱말만 — `search-plan.ts` 의 `bodyHighlightOf`, 읽기 화면 `highlightTermsOf` 도 같은 계획에서 꺼낸다), 읽기 화면 `modules/mail/message-highlight.ts` + **정화한 뒤** HTML 의 글 조각에 `` 를 넣는 `html/html-mark.ts`(태그 · 속성 · 주석 · style 안은 그대로, 길이에 비례 · 1,000개까지). 위치는 NFC 글의 UTF-16 [시작, 끝) — 강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다. **3차(대화)**: 대화 키 — 색인 쓰기(`mail-search-index.repository.ts` 의 `linkThreads`)가 넣은 메일 M 의 refs 가 가리키는 메일 · M 을 가리키는 메일 가운데 말머리를 뗀 제목(`subject_base`)이 같은 것의 키를 가장 작은 키로 합친다(쌍 읽기 한 쿼리 + UPDATE 한 문장 · 합치기 `thread-keys.ts`, Message-ID 가 없으면 혼자, 넣기는 이미 있는 줄의 대화 키를 덮지 않는다). 같은 대화 `GET /api/messages/{id}/conversation` · 묶은 목록 `GET /api/conversations?mailboxId=` — `modules/mail/conversation.controller.ts` · `conversation.service.ts`(색인이 준비됐으면 색인 · 아니면 엔진의 스레드 — 응답의 `basis`, 커서가 방식을 잇는다 `mail-search/conversation-cursor.ts` — `m` 이 색인 방식이면 **입구에서 decode**: 꾸민 커서는 맞추기 · 엔진 · DB 전에 400). 색인 쿼리 `mail-search/conversation-sql.ts` · `mail-conversation-query.service.ts` — **늘 그 직원 주소의 btree 로 먼저 좁힌다**(메일함은 `$n = ANY("mailbox_ids")` 꼴 — 메일함 posting 은 회사 전체라 그 직원의 줄에서 거른다. 메일함 칸을 담은 GIN 은 검색의 찾기 GIN 하나뿐이고 대화 쿼리는 쓰지 않는다), 같은 대화는 그 메일의 키와 쪽을 한 문장(LEFT JOIN LATERAL — 색인에 없음과 빈 쪽을 가른다). 묶은 목록 줄: messageIds 최신부터 `CONVERSATION_MESSAGE_IDS_MAX`(500)까지 · 참여자는 **첫 보낸 사람 칸**(`from_email` · `from_name` — 마이그레이션 `AddMailSearchSender`, 색인 판 4. 보낸 사람 글을 다시 해석하지 않는다 — 이름 안의 `<주소>` 는 주소가 아니다)에서 주소마다 한 번 · 10명까지 DB 가 고른다(대화마다 LATERAL `DISTINCT ON`), `pendingSendIds`(messageIds 가운데 붙잡힌 메일 — 기다리는 제출은 쪽마다 한 번: 색인은 `getSummariesWithHeld`, 엔진은 목록 요청 안). 10명 흉내(10만 줄)에서 큰 메일함 첫 쪽 15~80ms · 자세히 · 참여자 1~2ms. 엔진 쪽은 `jmap/jmap-conversations.ts`(Thread/get · collapseThreads) — **거르기는 값싼 속성 · 요약 속성은 내보낼 메일만**, 상한 · 시간은 `mail-search.constants.ts` 의 `CONVERSATION_ENGINE_*`(같은 대화 한 요청 1,000통 — 그 이하 스레드만 total, 넘으면 커서 자리부터 · total null / 묶은 목록 스레드마다 최신 500 · 한 요청 2,000(`shareScan`) — 상한에 걸린 줄은 읽지 못한 메일을 거르지 않고 통 수에 더하고 나머지는 읽은 최신 쪽으로 / 한 요청 15초 — 훑는 호출은 마지막 요약 읽기 몫 3초를 뺀 제한 시간, 마지막 읽기만 남은 시간을 다, 남은 3초면 더 훑지 않고 시간에 끊긴 훑기는 읽은 데까지로 답한다. 참여자 이름은 색인과 같은 `participantName`(100 코드 포인트)). 엔진의 Thread/get emailIds 는 받은 시각 오래된 차례다(2026-11-06 개발 엔진 200 스레드 확인). 휴지통 · 스팸메일함 역할과 내부 발송 키워드는 `jmap/mail-scope.ts` 한 곳, 보이기 경로의 기다리는 제출 상한 경고는 `pending-send.ts` 의 `warnIfHeldQueryFull`. 설정 `groupByConversation`(`modules/mail-user-settings` — 처음 끔, 서버는 화면이 부르면 묶을 뿐 설정을 보지 않는다) | | Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) | | 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) | | env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) | @@ -260,6 +261,7 @@ SwaggerModule.setup('api-docs', app, document); > 코드 실패 카운터(`login-attempt.service.ts` 의 codeFailures · dailyCodeFailures), 2단계 바꾸기 잠금(`two-factor-locks.ts`) · 앱 비밀번호 만들기 차례(`AppPasswordsService.creating`) — > 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) — > 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다. + > 검색 색인(14단계)의 직원마다 차례(`mail-search-runs.ts` 의 `MailSearchRuns`) · 맞추기의 소식 모음 · 기다리는 맞추기 · 5분 차례 겹침 막기(`mail-search-sync.service.ts` 의 debounces · queued · syncingAll) · 서버 전체 맞추기 수(`ConcurrencyGate` — 4) · 1분 채우기 겹침 막기(`mail-search-build.service.ts` 의 running) — 그대로면 인스턴스마다 따로 돌아, **두 인스턴스의 1분 차례가 같은 직원을 함께 채우고**(쓰기는 상태 줄 잠금으로 차례가 되지만 같은 묶음을 두 번 읽는다) 같은 직원을 함께 맞추며, 엔진에 가는 맞추기 수가 인스턴스 수만큼 는다 → 직원마다 잠금(`SET NX PX` · `pg_advisory_lock`) · 공유 셈. 표 쓰기 자체는 DB 라 그대로 맞다. > 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어 > 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) — > 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다: diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 0311d30..6435519 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -37,7 +37,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) | | 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` | | 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) | -| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | +| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | | 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 | | 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) | | 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) | @@ -46,13 +46,16 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 | | 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 | | 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) | -| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 | +| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 | | 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" | | 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" | | 바탕화면 알림 (5단계) | `src/composables/useNewMailNotifier.ts` · `useDesktopNotification.ts` · `stores/mail-notify.store.ts`(브라우저 · 사용자마다 localStorage) · `shared/utils/desktop-notify.ts` · 목록 위 권하기 `components/mail/MailNotifyOffer.vue` · 설정 `pages/settings/MailNotifyPage.vue`(+ `components/settings/NotifySettingsForm.vue` · `RealtimeLinkStatus.vue`) · 아이콘 `assets/brand/notification-icon.png` · 문구 `shared/constants/mail-notify.ts` | 연결한 탭이 `new-mail` 을 받고 설정이 켜져 있고 허락됐고 **웹메일을 보고 있는 탭이 없을 때만**(보이고 초점이 있음 — `visibilityState === 'visible' && document.hasFocus()`, 다른 탭에 짧게 묻는다) 띄운다 — tag 하나로 앞 알림을 바꿔 쓰고, 사용자가 돌아오면 닫는다. 허락을 묻는 동안 단추는 `disabled` 가 아니라 `aria-disabled`(초점을 잃지 않게). 허락은 사용자가 누른 뒤에만(`Notification.requestPermission()`). 누르면 그 메일로(`useMailNavigation` 의 `openFromNotification` — 세션이 바뀌었으면 이동하지 않는다) | | 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 | | 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") | -| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" | +| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) | +| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel` | +| 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" | +| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에" | | 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 다섯 칸만) — 계약 1절 · 2절 "부재중 자동 응답" | | 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | diff --git a/backend/src/migrations/1793873715423-AddMailSearchIndex.ts b/backend/src/migrations/1793873715423-AddMailSearchIndex.ts new file mode 100644 index 0000000..1213c58 --- /dev/null +++ b/backend/src/migrations/1793873715423-AddMailSearchIndex.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 표 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕, 설계 A). + * - mail_search_messages: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계 · 원문 글(NFC) · 접기 글(fold_*) · + * 글자 두 개 묶음(grams) · 대화 키. 색인: PK, (직원, 받은 시각, id) · (직원, Message-ID) · (직원, 대화 키), + * GIN 셋(grams · mailbox_ids · refs — TypeORM 이 GIN 을 적지 못해 엔티티에는 synchronize: false 로 이름만 있다) + * - mail_search_states: 직원마다 한 줄 — 상태(building · ready) · 엔진 Email state · 채우기 자리 · 멈춘 까닭 · 실패 수 + * - mail_settings.group_by_conversation: 목록을 대화로 묶어 보기(기본 끔 — API 는 14단계 3차) + * 기존 표의 칸 · 행은 지우지 않는다 — 표를 새로 만들고 칸 하나를 더하기만 한다. + * (자동 생성: npm run migration:generate — GIN 색인 셋만 손으로 더했다. 엔티티는 modules/mail-search/*.entity.ts · + * modules/mail-user-settings/mail-user-settings.entity.ts) + */ +export class AddMailSearchIndex1793873715423 implements MigrationInterface { + name = 'AddMailSearchIndex1793873715423'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "mail_search_messages" ("user_email" character varying(254) NOT NULL, "email_id" character varying(64) NOT NULL, "mailbox_ids" text array NOT NULL, "keywords" text array NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, "size" integer NOT NULL, "message_id" text, "refs" text array NOT NULL, "subject" text NOT NULL, "subject_base" text NOT NULL, "from_text" text NOT NULL, "to_text" text NOT NULL, "cc_text" text NOT NULL, "attachment_names" text NOT NULL, "has_attachment" boolean NOT NULL, "preview" text NOT NULL, "body_text" text NOT NULL, "fold_from" text NOT NULL, "fold_to" text NOT NULL, "fold_subject" text NOT NULL, "fold_attachment" text NOT NULL, "fold_body" text NOT NULL, "grams" text array NOT NULL, "thread_key" character varying(64) NOT NULL, "engine_thread_id" character varying(64), "indexed_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_bdeada2a9bbda7dece469d81b6f" PRIMARY KEY ("user_email", "email_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_thread_key" ON "mail_search_messages" ("user_email", "thread_key") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_message_id" ON "mail_search_messages" ("user_email", "message_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_received" ON "mail_search_messages" ("user_email", "received_at", "email_id") `, + ); + // GIN — 찾는 말의 글자 두 개 묶음이 모두 든 줄(grams @> …) · 메일함 범위(mailbox_ids && …) · 늦게 온 원본이 자식을 찾기(refs @> …) + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_refs" ON "mail_search_messages" USING GIN ("refs")`, + ); + await queryRunner.query( + `CREATE TABLE "mail_search_states" ("user_email" character varying(254) NOT NULL, "status" character varying(32) NOT NULL, "email_state" text, "build_anchor" character varying(64), "build_done" integer NOT NULL DEFAULT '0', "build_total" integer, "build_started_at" TIMESTAMP WITH TIME ZONE, "ready_at" TIMESTAMP WITH TIME ZONE, "last_synced_at" TIMESTAMP WITH TIME ZONE, "paused_reason" character varying(32), "failure_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e8aa94c380c49ff05919e5177ab" PRIMARY KEY ("user_email"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_states_status" ON "mail_search_states" ("status") `, + ); + await queryRunner.query( + `ALTER TABLE "mail_settings" ADD "group_by_conversation" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "mail_settings" DROP COLUMN "group_by_conversation"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_states_status"`, + ); + await queryRunner.query(`DROP TABLE "mail_search_states"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_refs"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_grams"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_received"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_message_id"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_thread_key"`, + ); + await queryRunner.query(`DROP TABLE "mail_search_messages"`); + } +} diff --git a/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts new file mode 100644 index 0000000..b4af38e --- /dev/null +++ b/backend/src/migrations/1793882273224-SearchIndexUserGrams.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤). + * - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 — + * 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서 + * 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정). + * 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다. + * `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로) + * - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다 + * 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다). + * (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts) + */ +export class SearchIndexUserGrams1793882273224 implements MigrationInterface { + name = 'SearchIndexUserGrams1793882273224'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_grams"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_user_grams"`, + ); + } +} diff --git a/backend/src/migrations/1793898213073-AddMailSearchSender.ts b/backend/src/migrations/1793898213073-AddMailSearchSender.ts new file mode 100644 index 0000000..c57769a --- /dev/null +++ b/backend/src/migrations/1793898213073-AddMailSearchSender.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자). + * - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 — + * 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 ` 꼴인 보낸 사람이 그 주소로 보이고, 400자에서 잘린 줄은 빠졌다 + * - 둘 다 null 을 받는다(주소가 없는 보낸 사람 · 지금 있는 줄). 지금 있는 줄은 색인 판 4(INDEX_FORMAT)로 다시 채우며 채운다 + * 기존 칸 · 행은 지우지 않는다. (자동 생성: npm run migration:generate — 엔티티는 modules/mail-search/mail-search-message.entity.ts) + */ +export class AddMailSearchSender1793898213073 implements MigrationInterface { + name = 'AddMailSearchSender1793898213073'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "mail_search_messages" ADD "from_email" character varying(254)`, + ); + await queryRunner.query( + `ALTER TABLE "mail_search_messages" ADD "from_name" character varying(100)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`, + ); + } +} diff --git a/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts b/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts new file mode 100644 index 0000000..914095d --- /dev/null +++ b/backend/src/migrations/1793979304483-SearchIndexUserGramsMailboxes.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 — 메일함 범위 찾기 (14단계 실사용 시험, 2026-11-06). + * - 글자 두 개 묶음 GIN 에 메일함 칸을 더한다: GIN 하나 (lower("user_email"), "grams", "mailbox_ids") 가 직원 · 묶음 · 메일함을 한 번에 맞춘다. + * 주소 · 묶음 GIN(`IDX_mail_search_messages_user_grams`)과 메일함만 담은 GIN(`IDX_mail_search_messages_mailbox_ids`)은 지운다. + * - 까닭: 메일함 범위의 후보 쿼리(`"mailbox_ids" @> $n` — search-sql.ts 의 candidatesSql)에서 계획기가 메일함 GIN 만으로 그 메일함의 줄을 모두 읽고 + * 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 골랐다. 메일함 id 는 계정마다 같은 꼴(a · b …)이라 그 posting 은 회사 전체이고, 묶음 확인이 + * 주소 확인보다 먼저 돌아 다른 직원의 줄까지 풀었다 — 1만 통 메일함 안 1.1초, 그 메일함 id 가 남의 큰 메일함과 같은 직원은 빈 메일함에도 1.6초. + * 계획기는 TOAST 를 푸는 값을 모르고, 묶음 GIN 의 대기 목록(fastupdate)이 쌓이면 그 GIN 을 더 비싸게 보아 이 길로 넘어간다 — + * 그래서 고를 길을 하나만 남긴다(이 색인 말고 `lower("user_email")` · 묶음 · 메일함 조건을 받는 색인이 없다. 메일함만 담은 색인을 다시 만들지 않는다). + * - 같은 표의 흉내(10명 · 10만 줄 — 메일함 id 가 겹친다)에서 큰 메일함 29ms · 작은 메일함 1~7ms · 전체 13ms + * (전에는 큰 메일함 43~125ms · 작은 메일함 5~88ms — 메일함 GIN 과 함께 쓸 때와 쓰지 않을 때). 그 표의 몫(직원 주소 · 메일함 칸)은 묶음보다 훨씬 작다. + * 쓰기 비용은 지금과 같다 — 메일함이 바뀐 줄은 지금도 두 GIN 에 새로 들어간다(색인된 칸이라 HOT 이 아니다). 표의 칸 · 행은 그대로다. + * btree_gin 확장은 SearchIndexUserGrams 가 만들었다. 내릴 때는 두 색인을 다시 만든다. + * (손으로 적었다 — TypeORM 이 GIN 을 적지 못한다. 엔티티는 modules/mail-search/mail-search-message.entity.ts 에 synchronize: false 로 이름만) + */ +export class SearchIndexUserGramsMailboxes1793979304483 implements MigrationInterface { + name = 'SearchIndexUserGramsMailboxes1793979304483'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_user_grams_mailboxes" ON "mail_search_messages" USING GIN (lower("user_email"), "grams", "mailbox_ids")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_user_grams"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_search_messages_user_grams_mailboxes"`, + ); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index d1bd871..f1b7267 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -6,6 +6,7 @@ import { PassportModule } from '@nestjs/passport'; import { AccountSecurityModule } from '@/modules/account-security/account-security.module'; import { MailModule } from '@/modules/mail/mail.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; +import { MailSearchModule } from '@/modules/mail-search/mail-search.module'; import { AppAuthController } from './app-auth.controller'; import { AuthSessionCleanup } from './auth-session.cleanup'; import { AuthSessionModule } from './auth-session.module'; @@ -40,6 +41,8 @@ import { LoginAttemptModule } from './login-attempt.module'; MailDirectoryModule, // 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기 AccountSecurityModule, + // 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다) + MailSearchModule, ], controllers: [AuthController, AppAuthController, AuthSessionsController], providers: [ diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 44ff825..f3ce85c 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -15,6 +15,7 @@ import { import type { CredentialCheck } from '@/modules/mail/mail.types'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; +import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; import type { AuthSessionEntity } from './auth-session.entity'; import { hashRefreshToken } from './auth-session.policy'; import type { @@ -260,6 +261,8 @@ describe('AuthService', () => { sessionState: jest.Mock; isEnabledInEngine: jest.Mock; }; + /** 검색 색인 대역 (14단계) — 로그인한 직원의 상태 줄 */ + let searchIndex: { enrollAfterLogin: jest.Mock }; let jwt: JwtService; let service: AuthService; let logs: string[]; @@ -278,6 +281,7 @@ describe('AuthService', () => { loginEvents as unknown as LoginEventsService, appPasswords as unknown as AppPasswordsService, twoFactorPolicy as unknown as TwoFactorPolicyService, + searchIndex as unknown as MailSearchIndexService, ); const decode = (token: string) => @@ -330,6 +334,7 @@ describe('AuthService', () => { .mockResolvedValue({ restriction: null, deadline: null }), isEnabledInEngine: jest.fn().mockResolvedValue(false), }; + searchIndex = { enrollAfterLogin: jest.fn().mockResolvedValue(undefined) }; jwt = new JwtService({}); service = build(); @@ -396,6 +401,22 @@ describe('AuthService', () => { expect(directory.recordLogin).not.toHaveBeenCalled(); }); + it('로그인에 성공한 직원의 검색 색인 상태 줄을 만든다 (14단계) — 실패한 로그인 · 코드를 기다리는 로그인은 만들지 않는다', async () => { + await service.login(ARA, PASSWORD, CLIENT_IP); + expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA); + + searchIndex.enrollAfterLogin.mockClear(); + mail.verifyCredentials.mockResolvedValue(INVALID); + expect( + (await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code, + ).toBe('AUTH_004'); + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + expect((await service.login(ARA, PASSWORD, CLIENT_IP)).status).toBe( + 'twoFactorRequired', + ); + expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled(); + }); + it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => { directory.recordLogin.mockRejectedValue(new Error('db down')); @@ -915,6 +936,20 @@ describe('AuthService', () => { await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String)); }); + it('비밀번호가 바뀌면 검색 색인의 멈춤을 풀고 줄을 만든다 (14단계 — 만료로 멈춘 색인) · 바뀌지 않았으면 하지 않는다', async () => { + const current = await loginToken(); + searchIndex.enrollAfterLogin.mockClear(); + + mail.changePassword.mockResolvedValueOnce('new-password-rejected'); + expect((await failureOf(change(PASSWORD, current)))?.code).toBe( + 'VAL_001', + ); + expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled(); + + await expect(change(PASSWORD, current)).resolves.toBeUndefined(); + expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA); + }); + it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => { const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP)) .tokens!.refreshToken; @@ -1806,6 +1841,8 @@ describe('AuthService', () => { expect(decode(result.tokens.accessToken).rst).toBe('passwordChange'); // 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다 expect(directory.recordLogin).not.toHaveBeenCalled(); + // 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계) + expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled(); // 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다 expect(loginEvents.recordSignIn).toHaveBeenCalledWith( expect.objectContaining({ email: ARA, isSuccess: true }), diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index ca8092f..69ad584 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -33,6 +33,7 @@ import type { PasswordChangeOutcome, } from '@/modules/mail/mail.types'; import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; +import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { truncateUserAgent } from '@/shared/utils/user-agent'; @@ -208,6 +209,8 @@ export class AuthService { private readonly appPasswords: AppPasswordsService, /** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */ private readonly twoFactorPolicy: TwoFactorPolicyService, + /** 14단계 — 로그인한 직원의 검색 색인 상태 줄 (처음이면 만들고, 멈춰 있으면 푼다 — 던지지 않는다) */ + private readonly searchIndex: MailSearchIndexService, ) {} /** @@ -329,6 +332,9 @@ export class AuthService { const tokens = await this.startSession(profile, client, state); // 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다) if (!passwordExpired) await this.recordInDirectory(profile); + // 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다) + if (!passwordExpired) + await this.searchIndex.enrollAfterLogin(profile.email); await this.loginEvents.recordSignIn({ method: signInMethodOf(client), email: profile.email, @@ -585,6 +591,8 @@ export class AuthService { refreshToken, twoFactorEnabled, ); + // 검색 색인(14단계) — 비밀번호가 바뀌었으니(만료도 풀렸다) 대리 접근이 다시 된다: 멈춘 줄을 풀고, 없던 줄은 만든다 (던지지 않는다) + await this.searchIndex.enrollAfterLogin(address); } finally { releaseLock?.(); } diff --git a/backend/src/modules/mail-search/conversation-cursor.spec.ts b/backend/src/modules/mail-search/conversation-cursor.spec.ts new file mode 100644 index 0000000..8b39a5a --- /dev/null +++ b/backend/src/modules/mail-search/conversation-cursor.spec.ts @@ -0,0 +1,107 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor'; +import { + conversationCursorMethod, + decodeConversationCursor, + encodeConversationCursor, +} from './conversation-cursor'; +import { encodeIndexCursor } from './search-cursor'; + +function codeOf(run: () => unknown): string | null { + try { + run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('대화 커서 (conversation-cursor — 14단계 C3 · C4)', () => { + const position = { at: new Date('2026-11-02T01:23:45.678Z'), key: 'm9x' }; + + it('자리를 그대로 되돌린다 — 같은 대화는 그 메일, 묶은 목록은 그 메일함 범위에서만', () => { + const thread = encodeConversationCursor('thread', position, 'e1'); + const list = encodeConversationCursor('list', position, 'inbox'); + expect(thread).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeConversationCursor('thread', thread, 'e1')).toEqual(position); + expect(decodeConversationCursor('list', list, 'inbox')).toEqual(position); + + // 다른 메일 · 다른 메일함 · 다른 종류의 커서는 VAL_001 + expect(codeOf(() => decodeConversationCursor('thread', thread, 'e2'))).toBe( + 'VAL_001', + ); + expect(codeOf(() => decodeConversationCursor('list', list, 'sent'))).toBe( + 'VAL_001', + ); + expect(codeOf(() => decodeConversationCursor('list', thread, 'e1'))).toBe( + 'VAL_001', + ); + }); + + it('방식을 커서로 안다 — 그 종류의 색인 커서만 index, 엔진 · 검색 커서 · 알 수 없는 값은 engine', () => { + expect( + conversationCursorMethod( + encodeConversationCursor('thread', position, 'e1'), + 'thread', + ), + ).toBe('index'); + expect( + conversationCursorMethod( + encodeConversationCursor('list', position, 'inbox'), + 'thread', + ), + ).toBe('engine'); + expect( + conversationCursorMethod( + encodeCursor( + { anchor: 'm1', position: 3 }, + { conditions: ['conversation', 'e1'] }, + ), + 'thread', + ), + ).toBe('engine'); + expect( + conversationCursorMethod( + encodeIndexCursor({ receivedAt: position.at, emailId: 'm1' }, 'scope'), + 'list', + ), + ).toBe('engine'); + for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600)]) { + expect(conversationCursorMethod(cursor, 'list')).toBe('engine'); + } + + // `m` 만 맞은 꾸민 커서는 색인 커서로 본다 — 나머지 모양은 decode 가 VAL_001 로 (서비스 입구 — 리뷰 L1) + const onlyMethod = Buffer.from(JSON.stringify({ m: 'ti' })).toString( + 'base64url', + ); + expect(conversationCursorMethod(onlyMethod, 'thread')).toBe('index'); + expect( + codeOf(() => decodeConversationCursor('thread', onlyMethod, 'e1')), + ).toBe('VAL_001'); + expect(conversationCursorMethod(onlyMethod, 'list')).toBe('engine'); + }); + + it('모양이 틀린 커서는 VAL_001 — 시각이 날짜가 아님 · 이상한 키 · 너무 긴 값', () => { + const real = JSON.parse( + Buffer.from( + encodeConversationCursor('list', position, 'inbox'), + 'base64url', + ).toString('utf8'), + ) as Record; + const forge = (patch: Record) => + Buffer.from(JSON.stringify({ ...real, ...patch })).toString('base64url'); + for (const cursor of [ + forge({ t: 9e15 }), + forge({ t: -1 }), + forge({ k: '../x' }), + forge({ k: 'k'.repeat(65) }), + forge({ v: 2 }), + 'not-json', + 'a'.repeat(600), + ]) { + expect( + codeOf(() => decodeConversationCursor('list', cursor, 'inbox')), + ).toBe('VAL_001'); + } + }); +}); diff --git a/backend/src/modules/mail-search/conversation-cursor.ts b/backend/src/modules/mail-search/conversation-cursor.ts new file mode 100644 index 0000000..ed95100 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-cursor.ts @@ -0,0 +1,122 @@ +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants'; +import { SEARCH_ID_COLUMN_LENGTH } from './mail-search.constants'; + +/** + * 색인으로 본 대화의 커서 (14단계 설계 C3 · C4 — 클라이언트에게는 불투명 문자열). + * - 같은 대화(`m: 'ti'`): `{ v: 1, m, t: 받은 시각(ms), k: 메일 id, s }` — 그 줄 다음(오래된 차례 — 받은 시각 · id 가 더 큰 쪽)부터 + * - 묶은 목록(`m: 'li'`): `{ v: 1, m, t: 그 메일함 안 마지막 받은 시각(ms), k: 대화 키, s }` — 그 대화 다음(최신 차례 — 더 작은 쪽)부터 + * 방식(m)이 커서에 실려 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로 이어 간다 — 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로. + * `m` 이 그 종류의 방식이면 색인 커서로 보고 나머지 모양은 서비스 입구에서 decode 로 본다(엔진 · DB 에 묻기 전에 VAL_001 — 3차 리뷰 L1). + * 범위 지문(s)에는 그 메일 id(같은 대화) · 메일함 id(묶은 목록)가 든다 — 다른 대화 · 메일함의 커서를 섞어 쓰면 VAL_001 + */ +const CURSOR_VERSION = 1; +const MAX_CURSOR_LENGTH = 512; +const SCOPE_HASH_LENGTH = 16; +/** 대화 키 · 메일 id 의 최대 길이 — 표의 열(thread_key · email_id) */ +const MAX_KEY_LENGTH = SEARCH_ID_COLUMN_LENGTH; + +/** 커서의 종류 */ +export type ConversationCursorKind = 'thread' | 'list'; + +const METHOD: Readonly> = { + thread: 'ti', + list: 'li', +}; + +/** 이어 갈 자리 — 받은 시각과 그 줄(같은 대화는 메일 id, 묶은 목록은 대화 키) */ +export interface ConversationPosition { + readonly at: Date; + readonly key: string; +} + +interface EncodedCursor { + v: number; + m: string; + t: number; + k: string; + s: string; +} + +function scopeHash(kind: ConversationCursorKind, scope: string): string { + return createHash('sha256') + .update(JSON.stringify([kind, scope])) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +function invalidCursor(reason: string): BusinessException { + return new BusinessException('VAL_001', { + context: { reason: `invalid-cursor:${reason}` }, + }); +} + +/** + * 방식 — `m` 이 그 종류의 색인 방식이면 'index'(판 · 시각 · 키 · 지문은 decodeConversationCursor 가 본다 — 틀리면 VAL_001), + * 아니면 'engine'(엔진 쪽 커서 — 모양이 틀리면 엔진 쪽 decode 가 VAL_001) + */ +export function conversationCursorMethod( + cursor: string, + kind: ConversationCursorKind, +): 'index' | 'engine' { + if (cursor.length > MAX_CURSOR_LENGTH) return 'engine'; + try { + const parsed = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as unknown; + return typeof parsed === 'object' && + parsed !== null && + (parsed as Partial).m === METHOD[kind] + ? 'index' + : 'engine'; + } catch { + return 'engine'; + } +} + +export function encodeConversationCursor( + kind: ConversationCursorKind, + position: ConversationPosition, + scope: string, +): string { + const payload: EncodedCursor = { + v: CURSOR_VERSION, + m: METHOD[kind], + t: position.at.getTime(), + k: position.key, + s: scopeHash(kind, scope), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 VAL_001 */ +export function decodeConversationCursor( + kind: ConversationCursorKind, + cursor: string, + scope: string, +): ConversationPosition { + 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 { v, m, t, k, s } = (parsed ?? {}) as Partial; + const valid = + v === CURSOR_VERSION && + m === METHOD[kind] && + typeof t === 'number' && + Number.isSafeInteger(t) && + t >= 0 && + Number.isFinite(new Date(t).getTime()) && + typeof k === 'string' && + k.length <= MAX_KEY_LENGTH && + MAIL_ID_PATTERN.test(k) && + typeof s === 'string'; + if (!valid) throw invalidCursor('shape'); + if (s !== scopeHash(kind, scope)) throw invalidCursor('other-list'); + return { at: new Date(t), key: k }; +} diff --git a/backend/src/modules/mail-search/conversation-sql.spec.ts b/backend/src/modules/mail-search/conversation-sql.spec.ts new file mode 100644 index 0000000..5eb9645 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-sql.spec.ts @@ -0,0 +1,154 @@ +import { + conversationDetailsSql, + conversationPageSql, + conversationParticipantsSql, + mailboxConversationsSql, +} from './conversation-sql'; + +const USER = 'ara.kim@arasystem.test'; +const EXCLUSIONS = { + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], +}; + +const flat = (sql: string) => sql.replace(/\s+/g, ' '); + +describe('대화의 SQL (conversation-sql — 14단계 C3 · C4)', () => { + it('같은 대화 한 쪽 — 그 메일의 대화 키를 같은 문장에서 읽고(리뷰 L2), 오래된 차례 · 빼는 메일함에만 든 메일 · 내부 발송 초안은 빼되 지금 메일은 넣고, 이어 갈 자리 다음부터', () => { + const after = { at: new Date('2026-11-01T00:00:00Z'), key: 'm3' }; + const query = conversationPageSql(USER, 'm5', EXCLUSIONS, after, 101); + + expect(flat(query.sql)).toBe( + 'SELECT "page"."email_id", "page"."received_at", "page"."total" ' + + 'FROM (SELECT "thread_key" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = $2) AS "current" ' + + 'LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' + + 'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ' + + 'AND ((NOT ("mailbox_ids" <@ $3::text[]) AND NOT ("keywords" && $4::text[])) OR "email_id" = $2) ' + + 'AND ("received_at", "email_id") > ($5::timestamptz, $6) ' + + 'ORDER BY "received_at" ASC, "email_id" ASC LIMIT $7) AS "page" ON TRUE ' + + 'ORDER BY "page"."received_at" ASC, "page"."email_id" ASC', + ); + expect(query.params).toEqual([ + USER, + 'm5', + ['b', 'c'], + ['$ara-sending'], + after.at, + 'm3', + 101, + ]); + + // 빼는 키워드만 있어도 지금 메일은 넣는다 + const keywordOnly = conversationPageSql( + USER, + 'm5', + { excludedMailboxIds: [], excludedKeywords: ['$ara-sending'] }, + null, + 10, + ); + expect(flat(keywordOnly.sql)).toContain( + 'AND ((NOT ("keywords" && $3::text[])) OR "email_id" = $2) ORDER BY', + ); + }); + + it('묶은 목록 한 쪽 — 그 직원의 줄(주소 btree)에서 메일함은 GIN 을 못 쓰는 꼴(= ANY)로 거른 뒤 대화 키로 묶는다', () => { + const query = mailboxConversationsSql(USER, 'a', EXCLUSIONS, null, 21); + + expect(flat(query.sql)).toBe( + 'SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' + + 'WHERE "user_email" = $1 AND $2 = ANY("mailbox_ids") AND NOT ("keywords" && $3::text[]) ' + + 'GROUP BY "thread_key" ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT $4', + ); + expect(query.params).toEqual([USER, 'a', ['$ara-sending'], 21]); + expect(query.sql).not.toContain('"mailbox_ids" @>'); + + const next = mailboxConversationsSql( + USER, + 'a', + EXCLUSIONS, + { at: new Date('2026-11-01T00:00:00Z'), key: 'k9' }, + 21, + ); + expect(flat(next.sql)).toContain( + 'GROUP BY "thread_key" HAVING (max("received_at"), "thread_key") < ($4::timestamptz, $5) ORDER BY', + ); + }); + + it('묶은 목록의 자세히 — 그 쪽의 대화 키만, 통 수는 대화 전체(빼는 메일함에만 든 메일 빼고 — 그 메일함 것은 넣고), 그 메일함 안 id 는 최신부터 상한까지(리뷰 L4) · 보낸 사람 글은 끌어오지 않는다', () => { + const query = conversationDetailsSql( + USER, + 'a', + ['k1', 'k2'], + EXCLUSIONS, + 500, + ); + const sql = flat(query.sql); + + expect(sql).toContain( + 'WHERE "user_email" = $1 AND "thread_key" = ANY($2::text[]) AND NOT ("keywords" && $8::text[]) GROUP BY "thread_key"', + ); + expect(sql).toContain( + 'count(*) FILTER (WHERE (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")))::int AS "message_count"', + ); + expect(sql).toContain( + '(array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE $3 = ANY("mailbox_ids")))[1:$7::int] AS "mailbox_message_ids"', + ); + expect(sql).not.toContain('from_text'); + expect(query.params).toEqual([ + USER, + ['k1', 'k2'], + 'a', + ['b', 'c'], + '$seen', + '$flagged', + 500, + ['$ara-sending'], + ]); + }); + + it('묶은 목록의 참여자 — 첫 보낸 사람 칸에서 주소(소문자)마다 처음 보낸 메일 하나, 그 차례로 상한까지 · 대화마다 (리뷰 L3 · L4)', () => { + const query = conversationParticipantsSql( + USER, + 'a', + ['k1', 'k2'], + EXCLUSIONS, + 10, + ); + const sql = flat(query.sql); + + expect(sql).toContain('FROM unnest($2::text[]) AS "keys"("thread_key")'); + expect(sql).toContain( + 'SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM "mail_search_messages" ' + + 'WHERE "user_email" = $1 AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL ' + + 'AND (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")) AND NOT ("keywords" && $5::text[]) ' + + 'ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC', + ); + expect(sql).toContain( + 'ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT $6) AS "first"', + ); + expect(sql).not.toContain('from_text'); + expect(query.params).toEqual([ + USER, + ['k1', 'k2'], + 'a', + ['b', 'c'], + ['$ara-sending'], + 10, + ]); + }); + + it('빼는 것이 없으면 조건도 없다 (지금 메일의 예외도 없다)', () => { + const none = { excludedMailboxIds: [], excludedKeywords: [] }; + const page = conversationPageSql(USER, 'm5', none, null, 10); + expect(flat(page.sql)).toContain( + 'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ORDER BY', + ); + expect(page.params).toEqual([USER, 'm5', 10]); + + const details = conversationDetailsSql(USER, 'a', ['k1'], none, 500); + expect(flat(details.sql)).toContain( + 'count(*) FILTER (WHERE TRUE)::int AS "message_count"', + ); + expect(flat(details.sql)).toContain('AND TRUE GROUP BY "thread_key"'); + }); +}); diff --git a/backend/src/modules/mail-search/conversation-sql.ts b/backend/src/modules/mail-search/conversation-sql.ts new file mode 100644 index 0000000..0dfb4b8 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-sql.ts @@ -0,0 +1,207 @@ +import { + FLAGGED_KEYWORD, + SEARCH_MESSAGES_TABLE, + SEEN_KEYWORD, +} from './mail-search.constants'; +import type { ConversationPosition } from './conversation-cursor'; +import type { SqlQuery } from './search-sql'; + +/** + * 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다. + * 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 — + * 메일함의 posting 은 회사 전체라(메일함 id 가 계정마다 같은 꼴) GIN 으로 거르지 않고 그 직원의 줄에서만 거른다(리뷰). + * 메일함 칸을 담은 GIN 은 찾기 GIN(lower 식 · 묶음 · 메일함) 하나뿐이고 이 쿼리들은 그것을 쓰지 않는다 + */ + +const TABLE = `"${SEARCH_MESSAGES_TABLE}"`; + +/** 대화에서 빼는 것 — 빼는 메일함에만 든 메일 · 빼는 키워드(내부 발송 초안) */ +export interface ConversationExclusions { + readonly excludedMailboxIds: readonly string[]; + readonly excludedKeywords: readonly string[]; +} + +/** 매개변수를 차례로 쌓는다 */ +class Params { + readonly values: unknown[] = []; + + add(value: unknown): string { + this.values.push(value); + return `$${this.values.length}`; + } +} + +/** 빼는 키워드 — 없으면 늘 참 */ +function keywordCondition( + params: Params, + exclusions: ConversationExclusions, +): string { + return exclusions.excludedKeywords.length > 0 + ? `NOT ("keywords" && ${params.add([...exclusions.excludedKeywords])}::text[])` + : 'TRUE'; +} + +/** 빼는 메일함에만 든 메일이 아니다 — 없으면 늘 참 */ +function mailboxCondition( + params: Params, + exclusions: ConversationExclusions, +): string { + return exclusions.excludedMailboxIds.length > 0 + ? `NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[])` + : 'TRUE'; +} + +/** + * 같은 대화 한 쪽 — 지금 보는 메일(`emailId`)의 대화 키를 같은 문장에서 읽어(3차 리뷰 L2 — 키를 읽은 뒤 그 키가 합쳐져 빈 쪽이 나오지 않게 · 왕복 하나) + * 그 키의 줄을 오래된 차례(받은 시각 · id)로, `after` 다음부터 `limit` 줄. 빼는 메일함에만 든 메일 · 빼는 키워드의 메일(내부 발송 초안)은 빼되 + * 지금 보는 메일은 넣는다(엔진 방식 `jmap-conversations.ts` 의 inConversation 과 같다). + * - 그 메일이 색인에 없으면 줄이 없다. 있는데 쪽이 비면 `email_id` 가 null 인 줄 하나다(LEFT JOIN LATERAL) — 둘을 가른다 + * - `total` 은 이 쪽의 조건(이어 갈 자리 빼고)에 맞는 줄 수 — 첫 쪽에서만 쓴다 + */ +export function conversationPageSql( + user: string, + emailId: string, + exclusions: ConversationExclusions, + after: ConversationPosition | null, + limit: number, +): SqlQuery { + const params = new Params(); + const user$ = params.add(user); + const email$ = params.add(emailId); + const where = [ + `"user_email" = ${user$}`, + `"thread_key" = "current"."thread_key"`, + ]; + const kept = [ + mailboxCondition(params, exclusions), + keywordCondition(params, exclusions), + ].filter((condition) => condition !== 'TRUE'); + if (kept.length > 0) { + where.push(`((${kept.join(' AND ')}) OR "email_id" = ${email$})`); + } + if (after !== null) { + where.push( + `("received_at", "email_id") > (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`, + ); + } + return { + sql: + `SELECT "page"."email_id", "page"."received_at", "page"."total"` + + ` FROM (SELECT "thread_key" FROM ${TABLE} WHERE "user_email" = ${user$} AND "email_id" = ${email$}) AS "current"` + + ` LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM ${TABLE}` + + ` WHERE ${where.join(' AND ')} ORDER BY "received_at" ASC, "email_id" ASC LIMIT ${params.add(limit)}) AS "page" ON TRUE` + + ` ORDER BY "page"."received_at" ASC, "page"."email_id" ASC`, + params: params.values, + }; +} + +/** + * 묶은 목록 한 쪽 — 그 메일함 안의 대화(대화 키)마다 그 메일함 안 마지막 받은 시각, 최신부터 `limit` 개. + * 그 직원의 줄(주소 btree)에서만 메일함 · 키워드로 거른 뒤 대화 키로 묶는다. `after` 면 그 대화 다음부터(받은 시각 · 키가 더 작은 쪽). + * `total` 은 묶은 대화 수(이어 갈 자리 다음의 것) — 첫 쪽에서만 쓴다 + */ +export function mailboxConversationsSql( + user: string, + mailboxId: string, + exclusions: ConversationExclusions, + after: ConversationPosition | null, + limit: number, +): SqlQuery { + const params = new Params(); + const where = [ + `"user_email" = ${params.add(user)}`, + `${params.add(mailboxId)} = ANY("mailbox_ids")`, + keywordCondition(params, exclusions), + ]; + const having = + after === null + ? '' + : ` HAVING (max("received_at"), "thread_key") < (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`; + return { + sql: `SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM ${TABLE} WHERE ${where.join(' AND ')} GROUP BY "thread_key"${having} ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT ${params.add(limit)}`, + params: params.values, + }; +} + +/** + * 묶은 목록의 대화마다 자세히 — 그 쪽의 대화 키만(주소 + 대화 키 btree). + * - 대화 전체(빼는 메일함에만 든 메일은 빼되 그 메일함의 메일은 넣는다): 통 수 + * - 그 메일함 안: 메일 id(최신부터 `maxIds` 통까지 — 첫째가 마지막 메일, 3차 리뷰 L4) · 모두 읽음 · 하나라도 중요 · 하나라도 첨부 + * 참여자는 conversationParticipantsSql 이 따로 읽는다(주소마다 한 번 · 상한까지 — 대화 전체의 보낸 사람 글을 끌어오지 않는다) + */ +export function conversationDetailsSql( + user: string, + mailboxId: string, + threadKeys: readonly string[], + exclusions: ConversationExclusions, + maxIds: number, +): SqlQuery { + const params = new Params(); + const user$ = params.add(user); + const keys$ = params.add([...threadKeys]); + const mailbox$ = params.add(mailboxId); + const inMailbox = `${mailbox$} = ANY("mailbox_ids")`; + const kept = keptInConversation(params, exclusions, inMailbox); + const seen$ = params.add(SEEN_KEYWORD); + const flagged$ = params.add(FLAGGED_KEYWORD); + const maxIds$ = params.add(maxIds); + const keywords = keywordCondition(params, exclusions); + return { + sql: + `SELECT "thread_key",` + + ` count(*) FILTER (WHERE ${kept})::int AS "message_count",` + + ` (array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE ${inMailbox}))[1:${maxIds$}::int] AS "mailbox_message_ids",` + + ` coalesce(bool_and(${seen$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), true) AS "is_read",` + + ` coalesce(bool_or(${flagged$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), false) AS "is_flagged",` + + ` coalesce(bool_or("has_attachment") FILTER (WHERE ${inMailbox}), false) AS "has_attachment"` + + ` FROM ${TABLE} WHERE "user_email" = ${user$} AND "thread_key" = ANY(${keys$}::text[]) AND ${keywords} GROUP BY "thread_key"`, + params: params.values, + }; +} + +/** + * 묶은 목록의 대화마다 참여자 — 첫 보낸 사람 칸(from_email · from_name — 3차 리뷰 L3)에서 주소(소문자)마다 처음 보낸 메일 하나, + * 그 차례(받은 시각 · id)로 `max` 명까지(대화마다 LATERAL — 주소 + 대화 키 btree). 대화의 범위는 자세히(통 수)와 같다 + */ +export function conversationParticipantsSql( + user: string, + mailboxId: string, + threadKeys: readonly string[], + exclusions: ConversationExclusions, + max: number, +): SqlQuery { + const params = new Params(); + const user$ = params.add(user); + const keys$ = params.add([...threadKeys]); + const mailbox$ = params.add(mailboxId); + const kept = keptInConversation( + params, + exclusions, + `${mailbox$} = ANY("mailbox_ids")`, + ); + const keywords = keywordCondition(params, exclusions); + const max$ = params.add(max); + return { + sql: + `SELECT "keys"."thread_key", "first"."from_email", "first"."from_name"` + + ` FROM unnest(${keys$}::text[]) AS "keys"("thread_key")` + + ` CROSS JOIN LATERAL (SELECT "senders"."from_email", "senders"."from_name", "senders"."received_at", "senders"."email_id" FROM (` + + `SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM ${TABLE}` + + ` WHERE "user_email" = ${user$} AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL AND ${kept} AND ${keywords}` + + ` ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC) AS "senders"` + + ` ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT ${max$}) AS "first"` + + ` ORDER BY "keys"."thread_key", "first"."received_at" ASC, "first"."email_id" ASC`, + params: params.values, + }; +} + +/** 대화에 드는 줄 — 빼는 메일함에만 든 메일이 아니거나 `orCondition`(그 메일함의 메일) */ +function keptInConversation( + params: Params, + exclusions: ConversationExclusions, + orCondition: string, +): string { + return exclusions.excludedMailboxIds.length > 0 + ? `(NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[]) OR ${orCondition})` + : 'TRUE'; +} diff --git a/backend/src/modules/mail-search/dto/search-index-status.dto.ts b/backend/src/modules/mail-search/dto/search-index-status.dto.ts new file mode 100644 index 0000000..a370f54 --- /dev/null +++ b/backend/src/modules/mail-search/dto/search-index-status.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import type { + SearchIndexStatus, + SearchIndexStatusView, +} from '@/modules/mail-search/mail-search.types'; + +/** GET /api/search-index 의 응답 (14단계 — 화면의 "검색 준비 중" 줄) */ +export class SearchIndexStatusDto implements SearchIndexStatusView { + @ApiProperty({ + description: + '색인 상태 — building: 지난 메일을 채우는 중(그동안 검색은 엔진 방식 — 낱말이 통째로 같은 메일만) · ready: 다 만들었음', + enum: ['building', 'ready'], + example: 'building', + }) + status!: SearchIndexStatus; + + @ApiProperty({ + description: '채운 메일 수 — 다 만들었으면 그때 색인에 든 수', + example: 1200, + }) + done!: number; + + @ApiProperty({ + description: + '채울 메일 수(채우기를 시작할 때의 메일 수) — 아직 시작하지 않았으면 null. 다 만들었으면 done 과 같다', + type: Number, + nullable: true, + example: 4800, + }) + total!: number | null; +} diff --git a/backend/src/modules/mail-search/index-document.spec.ts b/backend/src/modules/mail-search/index-document.spec.ts new file mode 100644 index 0000000..6bba378 --- /dev/null +++ b/backend/src/modules/mail-search/index-document.spec.ts @@ -0,0 +1,232 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + firstSender, + isIndexableId, + referencesOf, + storableText, + takeCodePoints, + toIndexRow, +} from './index-document'; +import type { IndexDocument } from './mail-index-source'; +import { + INDEX_ADDRESSES_MAX, + INDEX_BODY_MAX_CHARS, + INDEX_FROM_NAME_MAX_CHARS, + INDEX_REFS_MAX, + INDEX_SUBJECT_MAX_CHARS, + SEARCH_EMAIL_COLUMN_LENGTH, +} from './mail-search.constants'; + +function documentOf(overrides: Partial = {}): IndexDocument { + return { + id: 'e1', + threadId: 't1', + mailboxIds: ['inbox'], + keywords: ['$seen'], + receivedAt: '2026-11-05T01:02:03Z', + size: 1234, + messageIds: ['m1@example.com'], + inReplyTo: [], + references: [], + subject: 'Re: [공지] 11월 계약서', + from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + to: [{ name: null, email: 'ara.kim@arasystem.test' }], + cc: [{ name: 'Partner', email: 'partner@example.com' }], + attachmentNames: ['계약서.PDF'], + hasAttachment: true, + preview: '계약서를 보내 드립니다', + bodyText: '안녕하세요.\n계약서를 보내 드립니다.', + ...overrides, + }; +} + +describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => { + it('원문은 NFC 그대로, 접기 칸은 소문자 · 반각 — 받는 사람 칸은 받는 사람과 참조를 함께', () => { + const row = toIndexRow(documentOf()); + expect(row).toMatchObject({ + emailId: 'e1', + mailboxIds: ['inbox'], + keywords: ['$seen'], + receivedAt: new Date('2026-11-05T01:02:03Z'), + size: 1234, + messageId: 'm1@example.com', + refs: [], + subject: 'Re: [공지] 11월 계약서', + subjectBase: '11월 계약서', + fromText: '이도윤 ', + toText: 'ara.kim@arasystem.test', + ccText: 'Partner ', + attachmentNames: '계약서.PDF', + hasAttachment: true, + preview: '계약서를 보내 드립니다', + bodyText: '안녕하세요.\n계약서를 보내 드립니다.', + foldFrom: '이도윤 ', + // 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2) + foldTo: 'ara.kim@arasystem.test partner ', + foldSubject: 're: [공지] 11월 계약서', + foldAttachment: '계약서.pdf', + foldBody: '안녕하세요. 계약서를 보내 드립니다.', + threadKey: 'e1', + engineThreadId: 't1', + }); + // 묶음은 다섯 칸에서 — 칸 경계를 넘지 않는다 (보낸 사람 끝 + 받는 사람 처음 같은 묶음이 없다) + expect(row.grams).toEqual( + expect.arrayContaining(['계약', '약서', 're', '공지']), + ); + expect(row.grams).not.toContain('>a'); + expect(new Set(row.grams).size).toBe(row.grams.length); + }); + + it('PostgreSQL 에 넣을 수 없는 글자(NUL · 짝 없는 서로게이트)는 U+FFFD — 위치는 그대로', () => { + const row = toIndexRow( + documentOf({ subject: 'a\u0000b\ud800c', bodyText: '\udc00본문' }), + ); + expect(row.subject).toBe('a\ufffdb\ufffdc'); + expect(row.bodyText).toBe('\ufffd본문'); + expect(row.foldSubject.length).toBe(row.subject.length); + }); + + it('풀어 쓴 한글(NFD)은 NFC 로 모은 원문을 담는다', () => { + const row = toIndexRow( + documentOf({ subject: '\u1112\u1161\u11ab\u1100\u1173\u11af 메일' }), + ); + expect(row.subject).toBe('한글 메일'); + }); + + it('본문은 앞 INDEX_BODY_MAX_CHARS 코드 포인트 · 제목도 상한까지 · 주소는 INDEX_ADDRESSES_MAX 개까지', () => { + const row = toIndexRow( + documentOf({ + bodyText: '가'.repeat(INDEX_BODY_MAX_CHARS + 500), + subject: 'S'.repeat(INDEX_SUBJECT_MAX_CHARS * 2), + to: Array.from({ length: INDEX_ADDRESSES_MAX + 50 }, (_, index) => ({ + name: null, + email: `p${index}@example.com`, + })), + }), + ); + expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS); + expect(row.subject).toHaveLength(INDEX_SUBJECT_MAX_CHARS); + expect(row.toText.split('\n')).toHaveLength(INDEX_ADDRESSES_MAX); + }); + + it('주소의 이름 · 주소 가운데 있는 것만 · 빈 첨부 이름은 뺀다 · 키워드 · 메일함은 겹치지 않게', () => { + const row = toIndexRow( + documentOf({ + from: [ + { name: ' ', email: 'a@example.com' }, + { name: '이름만', email: null }, + { name: null, email: null }, + ], + attachmentNames: ['', ' 보고서.hwp '], + keywords: ['$seen', '$seen', '$flagged'], + mailboxIds: ['inbox', 'inbox'], + }), + ); + expect(row.fromText).toBe('a@example.com\n이름만'); + expect(row.attachmentNames).toBe('보고서.hwp'); + expect(row.keywords).toEqual(['$seen', '$flagged']); + expect(row.mailboxIds).toEqual(['inbox']); + }); + + it('첫 보낸 사람 칸(참여자 — 리뷰 L3) — 주소 · 이름을 따로, 이름 안의 `<주소>` 꼴은 주소가 되지 않는다', () => { + // 주소 없이 이름만 `대표 ` — 보낸 사람 글(조건 검색)에는 남지만 참여자 칸은 비운다 + const nameOnly = toIndexRow( + documentOf({ from: [{ name: '대표 ', email: null }] }), + ); + expect(nameOnly.fromText).toBe('대표 '); + expect(nameOnly.fromEmail).toBeNull(); + expect(nameOnly.fromName).toBeNull(); + + // 이름이 `<다른 주소>` 를 품어도 칸의 주소는 엔진이 준 주소다 + const disguised = toIndexRow( + documentOf({ + from: [ + { name: ' 대표 ', email: ' real@example.net ' }, + { name: '둘째', email: 'second@example.com' }, + ], + }), + ); + expect(disguised.fromEmail).toBe('real@example.net'); + expect(disguised.fromName).toBe('대표 '); + + expect(toIndexRow(documentOf({ from: [] })).fromEmail).toBeNull(); + }); + + it('첫 보낸 사람 — 이름은 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다) · 주소가 열보다 길면 담지 않는다', () => { + const long = firstSender([ + { + name: `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀`, + email: 'a@example.com', + }, + ]); + expect([...(long.name ?? '')]).toHaveLength(INDEX_FROM_NAME_MAX_CHARS); + expect(long.name?.endsWith('😀')).toBe(true); + + const tooLong = `${'a'.repeat(SEARCH_EMAIL_COLUMN_LENGTH)}@example.com`; + expect(firstSender([{ name: '긴 주소', email: tooLong }])).toEqual({ + email: null, + name: null, + }); + expect(firstSender([{ name: ' ', email: 'a@example.com' }])).toEqual({ + email: 'a@example.com', + name: null, + }); + }); + + it('받은 시각이 날짜가 아니면 1970-01-01 · 크기는 32비트 정수 안 · Message-ID 가 없으면 null · 긴 대화 id 는 null', () => { + const row = toIndexRow( + documentOf({ + receivedAt: 'not-a-date', + size: Number.MAX_SAFE_INTEGER, + messageIds: [], + threadId: 'x'.repeat(100), + }), + ); + expect(row.receivedAt.getTime()).toBe(0); + expect(row.size).toBe(2_147_483_647); + expect(row.messageId).toBeNull(); + expect(row.engineThreadId).toBeNull(); + }); + + it('1MiB 본문도 길이에 비례해 담는다 (자르기 전에 통째로 NFC 하지 않는다)', () => { + const body = '계약서를 보내 드립니다. Project plan '.repeat(40_000); + const cpu = startCpuTimer(); + const row = toIndexRow(documentOf({ bodyText: body })); + expect(row.bodyText).toHaveLength(INDEX_BODY_MAX_CHARS); + expect(cpu()).toBeLessThan(3_000); + }); +}); + +describe('referencesOf (답장 관계)', () => { + it('In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게', () => { + expect(referencesOf(['c@x'], ['a@x', 'b@x', 'c@x'])).toEqual([ + 'c@x', + 'b@x', + 'a@x', + ]); + }); + + it('앞 INDEX_REFS_MAX 개 · 빈 것 · 너무 긴 id 는 뺀다', () => { + const many = Array.from({ length: 80 }, (_, index) => `r${index}@x`); + const refs = referencesOf([], many); + expect(refs).toHaveLength(INDEX_REFS_MAX); + expect(refs[0]).toBe('r79@x'); + expect(referencesOf(['', ' ', 'x'.repeat(300)], ['ok@x'])).toEqual([ + 'ok@x', + ]); + }); +}); + +describe('takeCodePoints · storableText · isIndexableId', () => { + it('서로게이트 쌍을 가르지 않는다', () => { + expect(takeCodePoints('😀😀😀', 2)).toBe('😀😀'); + expect(takeCodePoints('ab', 5)).toBe('ab'); + expect(storableText('😀😀😀', 1)).toBe('😀'); + }); + + it('엔진 id 는 비지 않고 열 길이 안이어야 한다', () => { + expect(isIndexableId('abc')).toBe(true); + expect(isIndexableId('')).toBe(false); + expect(isIndexableId('x'.repeat(65))).toBe(false); + }); +}); diff --git a/backend/src/modules/mail-search/index-document.ts b/backend/src/modules/mail-search/index-document.ts new file mode 100644 index 0000000..167a6ae --- /dev/null +++ b/backend/src/modules/mail-search/index-document.ts @@ -0,0 +1,247 @@ +import { foldText, gramsOf } from '@/shared/utils/search-text'; +import { subjectBase } from '@/shared/utils/subject-base'; +import type { IndexAddress, IndexDocument } from './mail-index-source'; +import { + INDEX_ADDRESSES_MAX, + INDEX_ADDRESS_LINE_MAX_CHARS, + INDEX_ATTACHMENT_NAMES_MAX, + INDEX_ATTACHMENT_NAME_MAX_CHARS, + INDEX_BODY_MAX_CHARS, + INDEX_FROM_NAME_MAX_CHARS, + INDEX_MESSAGE_ID_MAX_LENGTH, + INDEX_PREVIEW_MAX_CHARS, + INDEX_REFS_MAX, + INDEX_SUBJECT_MAX_CHARS, + SEARCH_EMAIL_COLUMN_LENGTH, + SEARCH_ID_COLUMN_LENGTH, +} from './mail-search.constants'; + +/** + * 엔진의 메일 한 통 → 색인 한 줄의 값 (순수 함수 — 14단계 설계 A1 · B1). + * - 글 칸은 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)으로 두고, 칸마다 길이 상한(mail-search.constants.ts)에서 자른다 — 코드 포인트로 세고 서로게이트 쌍을 가르지 않는다 + * - PostgreSQL 의 text 는 NUL(U+0000)을 받지 않고 짝이 없는 서로게이트는 UTF-8 로 보낼 때 U+FFFD 가 된다 — 둘 다 먼저 U+FFFD 로 바꾼다 + * (한 단위를 한 단위로 — 위치가 바뀌지 않는다. 접은 글 · 묶음이 저장된 원문과 같아지게) + * - 접기 글(fold_*)은 foldText — 원문과 위치가 같다. 받는 사람 칸은 받는 사람과 참조를 줄바꿈으로 이어 접는다 + * - 묶음(grams)은 다섯 접기 칸에서 따로 뽑아 합친다(칸 경계를 넘지 않는다, 겹치지 않게) + * - 답장 관계(refs): In-Reply-To 다음 References 를 가까운 것(뒤)부터, 겹치지 않게 앞 INDEX_REFS_MAX 개. 너무 긴 id 는 뺀다 + * - 첫 보낸 사람은 주소 · 이름을 따로도 둔다(from_email · from_name — 묶은 목록의 참여자, 3차 리뷰 L3). 주소가 없으면 null — 이름 안의 `<주소>` 꼴은 주소로 읽지 않는다 + * - 대화 키는 넣을 때 그 메일 id 다 — 색인에 쓸 때 이어진 메일의 키와 합친다(3차 — 설계 B4, mail-search-index.repository.ts 의 linkThreads) + */ + +/** 색인 한 줄의 값 — 표의 칸과 같다 (user_email · indexed_at 은 쓰는 쪽이 붙인다) */ +export interface IndexRowValues { + readonly emailId: string; + readonly mailboxIds: string[]; + readonly keywords: string[]; + readonly receivedAt: Date; + readonly size: number; + readonly messageId: string | null; + readonly refs: string[]; + readonly subject: string; + readonly subjectBase: string; + readonly fromText: string; + readonly fromEmail: string | null; + readonly fromName: string | null; + readonly toText: string; + readonly ccText: string; + readonly attachmentNames: string; + readonly hasAttachment: boolean; + readonly preview: string; + readonly bodyText: string; + readonly foldFrom: string; + readonly foldTo: string; + readonly foldSubject: string; + readonly foldAttachment: string; + readonly foldBody: string; + readonly grams: string[]; + readonly threadKey: string; + readonly engineThreadId: string | null; +} + +/** NUL · 짝이 없는 서로게이트 — PostgreSQL text 에 그대로 넣을 수 없는 글자 */ +const UNSTORABLE = + // eslint-disable-next-line no-control-regex + /\u0000|[\ud800-\udbff](?![\udc00-\udfff])|(?= 0xd800 && + code <= 0xdbff && + units + 1 < text.length && + (text.charCodeAt(units + 1) & 0xfc00) === 0xdc00 + ? 2 + : 1; + points += 1; + } + return text.slice(0, units); +} + +/** 저장할 원문 — 넣을 수 없는 글자를 바꾸고, NFC 로 모으고, 코드 포인트 `max` 개까지 */ +export function storableText(text: string, max: number): string { + // 아주 긴 글(본문 1MiB 등)을 통째로 NFC 하지 않게 먼저 넉넉히 자른다 + const head = + text.length > max * NFC_SLACK ? text.slice(0, max * NFC_SLACK) : text; + return takeCodePoints( + head.replace(UNSTORABLE, REPLACEMENT_CHARACTER).normalize('NFC'), + max, + ); +} + +/** "이름 <주소>" — 이름이 없으면 주소, 주소가 없으면 이름 */ +function addressLine(address: IndexAddress): string { + const name = (address.name ?? '').trim(); + const email = (address.email ?? '').trim(); + const line = name && email ? `${name} <${email}>` : name || email; + return storableText(line, INDEX_ADDRESS_LINE_MAX_CHARS); +} + +/** + * 참여자 이름 — 앞뒤 공백을 지운 NFC, 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다), 비면 null. + * 색인의 첫 보낸 사람 칸(firstSender)과 엔진 방식 대화의 참여자(modules/mail/jmap/jmap-conversations.ts)가 같은 자르기를 쓴다 + */ +export function participantName( + name: string | null | undefined, +): string | null { + const trimmed = storableText( + (name ?? '').trim(), + INDEX_FROM_NAME_MAX_CHARS, + ).trim(); + return trimmed === '' ? null : trimmed; +} + +/** + * 첫 보낸 사람 — 주소(앞뒤 공백을 지운 NFC, SEARCH_EMAIL_COLUMN_LENGTH 를 넘거나 없으면 null)와 이름(participantName). + * 목록 요약(jmap.mapper.ts 의 toAddress — `from[0]`)과 같은 사람이다. 주소가 없으면 이름도 두지 않는다(참여자는 주소로 센다) + */ +export function firstSender(addresses: readonly IndexAddress[]): { + readonly email: string | null; + readonly name: string | null; +} { + const first = addresses[0]; + const email = storableText( + (first?.email ?? '').trim(), + SEARCH_EMAIL_COLUMN_LENGTH + 1, + ); + if (email === '' || email.length > SEARCH_EMAIL_COLUMN_LENGTH) { + return { email: null, name: null }; + } + return { email, name: participantName(first?.name) }; +} + +function addressText(addresses: readonly IndexAddress[]): string { + return addresses + .slice(0, INDEX_ADDRESSES_MAX) + .map(addressLine) + .filter((line) => line !== '') + .join('\n'); +} + +/** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터, 겹치지 않게 앞 INDEX_REFS_MAX 개 */ +export function referencesOf( + inReplyTo: readonly string[], + references: readonly string[], +): string[] { + const refs: string[] = []; + const seen = new Set(); + for (const raw of [...inReplyTo, ...[...references].reverse()]) { + const ref = storableText(raw.trim(), INDEX_MESSAGE_ID_MAX_LENGTH + 1); + if (ref === '' || ref.length > INDEX_MESSAGE_ID_MAX_LENGTH) continue; + if (seen.has(ref)) continue; + seen.add(ref); + refs.push(ref); + if (refs.length >= INDEX_REFS_MAX) break; + } + return refs; +} + +/** 첫 Message-ID — 없거나 너무 길면 null */ +function firstMessageId(messageIds: readonly string[]): string | null { + const first = storableText( + (messageIds[0] ?? '').trim(), + INDEX_MESSAGE_ID_MAX_LENGTH + 1, + ); + return first === '' || first.length > INDEX_MESSAGE_ID_MAX_LENGTH + ? null + : first; +} + +/** 열 길이 안의 엔진 id 인가 */ +export function isIndexableId(id: string): boolean { + return id !== '' && id.length <= SEARCH_ID_COLUMN_LENGTH; +} + +/** 엔진의 메일 한 통 → 색인 한 줄의 값. 받은 시각이 날짜가 아니면 1970-01-01 (목록과 같다) */ +export function toIndexRow(document: IndexDocument): IndexRowValues { + const subject = storableText(document.subject, INDEX_SUBJECT_MAX_CHARS); + const fromText = addressText(document.from); + const sender = firstSender(document.from); + const toText = addressText(document.to); + const ccText = addressText(document.cc); + const attachmentNames = document.attachmentNames + .slice(0, INDEX_ATTACHMENT_NAMES_MAX) + .map((name) => storableText(name.trim(), INDEX_ATTACHMENT_NAME_MAX_CHARS)) + .filter((name) => name !== '') + .join('\n'); + const bodyText = storableText(document.bodyText, INDEX_BODY_MAX_CHARS); + + const foldFrom = foldText(fromText); + const foldTo = foldText([toText, ccText].filter(Boolean).join('\n')); + const foldSubject = foldText(subject); + const foldAttachment = foldText(attachmentNames); + const foldBody = foldText(bodyText); + const grams = new Set(); + for (const fold of [ + foldFrom, + foldTo, + foldSubject, + foldAttachment, + foldBody, + ]) { + gramsOf(fold, grams); + } + + const receivedAt = Date.parse(document.receivedAt); + const threadId = document.threadId ?? ''; + return { + emailId: document.id, + mailboxIds: [...new Set(document.mailboxIds)], + keywords: [...new Set(document.keywords)], + receivedAt: new Date(Number.isNaN(receivedAt) ? 0 : receivedAt), + size: Math.min(MAX_INT32, Math.max(0, Math.trunc(document.size) || 0)), + messageId: firstMessageId(document.messageIds), + refs: referencesOf(document.inReplyTo, document.references), + subject, + subjectBase: subjectBase(subject), + fromText, + fromEmail: sender.email, + fromName: sender.name, + toText, + ccText, + attachmentNames, + hasAttachment: document.hasAttachment, + preview: storableText(document.preview, INDEX_PREVIEW_MAX_CHARS), + bodyText, + foldFrom, + foldTo, + foldSubject, + foldAttachment, + foldBody, + grams: [...grams], + // 처음 키는 그 메일 id — 색인에 쓸 때 이어진 메일의 키와 합친다(mail-search-index.repository.ts 의 linkThreads) + threadKey: document.id, + engineThreadId: isIndexableId(threadId) ? threadId : null, + }; +} diff --git a/backend/src/modules/mail-search/mail-conversation-query.service.ts b/backend/src/modules/mail-search/mail-conversation-query.service.ts new file mode 100644 index 0000000..3ddba32 --- /dev/null +++ b/backend/src/modules/mail-search/mail-conversation-query.service.ts @@ -0,0 +1,248 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { + encodeConversationCursor, + type ConversationPosition, +} from './conversation-cursor'; +import { + conversationDetailsSql, + conversationPageSql, + conversationParticipantsSql, + mailboxConversationsSql, + type ConversationExclusions, +} from './conversation-sql'; +import { + CONVERSATION_MESSAGE_IDS_MAX, + CONVERSATION_PARTICIPANTS_MAX, +} from './mail-search.constants'; +import type { ConversationParticipant } from './mail-search.types'; + +/** 같은 대화 한 쪽 — 메일 id 만(요약은 부르는 쪽이 엔진에서 읽는다) */ +export interface IndexConversationPage { + readonly ids: readonly string[]; + readonly hasNext: boolean; + readonly nextCursor: string | null; + /** 첫 쪽에서만 — 다음 쪽은 null */ + readonly total: number | null; +} + +/** 묶은 목록의 대화 하나 (색인) — 마지막 메일의 요약은 부르는 쪽이 엔진에서 읽는다 */ +export interface IndexConversation { + readonly key: string; + /** 그 메일함 안 마지막 메일 */ + readonly latestId: string; + /** 그 메일함 안 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 */ + readonly mailboxMessageIds: readonly string[]; + readonly messageCount: number; + readonly participants: readonly ConversationParticipant[]; + readonly isRead: boolean; + readonly isFlagged: boolean; + readonly hasAttachment: boolean; +} + +export interface IndexConversationList { + readonly items: readonly IndexConversation[]; + readonly hasNext: boolean; + readonly nextCursor: string | null; + readonly total: number | null; +} + +/** 쪽을 읽는 자리 — 커서는 서비스 입구에서 읽어(VAL_001 — 엔진 · DB 에 묻기 전) 자리로 넘긴다 (3차 리뷰 L1) */ +export interface ConversationPageRequest { + readonly limit: number; + readonly after: ConversationPosition | null; +} + +interface PageRow { + /** 그 메일은 색인에 있는데 쪽이 비었으면 null (LEFT JOIN LATERAL) */ + readonly email_id: string | null; + readonly received_at: Date | string | null; + readonly total: number | null; +} + +interface ListRow { + readonly thread_key: string; + readonly latest_at: Date | string; + readonly total: number; +} + +interface DetailRow { + readonly thread_key: string; + readonly message_count: number; + readonly mailbox_message_ids: string[] | null; + readonly is_read: boolean; + readonly is_flagged: boolean; + readonly has_attachment: boolean; +} + +interface ParticipantRow { + readonly thread_key: string; + readonly from_email: string; + readonly from_name: string | null; +} + +function dateOf(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */ +function normalize(user: string): string { + return user.trim().toLowerCase(); +} + +/** + * 색인으로 본 대화 (14단계 설계 C3 · C4) — 대화 키(B4)로 묶는다. 엔진을 모른다(빼는 메일함 · 키워드는 부르는 쪽이 엔진에서 받아 준다). + * - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread) + * - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터 + * CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지) + * 모든 쿼리는 그 직원 주소(btree)로 먼저 좁히고 메일함은 그 줄에서 거른다 — 메일함의 GIN posting(회사 전체)을 쓰지 않는다(conversation-sql.ts) + */ +@Injectable() +export class MailConversationQueryService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * 같은 대화 한 쪽 — 오래된 차례. 지금 보는 메일(`emailId`)은 빼는 메일함에 있어도 넣는다. + * 그 메일이 색인에 없으면 null (부르는 쪽이 첫 쪽이면 엔진으로, 이어 가는 쪽이면 RES_001) + */ + async conversation( + user: string, + emailId: string, + exclusions: ConversationExclusions, + page: ConversationPageRequest, + ): Promise { + const query = conversationPageSql( + normalize(user), + emailId, + exclusions, + page.after, + page.limit + 1, + ); + const rows = await this.dataSource.query( + query.sql, + query.params, + ); + if (rows.length === 0) return null; + const found = rows.flatMap((row) => + row.email_id !== null && row.received_at !== null + ? [ + { + id: row.email_id, + at: dateOf(row.received_at), + total: row.total, + }, + ] + : [], + ); + const shown = found.slice(0, page.limit); + const hasNext = found.length > page.limit; + const last = shown[shown.length - 1]; + return { + ids: shown.map((row) => row.id), + hasNext, + nextCursor: + hasNext && last + ? encodeConversationCursor( + 'thread', + { at: last.at, key: last.id }, + emailId, + ) + : null, + total: page.after === null ? Number(found[0]?.total ?? 0) : null, + }; + } + + /** 묶은 목록 한 쪽 — 그 메일함 안 마지막 받은 시각 최신부터 */ + async mailboxConversations( + user: string, + mailboxId: string, + exclusions: ConversationExclusions, + page: ConversationPageRequest, + ): Promise { + const key = normalize(user); + const { after } = page; + // 쪽 · 자세히 · 참여자는 같은 스냅숏에서 — 그사이 온 메일로 대화 키가 합쳐져도 한 쪽 안에서는 어긋나지 않게 + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + const listQuery = mailboxConversationsSql( + key, + mailboxId, + exclusions, + after, + page.limit + 1, + ); + const rows = await manager.query( + listQuery.sql, + listQuery.params, + ); + const shown = rows.slice(0, page.limit); + const hasNext = rows.length > page.limit; + const keys = shown.map((row) => row.thread_key); + let detailRows: DetailRow[] = []; + let participantRows: ParticipantRow[] = []; + if (keys.length > 0) { + const detailQuery = conversationDetailsSql( + key, + mailboxId, + keys, + exclusions, + CONVERSATION_MESSAGE_IDS_MAX, + ); + detailRows = await manager.query( + detailQuery.sql, + detailQuery.params, + ); + const participantQuery = conversationParticipantsSql( + key, + mailboxId, + keys, + exclusions, + CONVERSATION_PARTICIPANTS_MAX, + ); + participantRows = await manager.query( + participantQuery.sql, + participantQuery.params, + ); + } + const details = new Map(detailRows.map((row) => [row.thread_key, row])); + const participants = new Map(); + for (const row of participantRows) { + const list = participants.get(row.thread_key) ?? []; + list.push({ name: row.from_name, email: row.from_email }); + participants.set(row.thread_key, list); + } + const items = shown.flatMap((row): IndexConversation[] => { + const detail = details.get(row.thread_key); + const ids = detail?.mailbox_message_ids ?? []; + if (!detail || ids.length === 0) return []; + return [ + { + key: row.thread_key, + latestId: ids[0], + mailboxMessageIds: ids, + messageCount: Math.max(Number(detail.message_count), 1), + participants: participants.get(row.thread_key) ?? [], + isRead: detail.is_read, + isFlagged: detail.is_flagged, + hasAttachment: detail.has_attachment, + }, + ]; + }); + const last = shown[shown.length - 1]; + return { + items, + hasNext, + nextCursor: + hasNext && last + ? encodeConversationCursor( + 'list', + { at: dateOf(last.latest_at), key: last.thread_key }, + mailboxId, + ) + : null, + total: after === null ? Number(rows[0]?.total ?? 0) : null, + }; + }); + } +} diff --git a/backend/src/modules/mail-search/mail-index-source.ts b/backend/src/modules/mail-search/mail-index-source.ts new file mode 100644 index 0000000..75e5960 --- /dev/null +++ b/backend/src/modules/mail-search/mail-index-source.ts @@ -0,0 +1,132 @@ +/** + * 메일 검색 색인이 메일 엔진에서 읽는 것 — 포트 (14단계 설계 B2). 엔진을 모르는 쪽(맞추기 · 채우기)은 이것만 안다. + * 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-index-source.ts`. 엔진을 바꿀 때는 mail-search.module.ts 의 useClass 만 바꾼다. + * + * 실패는 BusinessException 으로 던진다 — 대리 접근이 막힌 계정은 메일 포트와 같은 예외(PasswordExpiredException · AccountLoginDisabledException · + * AUTH_001), 엔진 장애 MAIL_001, 엔진 한도 REQ_001. + */ +export const MAIL_INDEX_SOURCE = Symbol('MAIL_INDEX_SOURCE'); + +/** 한 번의 엔진 읽기의 끝 시각(epoch ms) — 넘으면 엔진 호출을 그만둔다 (호출마다 남은 시간만큼만 기다린다) */ +export interface IndexReadOptions { + readonly deadline: number; +} + +/** + * 차례의 끝 시각에 닿았다 — 남은 시간이 INDEX_CALL_MIN_MS 보다 적어 엔진 읽기를 시작하지 않았거나, 남은 시간으로 줄인 기다림이 끝나 끊었다. + * **실패가 아니다** — 맞추기 · 채우기는 쓴 데까지를 두고 다음 차례에 이어 한다(실패 수 · 경고 없음). 응답으로 나가지 않는다(배경 일에서만 쓴다) + */ +export class IndexDeadlineError extends Error { + constructor() { + super('검색 색인 차례의 끝 시각'); + this.name = 'IndexDeadlineError'; + } +} + +/** 주소 하나 — 이름이 없으면 null */ +export interface IndexAddress { + readonly name: string | null; + readonly email: string | null; +} + +/** 색인에 넣을 메일 한 통 — 엔진이 준 그대로(자르기 · 접기는 index-document.ts 가 한다) */ +export interface IndexDocument { + readonly id: string; + /** 엔진의 대화(threadId) — 없으면 null */ + readonly threadId: string | null; + readonly mailboxIds: readonly string[]; + /** 켜진 키워드 */ + readonly keywords: readonly string[]; + /** 받은 시각 (ISO 8601) */ + readonly receivedAt: string; + readonly size: number; + /** 머리글 Message-ID 들 (꺾쇠 없이) */ + readonly messageIds: readonly string[]; + readonly inReplyTo: readonly string[]; + readonly references: readonly string[]; + readonly subject: string; + readonly from: readonly IndexAddress[]; + readonly to: readonly IndexAddress[]; + readonly cc: readonly IndexAddress[]; + /** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 빠진다 */ + readonly attachmentNames: readonly string[]; + /** 목록과 같은 첨부 판정 */ + readonly hasAttachment: boolean; + readonly preview: string; + /** 본문 글 — HTML 본문이면 글자만, 부분은 줄바꿈으로 잇는다. 엔진이 부분마다 앞쪽만 줄 수 있다 */ + readonly bodyText: string; +} + +/** 바뀔 수 있는 칸만 (JMAP 에서 메일의 글은 바뀌지 않는다 — 메일함 · 키워드뿐) */ +export interface IndexMutable { + readonly id: string; + readonly mailboxIds: readonly string[]; + readonly keywords: readonly string[]; +} + +/** 엔진 Email state 부터의 변화 — 만들어진 · 바뀐 · 없어진 메일 id */ +export type IndexChanges = + | { + readonly kind: 'changes'; + readonly created: readonly string[]; + readonly updated: readonly string[]; + readonly destroyed: readonly string[]; + readonly newState: string; + /** 한 번에 다 주지 못했다 — newState 부터 다시 묻는다 */ + readonly hasMore: boolean; + } + /** 엔진이 그 state 부터의 변화를 셀 수 없다 — 전체 대조로 */ + | { readonly kind: 'cannotCalculate' }; + +/** 받은 차례(최신부터) 메일 id 의 한 쪽 */ +export interface IndexIdPage { + readonly ids: readonly string[]; + /** 이 쪽의 첫 메일 자리 (0 부터) */ + readonly position: number; + /** 엔진이 센 모든 메일 수 — 세지 않았으면 null */ + readonly total: number | null; +} + +/** 엔진의 모든 메일 id 와 그것을 읽기 바로 전의 Email state (전체 대조) */ +export interface IndexAllIds { + readonly state: string; + readonly ids: readonly string[]; +} + +export interface MailIndexSource { + /** 지금 Email state 와 모든 메일 수 (채우기를 시작할 때 — 싼 읽기) */ + snapshot( + user: string, + options: IndexReadOptions, + ): Promise<{ readonly state: string; readonly total: number | null }>; + /** sinceState 부터의 변화 — 한 번에 maxChanges 개까지 */ + changes( + user: string, + sinceState: string, + maxChanges: number, + options: IndexReadOptions, + ): Promise; + /** 메일 글 — 그사이 없어진 메일은 빠진다 (차례는 ids 를 따르지 않아도 된다) */ + documents( + user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise; + /** 메일함 · 키워드만 — 그사이 없어진 메일은 빠진다 */ + mutable( + user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise; + /** + * 받은 차례(최신부터) 한 쪽 — anchor(지난 쪽의 마지막 메일) 다음부터. anchor 가 없거나 그사이 지워졌으면 `position` 부터 + */ + pageIds( + user: string, + from: { readonly anchor: string | null; readonly position: number }, + limit: number, + options: IndexReadOptions, + ): Promise; + /** 모든 메일 id — 전체 대조 (그 바로 전의 state 와 함께) */ + allIds(user: string, options: IndexReadOptions): Promise; +} diff --git a/backend/src/modules/mail-search/mail-search-build.service.ts b/backend/src/modules/mail-search/mail-search-build.service.ts new file mode 100644 index 0000000..71d01fa --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-build.service.ts @@ -0,0 +1,280 @@ +import { + Inject, + Injectable, + Logger, + type OnApplicationBootstrap, + Optional, +} from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +import { maskEmail } from '@/shared/utils/mask'; +import { isIndexableId } from './index-document'; +import { + IndexDeadlineError, + MAIL_INDEX_SOURCE, + type MailIndexSource, +} from './mail-index-source'; +import { MailSearchIndexRepository } from './mail-search-index.repository'; +import { MailSearchRuns } from './mail-search-runs'; +import { MailSearchSyncService } from './mail-search-sync.service'; +import { + MAIL_SEARCH_TIMING, + SYSTEM_SEARCH_TIMING, + type MailSearchTiming, +} from './mail-search-timing'; +import { + BUILD_BATCH_PAUSE_MS, + BUILD_BATCH_SIZE, + BUILD_RUN_BUDGET_MS, + FINISH_MIN_REMAINING_MS, + INDEX_FORMAT, + SEARCH_CRON_TIME_ZONE, + STEP_MIN_REMAINING_MS, +} from './mail-search.constants'; +import { isBuildWindow, isDueAfterFailures } from './search-index-schedule'; + +/** 밤 채우기 차례에서 한 번에 읽어 볼 직원 수 — 실패가 이어진 직원 · 다른 도메인 줄을 건너뛰고 고를 만큼 */ +const BUILD_CANDIDATES_LIMIT = 20; +const MS_PER_MINUTE = 60_000; + +/** + * 한 직원의 채우기 결과 — 다 만들었음 · 이어 하기(끝 시각 · 창이 닫힘 · 마무리를 새 차례로 넘김) · 할 일 없음(줄이 없음 · 이미 ready · 멈춤) · + * 실패(상태 줄에 적었다 — 실패 수 · 멈춤) + */ +export type BuildOutcome = 'ready' | 'partial' | 'skipped' | 'failed'; + +/** 한 묶음의 결과 */ +type StepOutcome = 'continue' | BuildOutcome; + +/** + * 지난 메일 채우기 (14단계 설계 B3) — 직원이 처음 로그인하면 상태 줄이 `building` 으로 생기고, 밤에 한 사람씩 채운다. + * + * - 시작: 엔진의 지금 Email state(S0)와 메일 수를 적는다 — 그때부터 새로 온 메일 · 바뀐 메일은 맞추기(B2)가 바로 넣는다 + * - 받은 차례 최신부터 BUILD_BATCH_SIZE 통씩 글을 읽어 넣고 마지막 메일(anchor) · 넣은 수를 적는다 — 한 묶음마다 직원의 차례(MailSearchRuns)를 + * 한 번씩 잡고 놓는다(그사이 맞추기가 끼어든다). 묶음 뒤 BUILD_BATCH_PAUSE_MS 쉰다(엔진 메모리 1g) + * - anchor 가 지워졌으면 넣은 수의 위치로 이어 간다 — 몇 통 겹치거나 빠질 수 있어 마지막 대조가 채운다 + * - 다 읽으면: 맞추기로 따라잡고 → 전체 대조(빠진 것 넣기 · 없어진 것 지우기) → `ready`. 이 마무리는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만 + * (= 새 차례의 처음에) 시작한다 — 시간 안에 끝나지 않으면 이어 하기로 두고 다음 차례에 다시 한다(넣은 것은 남으니 메일함이 커도 끝내 ready 가 된다) + * - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸리면 + * (IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기로 끝낸다(실패 수 · 경고 · 미루기 없음) + * - 밤에만: 1분마다 깨어 창(isBuildWindow — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30) 안이면 사내 도메인 직원 한 사람을 BUILD_RUN_BUDGET_MS 동안. + * 창이 닫히면 다음 묶음을 시작하지 않는다. 시험에서는 build() 를 바로 불러 창을 건너뛴다 + * 다 만들기 전 그 직원의 검색 · 대화는 엔진 방식이다(14단계 2차 · 3차가 상태를 본다). + * + * ⚠️ 1분 차례의 겹침 막기(running)는 프로세스 메모리다 — 인스턴스가 둘이면 두 차례가 같은 직원을 함께 채운다 (nestjs 스킬의 Redis 목록) + */ +@Injectable() +export class MailSearchBuildService implements OnApplicationBootstrap { + private readonly logger = new Logger(MailSearchBuildService.name); + private running = false; + /** 판이 다른 줄을 되돌렸나 — 서버가 뜰 때 못 했으면 1분 차례마다 다시 해 본다 (그동안 상태 API · 검색은 판을 보고 building 으로 다룬다) */ + private formatsReset = false; + private readonly timing: MailSearchTiming; + + constructor( + @Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource, + private readonly repository: MailSearchIndexRepository, + private readonly runs: MailSearchRuns, + private readonly sync: MailSearchSyncService, + @Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming, + ) { + this.timing = timing ?? SYSTEM_SEARCH_TIMING; + } + + /** + * 서버가 뜰 때 — 판(INDEX_FORMAT)이 다른 사내 직원의 상태 줄을 building 으로 되돌린다(밤에 다시 채운다 — 그동안 검색은 엔진 방식). + * 되돌리지 못해도 서버는 뜬다 — 1분 차례가 다시 해 본다. 그동안 그 직원은 ready 여도 판이 달라 검색은 엔진 방식이고(isSearchReady) + * 상태 API 도 building 으로 보인다(toStatusView) + */ + async onApplicationBootstrap(): Promise { + await this.resetOutdatedFormats(); + } + + /** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */ + private async resetOutdatedFormats(): Promise { + const suffix = this.sync.indexedAddressSuffix; + if (suffix === null || this.formatsReset) return; + try { + const reset = await this.repository.resetOutdatedFormats( + suffix, + INDEX_FORMAT, + new Date(this.timing.now()), + ); + this.formatsReset = true; + if (reset > 0) { + // 배포마다 한 번뿐인 상태 변화 — 수만 (주소 없음) + this.logger.log( + `검색 색인의 판이 바뀌어 ${reset}명의 색인을 밤에 다시 채웁니다 (판 ${INDEX_FORMAT}).`, + ); + } + } catch (error) { + this.logger.warn( + `[SYS_001] 판이 바뀐 검색 색인을 되돌리지 못했습니다(그 직원의 검색은 엔진 방식 — 1분 뒤 다시): ${error instanceof Error ? error.name : typeof error}`, + ); + } + } + + /** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */ + @Cron('0 * * * * *', { + name: 'mail-search-build', + timeZone: SEARCH_CRON_TIME_ZONE, + }) + async tick(): Promise { + const startedAt = this.timing.now(); + if (this.running || this.sync.isStopping) return null; + // 서버가 뜰 때 판을 되돌리지 못했으면 낮에도 다시 해 본다 (한 번 되면 하지 않는다) + await this.resetOutdatedFormats(); + if (!isBuildWindow(new Date(startedAt))) return null; + this.running = true; + try { + const candidates = await this.repository.buildCandidates( + BUILD_CANDIDATES_LIMIT, + ); + const next = candidates.find( + (state) => + this.sync.isIndexedAddress(state.userEmail) && + isDueAfterFailures(state.failureCount, state.updatedAt, startedAt), + ); + if (!next) return null; + return await this.build( + next.userEmail, + startedAt + BUILD_RUN_BUDGET_MS, + () => isBuildWindow(new Date(this.timing.now())), + ); + } catch (error) { + this.logger.error( + `[SYS_001] 검색 색인 채우기 차례를 읽지 못했습니다: ${error instanceof Error ? error.name : typeof error}`, + ); + return null; + } finally { + this.running = false; + } + } + + /** + * 한 직원의 지난 메일을 끝 시각까지 채운다 — `keepGoing` 이 false 가 되면(밤 창이 닫힘) 다음 묶음을 시작하지 않는다. 던지지 않는다 + */ + async build( + user: string, + deadline: number, + keepGoing: () => boolean = () => true, + ): Promise { + const key = user.trim().toLowerCase(); + for (;;) { + if (!this.hasTime(deadline) || !keepGoing()) return 'partial'; + const step = await this.runs.run(key, () => this.step(key, deadline)); + if (step !== 'continue') return step; + await this.timing.sleep(BUILD_BATCH_PAUSE_MS); + } + } + + /** 한 묶음 — 그 직원의 차례 안에서 */ + private async step(user: string, deadline: number): Promise { + try { + if (!this.hasTime(deadline)) return 'partial'; + const state = await this.repository.findState(user); + if ( + !state || + state.status !== 'building' || + state.pausedReason !== null + ) { + return 'skipped'; + } + const now = new Date(this.timing.now()); + + if (state.buildStartedAt === null || state.emailState === null) { + // 시작 — 지금 state(S0)부터는 맞추기가 넣는다. 처음 시작이면(판을 되돌린 직원 포함) 남은 색인 줄을 함께 지운다 — 옛 판 줄이 남지 않게 + const snapshot = await this.source.snapshot(user, { deadline }); + const result = await this.repository.write( + user, + { + clearIndex: state.buildStartedAt === null, + state: { + emailState: snapshot.state, + buildStartedAt: now, + buildTotal: snapshot.total, + buildDone: 0, + buildAnchor: null, + }, + }, + now, + ); + return result.written ? 'continue' : 'skipped'; + } + + const page = await this.source.pageIds( + user, + { anchor: state.buildAnchor, position: state.buildDone }, + BUILD_BATCH_SIZE, + { deadline }, + ); + if (page.ids.length === 0) { + // 다 읽었다 — 마무리는 새 차례의 처음에만 (남은 시간이 모자라면 다음 차례가 이것부터 한다) + if (this.timing.now() > deadline - FINISH_MIN_REMAINING_MS) { + return 'partial'; + } + return await this.finish(user, deadline); + } + + const indexable = page.ids.filter(isIndexableId); + const rows = await this.sync.readRows(user, indexable, deadline); + if (rows === null) return 'partial'; + const result = await this.repository.write( + user, + { + upserts: rows, + state: { + // 다음 쪽의 자리 — 표에 넣을 수 있는 id 만(열 길이). 하나도 없으면 넣은 수의 위치로 잇는다 + buildAnchor: indexable[indexable.length - 1] ?? null, + buildDone: page.position + page.ids.length, + }, + }, + new Date(this.timing.now()), + ); + return result.written ? 'continue' : 'skipped'; + } catch (error) { + // 차례의 끝 시각에 걸렸다 — 실패가 아니라 이어 하기 (이 묶음은 쓰지 않았다) + if (error instanceof IndexDeadlineError) return 'partial'; + await this.sync.handleFailure(user, error, 'build'); + return 'failed'; + } + } + + /** 다 읽었다 — 따라잡기 → 전체 대조(빠진 것 넣기) → ready (던진다 — step 이 받는다) */ + private async finish(user: string, deadline: number): Promise { + if (!(await this.sync.catchUp(user, deadline))) return 'partial'; + const startedAt = (await this.repository.findState(user))?.buildStartedAt; + let count = 0; + const done = await this.sync.reconcile(user, deadline, { + addMissing: true, + refreshMutable: false, + finalState: (indexed) => { + count = indexed; + return { + status: 'ready', + readyAt: new Date(this.timing.now()), + buildAnchor: null, + buildDone: indexed, + buildTotal: indexed, + }; + }, + }); + if (!done) return 'partial'; + const minutes = startedAt + ? Math.round((this.timing.now() - startedAt.getTime()) / MS_PER_MINUTE) + : null; + // 직원마다 한 번뿐인 상태 변화라 info 로 남긴다 (메일 수 · 걸린 때만 — 글 · 주소 없음) + this.logger.log( + `검색 색인을 다 만들었습니다: user=${maskEmail(user)}, 메일 ${count}통${minutes === null ? '' : `, 시작부터 ${minutes}분`}`, + ); + return 'ready'; + } + + /** 다음 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */ + private hasTime(deadline: number): boolean { + return ( + !this.sync.isStopping && + this.timing.now() <= deadline - STEP_MIN_REMAINING_MS + ); + } +} diff --git a/backend/src/modules/mail-search/mail-search-index.repository.ts b/backend/src/modules/mail-search/mail-search-index.repository.ts new file mode 100644 index 0000000..0a326e1 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-index.repository.ts @@ -0,0 +1,410 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import type { DataSource, EntityManager } from 'typeorm'; + +import type { IndexRowValues } from './index-document'; +import type { IndexMutable } from './mail-index-source'; +import { MailSearchMessageEntity } from './mail-search-message.entity'; +import { MailSearchStateEntity } from './mail-search-state.entity'; +import { + INDEX_FORMAT, + SEARCH_MESSAGES_TABLE, + SEARCH_STATES_TABLE, +} from './mail-search.constants'; +import { mergeThreadKeys } from './thread-keys'; +import type { + SearchIndexStatus, + SearchPausedReason, +} from './mail-search.types'; + +/** 상태 줄에 함께 쓰는 칸 (쓰기가 성공하면 실패 수는 0 으로) */ +export interface SearchStatePatch { + readonly status?: SearchIndexStatus; + readonly emailState?: string | null; + readonly buildAnchor?: string | null; + readonly buildDone?: number; + readonly buildTotal?: number | null; + readonly buildStartedAt?: Date | null; + readonly readyAt?: Date | null; + readonly lastSyncedAt?: Date | null; +} + +/** 한 트랜잭션에 쓰는 것 — (그 직원의 색인 줄 모두 지우기) → 지우기 → 넣기(있으면 바꾸기) → 메일함 · 키워드 바꾸기 → 상태 줄 */ +export interface SearchIndexWrite { + /** + * 그 직원의 색인 줄을 모두 지운다 — 채우기를 처음부터 시작할 때(판이 바뀌어 되돌린 직원 포함). 채우기는 위치로 건너뛸 수 있고 마지막 대조는 + * 없는 줄만 넣으므로, 남겨 두면 옛 판(접기 규칙)의 줄이 그대로 남았다. 그동안(building) 검색은 엔진 방식이다 + */ + readonly clearIndex?: boolean; + readonly deletes?: readonly string[]; + readonly upserts?: readonly IndexRowValues[]; + readonly updates?: readonly IndexMutable[]; + readonly state?: SearchStatePatch; +} + +/** 쓰기 결과 — 상태 줄이 없으면(퇴사 마무리가 지웠다) 아무것도 쓰지 않고 written false */ +export interface SearchIndexWriteResult { + readonly written: boolean; + /** 메일함 · 키워드를 바꾼 메일 id — 색인에 없던 메일은 빠진다 */ + readonly updatedIds: ReadonlySet; +} + +/** + * 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id)와 대화 키(thread_key) 밖의 모두. + * 대화 키는 이미 있는 줄이면 그대로 둔다 — 합친 대화를 다시 넣기(겹친 채우기 등)가 풀지 않게 (새 줄은 그 메일의 id 로 넣고 linkThreads 가 잇는다) + */ +const UPSERT_COLUMNS = [ + 'mailbox_ids', + 'keywords', + 'received_at', + 'size', + 'message_id', + 'refs', + 'subject', + 'subject_base', + 'from_text', + 'from_email', + 'from_name', + 'to_text', + 'cc_text', + 'attachment_names', + 'has_attachment', + 'preview', + 'body_text', + 'fold_from', + 'fold_to', + 'fold_subject', + 'fold_attachment', + 'fold_body', + 'grams', + 'engine_thread_id', + 'indexed_at', +]; + +/** + * 메일 검색 색인 표(`mail_search_messages` · `mail_search_states`) 접근 (14단계 설계 A). + * 색인 줄을 쓰는 일(write)은 모두 그 직원의 상태 줄을 잠그고(FOR UPDATE) 같은 트랜잭션에서 한다 — 상태 줄이 없으면 쓰지 않는다 + * (퇴사 마무리가 상태 줄 → 색인 줄 차례로 지운다 — ops-data.service.ts. 그사이 끝난 맞추기가 줄을 되살리지 않게). + * 언제나 직원 주소(user_email)로 묻는다. + */ +@Injectable() +export class MailSearchIndexRepository { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + findState(userEmail: string): Promise { + return this.dataSource.manager.findOneBy(MailSearchStateEntity, { + userEmail, + }); + } + + /** + * 상태 줄이 없으면 `building` 으로 만들고, 멈춰 있으면 푼다(실패 수도 0) — 직원이 로그인했으니 대리 접근이 다시 될 것이다. + * 있고 멈추지 않았으면 쓰지 않는다(로그인 · 상태 묻기마다 줄을 고쳐 쓰지 않게). + * + * 퇴사 마무리 바로 뒤 남은 액세스 토큰으로 GET /api/search-index 가 오면 빈 상태 줄(building)이 다시 생길 수 있다 — 고치지 않는다: + * 엔진 계정이 먼저 지워졌으므로 채우기 · 맞추기는 대리 접근에서 막혀(AUTH_001) 그 줄을 멈출 뿐 메일 줄은 생기지 않고, + * 끊은 세션은 15초 안에 막힌다. 같은 주소를 새 사람이 쓰면 그 사람의 로그인이 그 줄로 처음부터 채운다(옛 메일 줄이 없다) + */ + async ensureState( + userEmail: string, + now: Date, + ): Promise { + const existing = await this.findState(userEmail); + if (existing && existing.pausedReason === null) return existing; + if (existing) { + await this.dataSource.manager.update( + MailSearchStateEntity, + { userEmail }, + { pausedReason: null, failureCount: 0, updatedAt: now }, + ); + } else { + await this.dataSource.manager + .createQueryBuilder() + .insert() + .into(MailSearchStateEntity) + .values({ + userEmail, + status: 'building', + emailState: null, + buildAnchor: null, + buildDone: 0, + buildTotal: null, + buildStartedAt: null, + readyAt: null, + lastSyncedAt: null, + pausedReason: null, + failureCount: 0, + indexFormat: INDEX_FORMAT, + createdAt: now, + updatedAt: now, + }) + .orIgnore() + .execute(); + } + const saved = await this.findState(userEmail); + if (!saved) { + // 만든 바로 뒤 다른 곳(퇴사 마무리)이 지웠다 — 처음 상태로 본다 + const fresh = new MailSearchStateEntity(); + Object.assign(fresh, { + userEmail, + status: 'building', + emailState: null, + buildAnchor: null, + buildDone: 0, + buildTotal: null, + buildStartedAt: null, + readyAt: null, + lastSyncedAt: null, + pausedReason: null, + failureCount: 0, + indexFormat: INDEX_FORMAT, + createdAt: now, + updatedAt: now, + }); + return fresh; + } + return saved; + } + + /** + * 판이 다른 줄을 building 으로 되돌린다 (색인 판 — mail-search.constants.ts 의 INDEX_FORMAT) → 되돌린 줄 수. + * 맞출 기준 · 채우기 자리를 지워 밤에 처음부터 다시 채운다 — 옛 색인 줄은 채우기가 시작하는 첫 묶음에서 지운다(SearchIndexWrite 의 clearIndex). + * 그동안 그 직원의 검색은 엔진 방식이다. `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 (같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다) + */ + async resetOutdatedFormats( + addressSuffix: string, + format: number, + now: Date, + ): Promise { + const [rows] = await this.dataSource.query<[unknown[], number]>( + `UPDATE "${SEARCH_STATES_TABLE}" + SET "status" = 'building', "email_state" = NULL, "build_anchor" = NULL, "build_done" = 0, "build_total" = NULL, + "build_started_at" = NULL, "ready_at" = NULL, "failure_count" = 0, "index_format" = $2, "updated_at" = $3 + WHERE "index_format" <> $2 AND right("user_email", length($1)) = $1 + RETURNING "user_email"`, + [addressSuffix, format, now], + ); + return rows.length; + } + + /** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */ + syncCandidates(): Promise { + return this.dataSource.manager + .createQueryBuilder(MailSearchStateEntity, 'state') + .where('state.email_state IS NOT NULL') + .andWhere('state.paused_reason IS NULL') + .orderBy('state.last_synced_at', 'ASC', 'NULLS FIRST') + .addOrderBy('state.user_email', 'ASC') + .getMany(); + } + + /** 밤 채우기의 차례 — building 이고 멈추지 않은 직원: 실패가 적은 · 먼저 시작한 · 먼저 만든 차례로 */ + buildCandidates(limit: number): Promise { + return this.dataSource.manager + .createQueryBuilder(MailSearchStateEntity, 'state') + .where('state.status = :status', { status: 'building' }) + .andWhere('state.paused_reason IS NULL') + .orderBy('state.failure_count', 'ASC') + .addOrderBy('state.build_started_at', 'ASC', 'NULLS LAST') + .addOrderBy('state.created_at', 'ASC') + .limit(limit) + .getMany(); + } + + /** 실패 한 번 → 이어진 실패 수 (줄이 없으면 0) */ + async recordFailure(userEmail: string, now: Date): Promise { + // UPDATE 의 결과는 [줄들, 바꾼 수] (TypeORM 의 postgres 드라이버) + const [rows] = await this.dataSource.query< + [{ failure_count: number }[], number] + >( + `UPDATE "${SEARCH_STATES_TABLE}" SET "failure_count" = "failure_count" + 1, "updated_at" = $2 WHERE "user_email" = $1 RETURNING "failure_count"`, + [userEmail, now], + ); + return Number(rows[0]?.failure_count ?? 0); + } + + /** 대리 접근이 막혀 멈춘다 (다음 로그인 때 ensureState 가 푼다) */ + async pause( + userEmail: string, + reason: SearchPausedReason, + now: Date, + ): Promise { + await this.dataSource.manager.update( + MailSearchStateEntity, + { userEmail }, + { pausedReason: reason, updatedAt: now }, + ); + } + + /** 그 직원의 색인 줄 수 */ + async countMessages(userEmail: string): Promise { + const rows: { count: number }[] = await this.dataSource.query( + `SELECT count(*)::int AS "count" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`, + [userEmail], + ); + return Number(rows[0]?.count ?? 0); + } + + /** 주어진 id 가운데 그 직원의 색인에 있는 것 */ + async existingIds( + userEmail: string, + ids: readonly string[], + ): Promise> { + if (ids.length === 0) return new Set(); + const rows: { email_id: string }[] = await this.dataSource.query( + `SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, + [userEmail, [...ids]], + ); + return new Set(rows.map((row) => row.email_id)); + } + + /** 그 직원의 색인에 든 메일 id 모두 (전체 대조) */ + async indexedIds(userEmail: string): Promise { + const rows: { email_id: string }[] = await this.dataSource.query( + `SELECT "email_id" FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`, + [userEmail], + ); + return rows.map((row) => row.email_id); + } + + /** + * 한 트랜잭션에 쓴다 — 상태 줄을 잠그고(없으면 쓰지 않는다), (모두 지우기) → 지우기 → 넣기 → 메일함 · 키워드 → 상태 줄(실패 수 0). + * `now` 는 넣은 줄의 indexed_at · 상태 줄의 updated_at + */ + async write( + userEmail: string, + change: SearchIndexWrite, + now: Date, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const locked: unknown[] = await manager.query( + `SELECT 1 FROM "${SEARCH_STATES_TABLE}" WHERE "user_email" = $1 FOR UPDATE`, + [userEmail], + ); + if (locked.length === 0) { + return { written: false, updatedIds: new Set() }; + } + if (change.clearIndex) { + await manager.query( + `DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`, + [userEmail], + ); + } + const deletes = [...new Set(change.deletes ?? [])]; + if (deletes.length > 0) { + await manager.query( + `DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, + [userEmail, deletes], + ); + } + const upserted = await this.upsertRows( + manager, + userEmail, + change.upserts ?? [], + now, + ); + await this.linkThreads(manager, userEmail, upserted); + const updatedIds = await this.updateMutable( + manager, + userEmail, + change.updates ?? [], + ); + if (change.state) { + await manager.update( + MailSearchStateEntity, + { userEmail }, + { ...change.state, failureCount: 0, updatedAt: now }, + ); + } + return { written: true, updatedIds }; + }); + } + + /** 넣기(있으면 대화 키 밖의 모든 칸을 바꾸기) → 넣은 id — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */ + private async upsertRows( + manager: EntityManager, + userEmail: string, + rows: readonly IndexRowValues[], + now: Date, + ): Promise { + if (rows.length === 0) return []; + const latest = new Map(rows.map((row) => [row.emailId, row])); + await manager + .createQueryBuilder() + .insert() + .into(MailSearchMessageEntity) + .values( + [...latest.values()].map((row) => ({ + ...row, + userEmail, + indexedAt: now, + })), + ) + .orUpdate(UPSERT_COLUMNS, ['user_email', 'email_id']) + .execute(); + return [...latest.keys()]; + } + + /** + * 대화 키 잇기 (14단계 설계 B4) — 넣은 메일 M 마다, M 의 답장 관계(refs)가 가리키는 메일과 M 을 가리키는 메일 가운데 말머리를 뗀 제목이 같은 것의 + * 키를 M 의 키와 하나로(가장 작은 키 — thread-keys.ts). Message-ID 가 없는 메일은 잇지 않는다(혼자). 그 직원의 줄만 본다. + * 쿼리 둘: 이어진 키 쌍 읽기(주소 + Message-ID 색인 · refs GIN) → 바꿀 키를 한 문장으로 UPDATE(주소 + 대화 키 색인). 쓰기 트랜잭션 안이다(상태 줄 잠금 — 같은 직원은 차례로) + */ + private async linkThreads( + manager: EntityManager, + userEmail: string, + ids: readonly string[], + ): Promise { + if (ids.length === 0) return; + const links = await manager.query<{ a: string; b: string }[]>( + `SELECT "n"."thread_key" AS "a", "o"."thread_key" AS "b" + FROM "${SEARCH_MESSAGES_TABLE}" AS "n" + JOIN "${SEARCH_MESSAGES_TABLE}" AS "o" + ON "o"."user_email" = "n"."user_email" AND "o"."message_id" = ANY("n"."refs") AND "o"."subject_base" = "n"."subject_base" + WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL + AND "o"."thread_key" <> "n"."thread_key" + UNION + SELECT "n"."thread_key", "o"."thread_key" + FROM "${SEARCH_MESSAGES_TABLE}" AS "n" + JOIN "${SEARCH_MESSAGES_TABLE}" AS "o" + ON "o"."user_email" = "n"."user_email" AND "o"."refs" @> ARRAY["n"."message_id"]::text[] AND "o"."subject_base" = "n"."subject_base" + WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL + AND "o"."message_id" IS NOT NULL AND "o"."thread_key" <> "n"."thread_key"`, + [userEmail, [...ids]], + ); + const renames = mergeThreadKeys(links.map((link) => [link.a, link.b])); + if (renames.size === 0) return; + await manager.query( + `UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message" SET "thread_key" = "rename"."to" + FROM unnest($2::text[], $3::text[]) AS "rename"("from", "to") + WHERE "message"."user_email" = $1 AND "message"."thread_key" = "rename"."from"`, + [userEmail, [...renames.keys()], [...renames.values()]], + ); + } + + /** 메일함 · 키워드만 바꾼다 (한 문장) → 바꾼 id */ + private async updateMutable( + manager: EntityManager, + userEmail: string, + updates: readonly IndexMutable[], + ): Promise> { + if (updates.length === 0) return new Set(); + const values: Record = {}; + for (const update of updates) { + values[update.id] = { + m: [...new Set(update.mailboxIds)], + k: [...new Set(update.keywords)], + }; + } + const [rows] = await manager.query<[{ email_id: string }[], number]>( + `UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message" + SET "mailbox_ids" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'm')), + "keywords" = ARRAY(SELECT jsonb_array_elements_text("change"."value" -> 'k')) + FROM jsonb_each($2::jsonb) AS "change"("key", "value") + WHERE "message"."user_email" = $1 AND "message"."email_id" = "change"."key" + RETURNING "message"."email_id"`, + [userEmail, JSON.stringify(values)], + ); + return new Set(rows.map((row) => row.email_id)); + } +} diff --git a/backend/src/modules/mail-search/mail-search-index.service.spec.ts b/backend/src/modules/mail-search/mail-search-index.service.spec.ts new file mode 100644 index 0000000..9c29424 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-index.service.spec.ts @@ -0,0 +1,135 @@ +import { Logger } from '@nestjs/common'; + +import type { MailSearchIndexRepository } from './mail-search-index.repository'; +import { + MailSearchIndexService, + toStatusView, +} from './mail-search-index.service'; +import { INDEX_FORMAT } from './mail-search.constants'; + +const ARA = 'ara.kim@arasystem.test'; + +describe('MailSearchIndexService (검색 색인의 직원 상태)', () => { + let ensureState: jest.Mock; + let findState: jest.Mock; + let service: MailSearchIndexService; + let warnings: string[]; + + beforeEach(() => { + ensureState = jest.fn().mockResolvedValue({ + status: 'building', + buildDone: 0, + buildTotal: null, + indexFormat: INDEX_FORMAT, + }); + findState = jest.fn().mockResolvedValue(null); + service = new MailSearchIndexService({ + ensureState, + findState, + } as unknown as MailSearchIndexRepository); + warnings = []; + jest.spyOn(Logger.prototype, 'warn').mockImplementation((message) => { + warnings.push(String(message)); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('상태를 물으면 줄을 만들거나 멈춤을 풀고(소문자 주소로) 준비 상태를 준다', async () => { + expect(await service.status(' Ara.Kim@ARASYSTEM.test ')).toEqual({ + status: 'building', + done: 0, + total: null, + }); + expect(ensureState).toHaveBeenCalledWith(ARA, expect.any(Date)); + }); + + it('로그인 뒤 줄 만들기가 실패해도 던지지 않는다 — 경고만 (주소 · DB 문장은 가려서)', async () => { + ensureState.mockRejectedValue( + Object.assign(new Error(`duplicate key (${ARA})`), { code: '23505' }), + ); + await expect(service.enrollAfterLogin(ARA)).resolves.toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Error/23505'); + expect(warnings[0]).not.toContain(ARA); + }); + + it('toStatusView — 다 만들었으면 채운 수 = 채울 수', () => { + expect( + toStatusView({ + status: 'ready', + buildDone: 120, + buildTotal: 120, + indexFormat: INDEX_FORMAT, + }), + ).toEqual({ status: 'ready', done: 120, total: 120 }); + }); + + it('toStatusView — 채운 수는 채울 수를 넘지 않는다 (채우는 동안 온 메일 · 위치로 이어 간 겹침) · 시작 전에는 채울 수 null', () => { + expect( + toStatusView({ + status: 'building', + buildDone: 130, + buildTotal: 120, + indexFormat: INDEX_FORMAT, + }), + ).toEqual({ status: 'building', done: 120, total: 120 }); + expect( + toStatusView({ + status: 'building', + buildDone: 0, + buildTotal: null, + indexFormat: INDEX_FORMAT, + }), + ).toEqual({ status: 'building', done: 0, total: null }); + }); + + it('toStatusView — 판이 다른 줄은 되돌리기 전이어도 building · 채운 0 (검색 방식과 같은 판정)', () => { + expect( + toStatusView({ + status: 'ready', + buildDone: 120, + buildTotal: 120, + indexFormat: INDEX_FORMAT - 1, + }), + ).toEqual({ status: 'building', done: 0, total: null }); + }); + + describe('색인으로 찾을 수 있나 (isSearchReady)', () => { + const ready = { + status: 'ready', + indexFormat: INDEX_FORMAT, + pausedReason: null, + }; + + it('다 만들었고 · 지금 판이고 · 멈추지 않았을 때만 (소문자 주소로 묻는다)', async () => { + findState.mockResolvedValue(ready); + await expect( + service.isSearchReady(' Ara.Kim@ARASYSTEM.test '), + ).resolves.toBe(true); + expect(findState).toHaveBeenCalledWith(ARA); + }); + + it.each([ + ['줄 없음', null], + ['채우는 중', { ...ready, status: 'building' }], + ['판이 다름(되돌리기 전)', { ...ready, indexFormat: INDEX_FORMAT - 1 }], + ['대리 접근이 막혀 멈춤', { ...ready, pausedReason: 'locked' }], + ])('%s 이면 엔진 방식', async (_label, state) => { + findState.mockResolvedValue(state); + await expect(service.isSearchReady(ARA)).resolves.toBe(false); + }); + + it('상태를 읽지 못하면(DB 장애) 엔진 방식 — 경고만, 주소 · DB 문장은 가린다', async () => { + findState.mockRejectedValue( + Object.assign(new Error(`connection to ${ARA} lost`), { + code: '57P01', + }), + ); + await expect(service.isSearchReady(ARA)).resolves.toBe(false); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Error/57P01'); + expect(warnings[0]).not.toContain(ARA); + }); + }); +}); diff --git a/backend/src/modules/mail-search/mail-search-index.service.ts b/backend/src/modules/mail-search/mail-search-index.service.ts new file mode 100644 index 0000000..9107daf --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-index.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { MailSearchIndexRepository } from './mail-search-index.repository'; +import { INDEX_FORMAT } from './mail-search.constants'; +import type { MailSearchStateEntity } from './mail-search-state.entity'; +import type { SearchIndexStatusView } from './mail-search.types'; + +/** + * 상태 줄 → 화면의 준비 상태 (GET /api/search-index). 채운 수는 채울 수를 넘지 않게 묶는다 — + * 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다. + * 판(INDEX_FORMAT)이 다른 줄은 아직 되돌리지 못했어도 building · 채운 0 으로 보인다 — 검색 방식(isSearchReady)과 같은 판정이다 + */ +export function toStatusView( + state: Pick< + MailSearchStateEntity, + 'status' | 'buildDone' | 'buildTotal' | 'indexFormat' + >, +): SearchIndexStatusView { + if (state.indexFormat !== INDEX_FORMAT) { + return { status: 'building', done: 0, total: null }; + } + const done = Math.max(0, state.buildDone); + const total = + state.buildTotal === null ? null : Math.max(0, state.buildTotal); + return { + status: state.status, + done: total === null ? done : Math.min(done, total), + total, + }; +} + +/** DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만 */ +function errorKind(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** + * 검색 색인의 직원 상태 (14단계 설계 A2 · C1) — 상태 줄 만들기 · 멈춤 풀기 · 준비 상태 알리기. + * 줄은 직원이 웹메일에 로그인할 때(AuthService) · 검색 준비 상태를 물을 때 만든다 — 로그인한 적 없는 계정은 색인하지 않는다. + * 대리 접근이 막혀 멈췄던 줄은 이때 푼다(로그인했으니 다시 될 것이다). + */ +@Injectable() +export class MailSearchIndexService { + private readonly logger = new Logger(MailSearchIndexService.name); + + constructor(private readonly repository: MailSearchIndexRepository) {} + + /** 로그인에 성공한 직원 — 상태 줄을 만들거나 멈춤을 푼다. 던지지 않는다(못 해도 로그인은 그대로 — 다음 로그인 · 검색 때 다시) */ + async enrollAfterLogin(user: string): Promise { + try { + await this.repository.ensureState(normalize(user), new Date()); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 검색 색인의 상태 줄을 만들지 못했습니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${errorKind(error)}`, + ); + } + } + + /** + * 색인으로 찾을 수 있나 (14단계 — 검색 방식 고르기) — 다 만들었고(ready) · 지금 판(INDEX_FORMAT)이고 · 멈추지 않았을 때. + * 상태를 읽지 못하면(DB 장애) false — 엔진 방식으로 찾는다(검색을 실패로 만들지 않는다) + */ + async isSearchReady(user: string): Promise { + try { + const state = await this.repository.findState(normalize(user)); + return ( + state !== null && + state.status === 'ready' && + state.indexFormat === INDEX_FORMAT && + state.pausedReason === null + ); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 검색 색인 상태를 읽지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user)}, reason=${errorKind(error)}`, + ); + return false; + } + } + + /** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */ + async status(user: string): Promise { + return toStatusView( + await this.repository.ensureState(normalize(user), new Date()), + ); + } +} + +function normalize(user: string): string { + return user.trim().toLowerCase(); +} diff --git a/backend/src/modules/mail-search/mail-search-message.entity.ts b/backend/src/modules/mail-search/mail-search-message.entity.ts new file mode 100644 index 0000000..dc16e8f --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-message.entity.ts @@ -0,0 +1,171 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + INDEX_FROM_NAME_MAX_CHARS, + SEARCH_EMAIL_COLUMN_LENGTH, + SEARCH_ID_COLUMN_LENGTH, + SEARCH_MESSAGES_TABLE, +} from './mail-search.constants'; + +/** + * 메일 검색 색인의 한 줄 — 직원 주소 + 엔진 메일 id 마다 (14단계 설계 A1). + * 언제나 로그인한 직원 주소(user_email)로만 묻는다 — 남의 줄을 읽는 길이 없다. + * + * 글 칸은 둘씩이다 — 원문(NFC — 맞은 곳의 위치를 그 위에 그린다)과 접기 글(fold_* — shared/utils/search-text.ts 의 foldText, strpos 확인용). + * 접기는 코드 포인트 하나씩 바꾸므로 두 글의 위치가 같다. grams 는 다섯 접기 칸의 글자 두 개 묶음(겹치지 않게 — 칸 경계 · 공백을 넘지 않는다)이고 + * GIN 으로 후보를 좁힌다. + * + * GIN 색인 둘((lower(user_email), grams, mailbox_ids) · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다 + * (migration:generate 가 지우지 않게). 찾기 GIN 은 직원 주소 · 묶음 · 메일함을 한 색인에 담는다(btree_gin — 14단계 2차 `SearchIndexUserGrams` → + * 실사용 시험 `SearchIndexUserGramsMailboxes`). GIN 의 posting 은 칸마다 따로라 흔한 묶음 · 메일함 id 는 **회사 전체** 줄의 posting 이다 + * (메일함 id 는 계정마다 같은 꼴(a · b …)이다) — 색인 하나가 그 직원 주소의 posting 과 맞추고 맞은 줄만 읽는다(10명 흉내에서 '계약' 7만여 줄 · 수 ms). + * 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. **메일함만 담은 GIN 을 다시 만들지 않는다** — 계획기가 그것만으로 + * 메일함의 줄을 모두 읽고 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 고를 수 있게 된다(2026-11-06 — 1만 통 메일함 안 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1.6초) + */ +@Entity({ name: SEARCH_MESSAGES_TABLE }) +@Index('IDX_mail_search_messages_received', [ + 'userEmail', + 'receivedAt', + 'emailId', +]) +@Index('IDX_mail_search_messages_message_id', ['userEmail', 'messageId']) +@Index('IDX_mail_search_messages_thread_key', ['userEmail', 'threadKey']) +@Index('IDX_mail_search_messages_user_grams_mailboxes', { synchronize: false }) +@Index('IDX_mail_search_messages_refs', { synchronize: false }) +export class MailSearchMessageEntity { + @PrimaryColumn({ + name: 'user_email', + type: 'varchar', + length: SEARCH_EMAIL_COLUMN_LENGTH, + }) + userEmail!: string; + + /** 엔진 메일 id */ + @PrimaryColumn({ + name: 'email_id', + type: 'varchar', + length: SEARCH_ID_COLUMN_LENGTH, + }) + emailId!: string; + + /** 든 메일함 id 들 — 메일함 범위 · 휴지통 · 스팸메일함 빼기 */ + @Column({ name: 'mailbox_ids', type: 'text', array: true }) + mailboxIds!: string[]; + + /** 켜진 키워드(`$seen` · `$flagged` · `$draft` · 우리 내부 키워드) — 엔진이 준 그대로 */ + @Column({ type: 'text', array: true }) + keywords!: string[]; + + /** 받은 시각 — 차례 · 기간 */ + @Column({ name: 'received_at', type: 'timestamptz' }) + receivedAt!: Date; + + @Column({ type: 'integer' }) + size!: number; + + /** 첫 Message-ID (꺾쇠 없이) — 없으면 null */ + @Column({ name: 'message_id', type: 'text', nullable: true }) + messageId!: string | null; + + /** 답장 관계 — In-Reply-To 다음 References 를 가까운 것부터(겹치지 않게, 앞 INDEX_REFS_MAX 개) */ + @Column({ type: 'text', array: true }) + refs!: string[]; + + /** 제목 원문(NFC) */ + @Column({ type: 'text' }) + subject!: string; + + /** 말머리를 뗀 제목의 접기 글 — 대화 키를 정할 때 (shared/utils/subject-base.ts) */ + @Column({ name: 'subject_base', type: 'text' }) + subjectBase!: string; + + /** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) — 보낸 사람 조건 검색(fold_from)의 바탕 */ + @Column({ name: 'from_text', type: 'text' }) + fromText!: string; + + /** + * 첫 보낸 사람의 주소 (14단계 3차 리뷰 L3 — 묶은 목록의 참여자) — 엔진이 준 주소 그대로(앞뒤 공백만 지운 NFC). 주소가 없거나 + * SEARCH_EMAIL_COLUMN_LENGTH 를 넘으면 null — 이름에 든 `<주소>` 꼴을 주소로 읽지 않는다 (참여자는 이 칸만 본다) + */ + @Column({ + name: 'from_email', + type: 'varchar', + length: SEARCH_EMAIL_COLUMN_LENGTH, + nullable: true, + }) + fromEmail!: string | null; + + /** 첫 보낸 사람의 이름 — 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트, 없으면 null */ + @Column({ + name: 'from_name', + type: 'varchar', + length: INDEX_FROM_NAME_MAX_CHARS, + nullable: true, + }) + fromName!: string | null; + + @Column({ name: 'to_text', type: 'text' }) + toText!: string; + + @Column({ name: 'cc_text', type: 'text' }) + ccText!: string; + + /** 진짜 첨부(본문 그림이 아닌 것)의 이름을 줄바꿈으로 */ + @Column({ name: 'attachment_names', type: 'text' }) + attachmentNames!: string; + + /** 목록과 같은 판정 (html/inline-images.ts 의 hasRealAttachment) */ + @Column({ name: 'has_attachment', type: 'boolean' }) + hasAttachment!: boolean; + + /** 엔진의 미리 보기 */ + @Column({ type: 'text' }) + preview!: string; + + /** 본문 글(HTML 이면 글자만) 앞 INDEX_BODY_MAX_CHARS 코드 포인트 */ + @Column({ name: 'body_text', type: 'text' }) + bodyText!: string; + + @Column({ name: 'fold_from', type: 'text' }) + foldFrom!: string; + + /** 받는 사람과 참조를 함께 (조건 검색의 "받는 사람"은 참조를 포함한다) */ + @Column({ name: 'fold_to', type: 'text' }) + foldTo!: string; + + @Column({ name: 'fold_subject', type: 'text' }) + foldSubject!: string; + + @Column({ name: 'fold_attachment', type: 'text' }) + foldAttachment!: string; + + @Column({ name: 'fold_body', type: 'text' }) + foldBody!: string; + + /** 다섯 접기 칸의 글자 두 개 묶음 — GIN (`grams @> 찾는 말의 묶음`) */ + @Column({ type: 'text', array: true }) + grams!: string[]; + + /** + * 우리 대화 키 (14단계 3차 — 설계 B4) — 넣을 때는 그 메일 id, 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 대화의 가장 작은 키로 합친다 + * (mail-search-index.repository.ts 의 linkThreads). 다시 넣어도 이미 있는 줄의 키는 그대로다 + */ + @Column({ + name: 'thread_key', + type: 'varchar', + length: SEARCH_ID_COLUMN_LENGTH, + }) + threadKey!: string; + + /** 엔진의 threadId (참고용 — 색인을 다 만들기 전의 대화 · 묶기는 엔진에 바로 묻는다) */ + @Column({ + name: 'engine_thread_id', + type: 'varchar', + length: SEARCH_ID_COLUMN_LENGTH, + nullable: true, + }) + engineThreadId!: string | null; + + @Column({ name: 'indexed_at', type: 'timestamptz' }) + indexedAt!: Date; +} diff --git a/backend/src/modules/mail-search/mail-search-query.service.spec.ts b/backend/src/modules/mail-search/mail-search-query.service.spec.ts new file mode 100644 index 0000000..6fb7f56 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-query.service.spec.ts @@ -0,0 +1,573 @@ +import type { DataSource } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + SEARCH_CANDIDATE_COUNT_LIMIT, + SEARCH_CHECK_ROWS_GROWTH, + SEARCH_CHECK_ROWS_LIMIT, + SEARCH_FIRST_CHECK_ROWS, + SEARCH_TOTAL_LIMIT, +} from './mail-search.constants'; +import { MailSearchQueryService } from './mail-search-query.service'; +import type { SearchCriteria, SearchScope } from './mail-search.types'; +import { + decodeIndexCursor, + encodeIndexCursor, + searchScopeKey, +} from './search-cursor'; +import { planSearch } from './search-plan'; + +const USER = 'ara.kim@arasystem.test'; +const SCOPE: SearchScope = { + mailboxId: null, + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], +}; + +/** 쿼리의 종류 — 쿼리마다 다른 모양으로 가른다 (search-sql.ts) */ +type QueryKind = + | 'candidates' + | 'check' + | 'keyset' + | 'frontier' + | 'keysetCount' + | 'plainPage' + | 'plainCount' + | 'highlight'; + +function kindOf(sql: string): QueryKind { + if (sql.includes('lower("user_email")')) return 'candidates'; + if (sql.includes('"body_text"')) return 'highlight'; + if (sql.includes('AS "hits"')) return 'keysetCount'; + if (sql.includes('AS "scanned"')) return 'frontier'; + if (sql.includes('AS "matches"')) return 'plainCount'; + if (sql.includes('AS "scan"')) return 'keyset'; + if (sql.includes('= ANY(')) return 'check'; + return 'plainPage'; +} + +interface Recorded { + readonly kind: QueryKind; + readonly sql: string; + readonly params: unknown[]; + /** 그 쿼리 앞에 건 계획기 설정 (그 트랜잭션의 것) */ + readonly settings: string[]; + /** 몇 번째 트랜잭션의 쿼리인가 (트랜잭션 밖이면 null) · 그 격리 수준 */ + readonly transaction: number | null; + readonly isolation: string | null; +} + +interface Row { + readonly email_id: string; + readonly received_at: Date; +} + +/** 받은 시각이 최신부터인 줄들 — id 는 m1, m2 … */ +function rows(count: number, from = 0): Row[] { + return Array.from({ length: count }, (_, index) => ({ + email_id: `m${from + index + 1}`, + received_at: new Date( + Date.UTC(2026, 10, 2, 0, 0, 0) - (from + index) * 60_000, + ), + })); +} + +/** 확인 쿼리의 id 매개변수 */ +const idsOf = (query: Recorded) => query.params[1] as string[]; + +function fakeDataSource( + answers: Partial unknown[]>>, +) { + const recorded: Recorded[] = []; + let transactions = 0; + const answer = ( + sql: string, + params: unknown[] | undefined, + context: { + settings: string[]; + transaction: number | null; + isolation: string | null; + }, + ) => { + const kind = kindOf(sql); + recorded.push({ kind, sql, params: params ?? [], ...context }); + const respond = answers[kind]; + if (!respond) throw new Error(`준비하지 않은 쿼리: ${kind}`); + return Promise.resolve(respond(params ?? [])); + }; + const dataSource = { + transaction: jest.fn( + ( + isolation: string, + run: (manager: { + query: (sql: string, params?: unknown[]) => Promise; + }) => Promise, + ) => { + transactions += 1; + const context = { + settings: [] as string[], + transaction: transactions, + isolation, + }; + return run({ + query: (sql: string, params?: unknown[]) => { + if (sql.startsWith('SET LOCAL')) { + context.settings.push(sql); + return Promise.resolve([]); + } + return answer(sql, params, context); + }, + }); + }, + ), + query: jest.fn((sql: string, params?: unknown[]) => + answer(sql, params, { settings: [], transaction: null, isolation: null }), + ), + }; + return { + recorded, + service: new MailSearchQueryService(dataSource as unknown as DataSource), + }; +} + +/** 확인 — 준 id 가운데 `matches` 를 만족하는 것만 맞았다고 답한다 */ +const answerChecks = + (matches: (id: string) => boolean) => + (params: unknown[]): unknown[] => + (params[1] as string[]).filter(matches).map((email_id) => ({ email_id })); + +const numberOf = (id: string) => Number(id.slice(1)); + +const criteria = (overrides: Partial): SearchCriteria => ({ + hasAttachment: false, + ...overrides, +}); + +async function search( + service: MailSearchQueryService, + given: SearchCriteria, + page: { limit: number; cursor?: string } = { limit: 20 }, + user = USER, +) { + return service.search(user, SCOPE, given, planSearch(given), page); +} + +describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', () => { + const bitmapHint = [ + 'SET LOCAL enable_seqscan = off', + 'SET LOCAL enable_indexscan = off', + ]; + const keysetHint = [ + 'SET LOCAL enable_bitmapscan = off', + 'SET LOCAL enable_seqscan = off', + ]; + + describe('묶음이 있는 찾기 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인', () => { + it(`후보는 GIN 의 bitmap 으로만 · 받은 시각 최신부터 확인 상한 + 1(${SEARCH_CHECK_ROWS_LIMIT + 1})줄까지 — 두 글자 낱말뿐이면 후보가 곧 결과(확인하지 않는다)`, async () => { + const { service, recorded } = fakeDataSource({ + candidates: () => rows(25), + }); + + const page = await search(service, criteria({ text: '계약' })); + + expect(recorded.map((query) => query.kind)).toEqual(['candidates']); + expect(recorded[0].settings).toEqual(bitmapHint); + expect(recorded[0].params.at(-1)).toBe(SEARCH_CHECK_ROWS_LIMIT + 1); + expect(page.hits.map((hit) => hit.id)).toEqual( + rows(20).map((row) => row.email_id), + ); + expect(page.hasNext).toBe(true); + expect(page.total).toBe(25); + // 다음 쪽 커서 — 이 쪽의 마지막 줄 다음부터, 같은 검색에서만 + const key = searchScopeKey(undefined, criteria({ text: '계약' })); + expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({ + receivedAt: rows(20)[19].received_at, + emailId: 'm20', + }); + }); + + it(`두 글자 낱말뿐인 찾기의 total — ${SEARCH_TOTAL_LIMIT}을 넘거나 후보가 상한 밖까지 있으면 null`, async () => { + for (const count of [ + SEARCH_TOTAL_LIMIT + 1, + SEARCH_CHECK_ROWS_LIMIT + 1, + ]) { + const { service } = fakeDataSource({ candidates: () => rows(count) }); + const page = await search(service, criteria({ text: '계약 확인' })); + expect(page.total).toBeNull(); + expect(page.hits).toHaveLength(20); + expect(page.hasNext).toBe(true); + } + }); + + it(`후보가 ${SEARCH_CANDIDATE_COUNT_LIMIT}개 이하면 한 번에 모두 확인한다 — 첫 쪽의 total 은 맞은 수`, async () => { + const { service, recorded } = fakeDataSource({ + candidates: () => rows(40), + check: answerChecks((id) => numberOf(id) % 4 !== 0), + }); + + const page = await search(service, criteria({ text: '계약서' })); + + expect(recorded.map((query) => query.kind)).toEqual([ + 'candidates', + 'check', + ]); + expect(idsOf(recorded[1])).toHaveLength(40); + // 확인은 기본 키로 그 줄만 — 계획기 설정 없이 + expect(recorded[1].settings).toEqual([]); + expect(page.hits).toHaveLength(20); + expect(page.hits.map((hit) => hit.id)).not.toContain('m4'); + expect(page.hasNext).toBe(true); + expect(page.total).toBe(30); + }); + + it(`후보가 많으면 받은 차례로 ${SEARCH_FIRST_CHECK_ROWS}줄부터 ${SEARCH_CHECK_ROWS_GROWTH}배씩 확인하다 쪽이 차면 멈춘다 — total 은 모른다(null)`, async () => { + const { service, recorded } = fakeDataSource({ + candidates: () => rows(2_500), + check: answerChecks((id) => numberOf(id) % 100 === 0), + }); + + const page = await search(service, criteria({ text: '"계약 갱신"' })); + + const checks = recorded.filter((query) => query.kind === 'check'); + expect(checks.map((query) => idsOf(query).length)).toEqual([ + SEARCH_FIRST_CHECK_ROWS, + SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH, + SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH ** 2, + ]); + // 확인은 받은 차례 그대로 이어진다 + expect(idsOf(checks[1])[0]).toBe('m101'); + expect(page.hits).toHaveLength(20); + expect(page.hits[0].id).toBe('m100'); + expect(page.hasNext).toBe(true); + expect(page.total).toBeNull(); + }); + + it('후보가 많아도 상한 안이면(상한 밖 후보 없음) 다 확인하고 끝 — 쪽을 못 채워도 hasNext 없음, 맞은 수를 안다', async () => { + const { service, recorded } = fakeDataSource({ + candidates: () => rows(2_500), + check: answerChecks((id) => ['m7', 'm2400'].includes(id)), + }); + + const page = await search(service, criteria({ text: '"계약 갱신"' })); + + const checked = recorded + .filter((query) => query.kind === 'check') + .reduce((sum, query) => sum + idsOf(query).length, 0); + expect(checked).toBe(2_500); + expect(page).toMatchObject({ + hasNext: false, + nextCursor: null, + total: 2, + }); + expect(page.hits.map((hit) => hit.id)).toEqual(['m7', 'm2400']); + }); + + it('상한 밖에도 후보가 있고 상한까지 확인해도 쪽을 못 채우면 찾은 것까지와 이어 찾기 커서 — 쪽이 비어 있어도 hasNext', async () => { + const lastChecked = rows(1, SEARCH_CHECK_ROWS_LIMIT - 1)[0]; + const { service, recorded } = fakeDataSource({ + candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1), + check: answerChecks((id) => id === 'm42'), + }); + + const page = await search(service, criteria({ text: '확인계약' })); + + const checked = recorded + .filter((query) => query.kind === 'check') + .flatMap(idsOf); + expect(checked).toHaveLength(SEARCH_CHECK_ROWS_LIMIT); + expect(checked).not.toContain(`m${SEARCH_CHECK_ROWS_LIMIT + 1}`); + expect(page.hits.map((hit) => hit.id)).toEqual(['m42']); + expect(page.hasNext).toBe(true); + expect(page.total).toBeNull(); + const key = searchScopeKey(undefined, criteria({ text: '확인계약' })); + // 이어 찾을 자리는 마지막으로 **확인한** 후보 — 맞은 줄이 아니다 + expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({ + receivedAt: lastChecked.received_at, + emailId: lastChecked.email_id, + }); + + const empty = fakeDataSource({ + candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1), + check: () => [], + }); + const emptyPage = await search( + empty.service, + criteria({ text: '확인계약' }), + ); + expect(emptyPage.hits).toEqual([]); + expect(emptyPage.hasNext).toBe(true); + expect(emptyPage.nextCursor).not.toBeNull(); + }); + + it('후보가 없으면 확인하지 않는다 — 빈 쪽 · total 0', async () => { + const { service, recorded } = fakeDataSource({ candidates: () => [] }); + await expect( + search(service, criteria({ text: '뷁뷁뷁' })), + ).resolves.toEqual({ + hits: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + expect(recorded.map((query) => query.kind)).toEqual(['candidates']); + }); + + it('직원 주소는 소문자로 맞춰 묻는다 (표의 주소는 늘 소문자)', async () => { + const { service, recorded } = fakeDataSource({ + candidates: () => rows(1), + }); + await search( + service, + criteria({ text: '계약' }), + { limit: 20 }, + ' Ara.Kim@ArASystem.test ', + ); + expect(recorded[0].params[0]).toBe(USER); + }); + }); + + it('묶음이 없으면(한 글자 낱말뿐) 받은 차례로 확인 상한까지 — 쪽이 차면 total 은 창 안에서 센다', async () => { + const { service, recorded } = fakeDataSource({ + keyset: (params) => rows(Number(params.at(-1))), + keysetCount: () => [{ count: 120 }], + frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }], + }); + + const page = await search(service, criteria({ text: '묵' })); + + expect(recorded.map((query) => query.kind)).toEqual([ + 'keyset', + 'keysetCount', + 'frontier', + ]); + expect(recorded[0].settings).toEqual(keysetHint); + expect(recorded[0].sql).not.toContain('"grams"'); + expect(recorded[0].sql).not.toContain('strpos("fold_body"'); + expect(recorded[0].params.slice(-3)).toEqual([ + SEARCH_CHECK_ROWS_LIMIT, + '묵', + 21, + ]); + expect(page.hits).toHaveLength(20); + expect(page.hasNext).toBe(true); + expect(page.total).toBe(120); + }); + + it('한 글자 낱말 — 창을 다 훑지 못하고 쪽을 못 채우면 이어 찾기, 다 훑었으면 끝', async () => { + const lastScanned = rows(1, 2_999)[0]; + const unfinished = fakeDataSource({ + keyset: () => rows(3), + frontier: () => [{ ...lastScanned, scanned: SEARCH_CHECK_ROWS_LIMIT }], + }); + const page = await search(unfinished.service, criteria({ text: '뷁' })); + expect(page.hits).toHaveLength(3); + expect(page.hasNext).toBe(true); + expect(page.total).toBeNull(); + const key = searchScopeKey(undefined, criteria({ text: '뷁' })); + expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({ + receivedAt: lastScanned.received_at, + emailId: lastScanned.email_id, + }); + + const done = fakeDataSource({ + keyset: () => rows(7), + frontier: () => [{ ...rows(1, 1_499)[0], scanned: 1_500 }], + }); + expect(await search(done.service, criteria({ text: '뷁' }))).toMatchObject({ + hasNext: false, + nextCursor: null, + total: 7, + }); + + const over = fakeDataSource({ + keyset: () => rows(21), + keysetCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }], + }); + expect( + (await search(over.service, criteria({ text: '팀' }))).total, + ).toBeNull(); + }); + + it('낱말 확인이 없으면(기간 · 첨부만) 받은 차례로 바로 — total 은 상한 + 1 까지 세어 넘으면 null', async () => { + const { service, recorded } = fakeDataSource({ + plainPage: (params) => rows(Number(params.at(-1))), + plainCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }], + }); + + const page = await search(service, criteria({ hasAttachment: true }), { + limit: 10, + }); + + expect(recorded.map((query) => query.kind)).toEqual([ + 'plainPage', + 'plainCount', + ]); + expect(recorded[0].params.at(-1)).toBe(11); + expect(recorded[1].params.at(-1)).toBe(SEARCH_TOTAL_LIMIT + 1); + expect( + recorded.every((query) => query.settings.join() === keysetHint.join()), + ).toBe(true); + expect(page.hits).toHaveLength(10); + expect(page.hasNext).toBe(true); + expect(page.total).toBeNull(); + + const few = fakeDataSource({ + plainPage: () => rows(3), + plainCount: () => [{ count: 3 }], + }); + expect( + await search(few.service, criteria({ hasAttachment: true })), + ).toMatchObject({ hasNext: false, nextCursor: null, total: 3 }); + }); + + it('다음 쪽은 커서의 자리부터 · total 은 늘 null — 다른 검색의 커서는 묻기 전에 VAL_001', async () => { + const given = criteria({ text: '계약' }); + const position = { + receivedAt: new Date('2026-11-01T00:00:00Z'), + emailId: 'm40', + }; + const cursor = encodeIndexCursor( + position, + searchScopeKey(undefined, given), + ); + const { service, recorded } = fakeDataSource({ + candidates: () => rows(3, 40), + }); + + const page = await search(service, given, { limit: 20, cursor }); + + expect(recorded[0].params).toEqual( + expect.arrayContaining([position.receivedAt, 'm40']), + ); + expect(page.total).toBeNull(); + expect(page.hasNext).toBe(false); + + const other = fakeDataSource({}); + await expect( + search(other.service, criteria({ text: '견적' }), { limit: 20, cursor }), + ).rejects.toMatchObject({ code: 'VAL_001' }); + expect(other.recorded).toEqual([]); + }); + + it('아무것도 맞을 수 없는 계획이면 묻지 않는다 — 빈 쪽 · total 0', async () => { + const { service, recorded } = fakeDataSource({}); + await expect(search(service, criteria({ text: '…' }))).resolves.toEqual({ + hits: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + await expect( + search(service, criteria({ body: '묵' })), + ).resolves.toMatchObject({ hits: [], total: 0 }); + expect(recorded).toEqual([]); + }); + + it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에(본문 밖의 접은 칸 넷도 — 미리 보기 고르기), id 가 없으면 묻지 않는다', async () => { + const { service, recorded } = fakeDataSource({ + highlight: () => [ + { + email_id: 'm1', + subject: '계약', + body_text: '본문', + preview: '미리', + fold_from: '이도윤 ', + fold_to: '김아라 ', + fold_subject: '계약', + fold_attachment: '계약서.pdf', + }, + ], + }); + + await expect(service.highlightTexts(USER, [])).resolves.toEqual(new Map()); + expect(recorded).toEqual([]); + + const texts = await service.highlightTexts(USER, ['m1', 'm2']); + expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]); + expect([...texts.entries()]).toEqual([ + [ + 'm1', + { + subject: '계약', + bodyText: '본문', + preview: '미리', + otherFields: [ + '이도윤 ', + '김아라 ', + '계약', + '계약서.pdf', + ], + }, + ], + ]); + }); + + it('DB 실패는 그대로 던진다 — 부르는 쪽(목록 서비스)이 첫 쪽이면 엔진 방식으로 찾는다', async () => { + const { service } = fakeDataSource({ + candidates: () => { + throw new Error('db down'); + }, + }); + const error = await search(service, criteria({ text: '계약' })).catch( + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(BusinessException); + }); + + it('다음 쪽에서는 후보가 적어도 모두 확인하지 않는다 — 쪽이 찰 때까지만 늘려 가며 (total 이 없다 — 리뷰 L3)', async () => { + const given = criteria({ text: '계약서' }); + const cursor = encodeIndexCursor( + { receivedAt: new Date('2026-11-01T00:00:00Z'), emailId: 'm9' }, + searchScopeKey(undefined, given), + ); + const { service, recorded } = fakeDataSource({ + candidates: () => rows(1_500, 9), + check: answerChecks(() => true), + }); + + const page = await search(service, given, { limit: 20, cursor }); + + const checks = recorded.filter((query) => query.kind === 'check'); + expect(checks.map((query) => idsOf(query).length)).toEqual([ + SEARCH_FIRST_CHECK_ROWS, + ]); + expect(page.hits).toHaveLength(20); + expect(page.total).toBeNull(); + }); + + it('한 글자 낱말 길의 맞은 줄 · 창의 끝 · 수는 한 스냅숏(REPEATABLE READ 트랜잭션 하나)에서 — 후보 · 낱말 확인 없는 길도 (리뷰 L4)', async () => { + const keyset = fakeDataSource({ + keyset: (params) => rows(Number(params.at(-1))), + keysetCount: () => [{ count: 120 }], + frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }], + }); + await search(keyset.service, criteria({ text: '묵' })); + expect(new Set(keyset.recorded.map((query) => query.transaction))).toEqual( + new Set([1]), + ); + expect(keyset.recorded.map((query) => query.isolation)).toEqual([ + 'REPEATABLE READ', + 'REPEATABLE READ', + 'REPEATABLE READ', + ]); + + const unfinished = fakeDataSource({ + keyset: () => rows(3), + frontier: () => [ + { ...rows(1, 2_999)[0], scanned: SEARCH_CHECK_ROWS_LIMIT }, + ], + }); + await search(unfinished.service, criteria({ text: '뷁' })); + expect(unfinished.recorded.map((query) => query.transaction)).toEqual([ + 1, 1, + ]); + + const plain = fakeDataSource({ + plainPage: () => rows(3), + plainCount: () => [{ count: 3 }], + }); + await search(plain.service, criteria({ hasAttachment: true })); + expect(plain.recorded.map((query) => query.transaction)).toEqual([1, 1]); + }); +}); diff --git a/backend/src/modules/mail-search/mail-search-query.service.ts b/backend/src/modules/mail-search/mail-search-query.service.ts new file mode 100644 index 0000000..7acf18d --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-query.service.ts @@ -0,0 +1,429 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import type { DataSource } from 'typeorm'; + +import { + SEARCH_CANDIDATE_COUNT_LIMIT, + SEARCH_CHECK_ROWS_GROWTH, + SEARCH_CHECK_ROWS_LIMIT, + SEARCH_FIRST_CHECK_ROWS, + SEARCH_TOTAL_LIMIT, +} from './mail-search.constants'; +import type { SearchCriteria, SearchScope } from './mail-search.types'; +import { + decodeIndexCursor, + encodeIndexCursor, + searchScopeKey, +} from './search-cursor'; +import type { SearchPlan } from './search-plan'; +import { + candidatesSql, + checkHitsSql, + highlightTextsSql, + keysetCountSql, + keysetFrontierSql, + keysetHitsSql, + plainCountSql, + plainPageSql, + type SearchPosition, + type SqlQuery, +} from './search-sql'; + +/** 찾은 메일 하나 — 엔진 id 와 받은 시각 */ +export interface IndexHit { + readonly id: string; + readonly receivedAt: Date; +} + +/** 색인으로 찾은 한 쪽 */ +export interface IndexSearchPage { + readonly hits: readonly IndexHit[]; + readonly hasNext: boolean; + readonly nextCursor: string | null; + /** 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null, 다음 쪽은 늘 null */ + readonly total: number | null; +} + +/** 강조에 쓰는 색인의 글 */ +export interface IndexHighlightText { + readonly subject: string; + readonly bodyText: string; + readonly preview: string; + /** 본문 밖의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (미리 보기 고르기 — search-highlight.ts) */ + readonly otherFields: readonly string[]; +} + +/** 쿼리의 줄 */ +interface HitRow { + readonly email_id: string; + readonly received_at: Date; +} + +/** 한 쪽을 찾은 결과 — 커서는 search() 가 만든다 */ +interface Found { + readonly hits: IndexHit[]; + readonly hasNext: boolean; + /** 이어 찾을 자리 — hasNext 일 때만 */ + readonly next: SearchPosition | null; + readonly total: number | null; +} + +/** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */ +type PlannerHint = + /** + * 후보 — 찾기 GIN(주소 · 묶음 · 메일함 — 이것 말고 받을 색인이 없다)의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게) + */ + | 'bitmap' + /** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */ + | 'keyset'; + +/** 한 스냅숏 안의 쿼리 하나 */ +type Ask = (query: SqlQuery) => Promise; + +/** 계획기 설정 — e2e 의 계획 시험(실제 PostgreSQL)도 같은 설정으로 본다 */ +export const PLANNER_SETTINGS: Readonly< + Record +> = { + bitmap: [ + 'SET LOCAL enable_seqscan = off', + 'SET LOCAL enable_indexscan = off', + ], + keyset: [ + 'SET LOCAL enable_bitmapscan = off', + 'SET LOCAL enable_seqscan = off', + ], +}; + +function toHit(row: HitRow): IndexHit { + return { + id: row.email_id, + receivedAt: + row.received_at instanceof Date + ? row.received_at + : new Date(row.received_at), + }; +} + +function positionOf(hit: IndexHit): SearchPosition { + return { receivedAt: hit.receivedAt, emailId: hit.id }; +} + +/** 첫 쪽의 수 — SEARCH_TOTAL_LIMIT 를 넘으면 null */ +function capTotal(count: number): number | null { + return count > SEARCH_TOTAL_LIMIT ? null : count; +} + +/** 맞은 줄(받은 시각 최신부터)에서 한 쪽 — 넘치면 다음 쪽이 있고, 이어 찾을 자리는 이 쪽의 마지막 줄 */ +function pageOf( + hits: readonly IndexHit[], + limit: number, + total: number | null, +): Found { + const page = hits.slice(0, limit); + const hasNext = hits.length > limit; + return { + hits: page, + hasNext, + next: hasNext ? positionOf(page[page.length - 1]) : null, + total, + }; +} + +/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */ +function normalize(user: string): string { + return user.trim().toLowerCase(); +} + +/** + * 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다. + * + * - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로 + * - 묶음이 있으면: 후보를 주소 + 묶음 + 메일함(GIN 하나)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음 + * - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과 + * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다 + * - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다. + * SEARCH_CHECK_ROWS_LIMIT 줄을 확인하고도 쪽을 채우지 못하면 찾은 것까지와 마지막으로 확인한 줄의 커서를 준다(hasNext — 쪽이 비어 있을 수도 있다) + * - 묶음이 없으면(한 글자 낱말뿐 — 본문은 훑지 않아 확인이 값싸다): 받은 차례로 SEARCH_CHECK_ROWS_LIMIT 줄까지 확인한다(이어 찾기는 위와 같다) + * - total 은 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 — 넘거나 이 요청 안에서 다 세지 못하면 null + */ +@Injectable() +export class MailSearchQueryService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async search( + user: string, + scope: SearchScope, + criteria: SearchCriteria, + plan: SearchPlan, + page: { readonly limit: number; readonly cursor?: string }, + ): Promise { + const key = normalize(user); + const scopeKey = searchScopeKey(scope.mailboxId ?? undefined, criteria); + // 커서는 찾기 전에 본다 — 다른 검색의 커서면 VAL_001 + const after = page.cursor ? decodeIndexCursor(page.cursor, scopeKey) : null; + if (plan.matchesNothing) { + return { hits: [], hasNext: false, nextCursor: null, total: 0 }; + } + const firstPage = after === null; + const found = + plan.checks.length === 0 + ? await this.plain(key, scope, plan, after, page.limit, firstPage) + : plan.grams.length === 0 + ? await this.keyset(key, scope, plan, after, page.limit, firstPage) + : await this.byGrams(key, scope, plan, after, page.limit, firstPage); + return { + hits: found.hits, + hasNext: found.hasNext, + nextCursor: + found.hasNext && found.next + ? encodeIndexCursor(found.next, scopeKey) + : null, + total: firstPage ? found.total : null, + }; + } + + /** 강조에 쓰는 글 — 찾은 쪽의 줄만 (없는 줄은 빠진다) */ + async highlightTexts( + user: string, + ids: readonly string[], + ): Promise> { + if (ids.length === 0) return new Map(); + const query = highlightTextsSql(normalize(user), ids); + const rows = await this.dataSource.query< + { + email_id: string; + subject: string; + body_text: string; + preview: string; + fold_from: string; + fold_to: string; + fold_subject: string; + fold_attachment: string; + }[] + >(query.sql, query.params); + return new Map( + rows.map((row) => [ + row.email_id, + { + subject: row.subject, + bodyText: row.body_text, + preview: row.preview, + otherFields: [ + row.fold_from, + row.fold_to, + row.fold_subject, + row.fold_attachment, + ], + }, + ]), + ); + } + + /** 낱말 확인 없이 — 받은 차례로 (첫 쪽은 SEARCH_TOTAL_LIMIT + 1 까지 센다 — 쪽과 수는 같은 스냅숏에서) */ + private plain( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + plainPageSql(user, scope, plan, after, limit + 1), + ); + let total: number | null = null; + if (firstPage) { + const counted = await ask<{ count: number }[]>( + plainCountSql(user, scope, plan, SEARCH_TOTAL_LIMIT + 1), + ); + total = capTotal(Number(counted[0]?.count ?? 0)); + } + return pageOf(rows.map(toHit), limit, total); + }); + } + + /** 묶음이 있다 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인한다 */ + private async byGrams( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + const candidates = ( + await this.snapshot('bitmap', (ask) => + ask( + candidatesSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT + 1), + ), + ) + ).map(toHit); + // 확인 상한 밖에도 후보가 있다 — 이 요청은 상한까지만 본다 + const more = candidates.length > SEARCH_CHECK_ROWS_LIMIT; + const window = more + ? candidates.slice(0, SEARCH_CHECK_ROWS_LIMIT) + : candidates; + + // 두 글자 낱말뿐 — 후보가 곧 결과다 + if (plan.exactByGrams) { + return pageOf(candidates, limit, more ? null : capTotal(window.length)); + } + + // 첫 쪽이고 후보가 적다 — 모두 확인해 total 을 안다 (다음 쪽은 total 이 없으니 쪽이 찰 때까지만 확인한다) + if (firstPage && !more && window.length <= SEARCH_CANDIDATE_COUNT_LIMIT) { + const matched = await this.check(user, plan, window); + const hits = window.filter((hit) => matched.has(hit.id)); + return pageOf(hits, limit, capTotal(hits.length)); + } + + // 후보가 많다 — 받은 차례로 조금씩 확인하다 쪽이 차면 멈춘다 + const hits: IndexHit[] = []; + let checked = 0; + let size = Math.max(SEARCH_FIRST_CHECK_ROWS, (limit + 1) * 2); + while (hits.length <= limit && checked < window.length) { + const slice = window.slice(checked, checked + size); + const matched = await this.check(user, plan, slice); + for (const hit of slice) if (matched.has(hit.id)) hits.push(hit); + checked += slice.length; + size *= SEARCH_CHECK_ROWS_GROWTH; + } + if (hits.length > limit) return pageOf(hits, limit, null); + // 후보를 다 확인했다 — 끝이고 맞은 수를 안다 + if (!more) { + return { hits, hasNext: false, next: null, total: capTotal(hits.length) }; + } + // 확인 상한에 닿았다 — 마지막으로 확인한 후보 다음부터 이어 찾는다 + return { + hits, + hasNext: true, + next: positionOf(window[window.length - 1]), + total: null, + }; + } + + /** 후보 가운데 낱말이 모두 맞은 줄의 id */ + private async check( + user: string, + plan: SearchPlan, + candidates: readonly IndexHit[], + ): Promise> { + if (candidates.length === 0) return new Set(); + const query = checkHitsSql( + user, + plan, + candidates.map((hit) => hit.id), + ); + const rows = await this.dataSource.query<{ email_id: string }[]>( + query.sql, + query.params, + ); + return new Set(rows.map((row) => row.email_id)); + } + + /** + * 묶음이 없다(한 글자 낱말뿐) — 받은 차례로 확인 상한까지 (확인하는 칸이 작아 값싸다). + * 맞은 줄 · 창의 끝 · 첫 쪽의 수는 **같은 스냅숏**에서 읽는다 — 그사이 온 메일로 창이 밀리면 이어 찾을 자리가 어긋나 같은 메일이 다음 쪽에 또 나왔다 + */ + private keyset( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, + firstPage: boolean, + ): Promise { + return this.snapshot('keyset', async (ask) => { + const rows = await ask( + keysetHitsSql( + user, + scope, + plan, + after, + SEARCH_CHECK_ROWS_LIMIT, + limit + 1, + ), + ); + if (rows.length > limit) { + return pageOf( + rows.map(toHit), + limit, + firstPage ? await this.keysetTotal(ask, user, scope, plan) : null, + ); + } + // 쪽을 채우지 못했다 — 확인한 창을 다 훑었나(그러면 끝), 아니면 그 창의 끝부터 이어 찾는다 + const hits = rows.map(toHit); + const frontier = await this.frontier(ask, user, scope, plan, after); + if (frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT) { + return { + hits, + hasNext: false, + next: null, + total: capTotal(hits.length), + }; + } + return { hits, hasNext: true, next: frontier.last, total: null }; + }); + } + + /** 받은 차례로 훑는 창의 끝 — 그 창의 마지막 줄 · 줄 수 (창이 비었으면 null) */ + private async frontier( + ask: Ask, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + ): Promise<{ last: SearchPosition; scanned: number } | null> { + const rows = await ask<(HitRow & { scanned: number })[]>( + keysetFrontierSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT), + ); + const row = rows[0]; + if (!row) return null; + return { + last: positionOf(toHit(row)), + scanned: Number(row.scanned), + }; + } + + /** 받은 차례 길의 첫 쪽 total — 창 안에서 SEARCH_TOTAL_LIMIT + 1 까지 센다. 넘거나 창을 다 훑지 못했으면 null */ + private async keysetTotal( + ask: Ask, + user: string, + scope: SearchScope, + plan: SearchPlan, + ): Promise { + const counted = await ask<{ count: number }[]>( + keysetCountSql( + user, + scope, + plan, + SEARCH_CHECK_ROWS_LIMIT, + SEARCH_TOTAL_LIMIT + 1, + ), + ); + const count = Number(counted[0]?.count ?? 0); + if (count > SEARCH_TOTAL_LIMIT) return null; + const frontier = await this.frontier(ask, user, scope, plan, null); + return frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT + ? count + : null; + } + + /** + * 한 스냅숏(REPEATABLE READ — 읽기만 한다)에서 계획기 설정을 건 뒤 쿼리들을 묻는다 (SET LOCAL — 트랜잭션이 끝나면 돌아온다). + * 스냅숏은 설정 다음의 첫 쿼리에서 잡힌다 + */ + private snapshot( + hint: PlannerHint, + work: (ask: Ask) => Promise, + ): Promise { + return this.dataSource.transaction('REPEATABLE READ', async (manager) => { + for (const setting of PLANNER_SETTINGS[hint]) { + await manager.query(setting); + } + return work((query: SqlQuery) => + manager.query(query.sql, query.params), + ); + }); + } +} diff --git a/backend/src/modules/mail-search/mail-search-runs.ts b/backend/src/modules/mail-search/mail-search-runs.ts new file mode 100644 index 0000000..c4c3166 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-runs.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; + +import { runOutsideRequestContext } from '@/shared/logger/request-context'; + +/** + * 직원마다 색인 일을 한 번에 하나씩 (14단계 설계 B2 — "같은 직원은 한 번에 하나") — 맞추기 · 채우기 한 묶음 · 전체 대조가 같은 줄에 선다. + * 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게. + * + * ⚠️ 프로세스 안의 차례다 — 인스턴스를 늘리면 두 인스턴스가 같은 직원을 함께 맞춘다(쓰기는 상태 줄을 잠가 차례로 되지만 같은 일을 두 번 한다). + * 그때는 잠금(Redis `SET NX PX` 또는 `pg_advisory_lock`)으로 옮긴다 (nestjs 스킬의 Redis 목록) + */ +@Injectable() +export class MailSearchRuns { + private readonly tails = new Map>(); + + /** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */ + run(user: string, task: () => Promise): Promise { + const key = user.toLowerCase(); + const previous = this.tails.get(key) ?? Promise.resolve(); + const start = () => runOutsideRequestContext(task); + const result = previous.then(start, start); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.tails.set(key, tail); + void tail.then(() => { + if (this.tails.get(key) === tail) this.tails.delete(key); + }); + return result; + } + + /** 그 직원의 일이 줄에 있거나 도는 중인가 */ + isBusy(user: string): boolean { + return this.tails.has(user.toLowerCase()); + } +} + +/** + * 함께 도는 일의 수 — 넘는 일은 먼저 온 차례로 기다린다 (14단계 — 실시간 소식이 직원 수만큼 한꺼번에 와도 맞추기는 이만큼만 함께). + * 앞의 일이 실패해도 자리는 돌려준다. + * + * ⚠️ 프로세스 안의 셈이다 — 인스턴스를 늘리면 인스턴스마다 따로 센다 (nestjs 스킬의 Redis 목록) + */ +export class ConcurrencyGate { + private active = 0; + private readonly waiting: (() => void)[] = []; + + constructor(private readonly limit: number) {} + + /** 지금 도는 일 · 기다리는 일의 수 (시험용) */ + get load(): { readonly active: number; readonly waiting: number } { + return { active: this.active, waiting: this.waiting.length }; + } + + async run(task: () => Promise): Promise { + if (this.active >= this.limit) { + await new Promise((resolve) => this.waiting.push(resolve)); + } else { + this.active += 1; + } + try { + return await task(); + } finally { + // 기다리는 일에 자리를 그대로 넘긴다 (자리 수는 그대로) — 없으면 돌려준다 + const next = this.waiting.shift(); + if (next) next(); + else this.active -= 1; + } + } +} diff --git a/backend/src/modules/mail-search/mail-search-state.entity.ts b/backend/src/modules/mail-search/mail-search-state.entity.ts new file mode 100644 index 0000000..46e43f2 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-state.entity.ts @@ -0,0 +1,90 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + SEARCH_EMAIL_COLUMN_LENGTH, + SEARCH_ID_COLUMN_LENGTH, + SEARCH_STATES_TABLE, + SEARCH_WORD_COLUMN_LENGTH, +} from './mail-search.constants'; +import type { + SearchIndexStatus, + SearchPausedReason, +} from './mail-search.types'; + +/** + * 직원마다 색인 상태 한 줄 (14단계 설계 A2) — 직원이 웹메일에 처음 로그인하거나 검색 준비 상태를 물을 때 `building` 으로 만든다. + * 로그인한 적 없는 계정은 색인하지 않는다. + * + * 색인 줄을 쓰는 일은 모두 이 줄을 잠그고(SELECT … FOR UPDATE) 같은 트랜잭션에서 한다 — 줄이 없으면(퇴사 마무리가 지웠다) 쓰지 않는다. + * 퇴사 마무리는 이 줄을 먼저 지우고 색인 줄을 지운다 (ops-data.service.ts — 그사이 끝난 쓰기가 남지 않게) + */ +@Entity({ name: SEARCH_STATES_TABLE }) +@Index('IDX_mail_search_states_status', ['status']) +export class MailSearchStateEntity { + @PrimaryColumn({ + name: 'user_email', + type: 'varchar', + length: SEARCH_EMAIL_COLUMN_LENGTH, + }) + userEmail!: string; + + @Column({ type: 'varchar', length: SEARCH_WORD_COLUMN_LENGTH }) + status!: SearchIndexStatus; + + /** 엔진 Email state — 맞추기(Email/changes)의 기준. 채우기를 시작하기 전에는 null (그때는 맞추지 않는다) */ + @Column({ name: 'email_state', type: 'text', nullable: true }) + emailState!: string | null; + + /** 채우기를 이어 할 자리 — 마지막으로 넣은 메일 id (받은 차례 최신부터) */ + @Column({ + name: 'build_anchor', + type: 'varchar', + length: SEARCH_ID_COLUMN_LENGTH, + nullable: true, + }) + buildAnchor!: string | null; + + /** 채운 메일 수 (anchor 가 지워졌을 때 이어 갈 위치이기도 하다) */ + @Column({ name: 'build_done', type: 'integer', default: 0 }) + buildDone!: number; + + /** 채울 메일 수 — 채우기를 시작할 때의 엔진 메일 수 */ + @Column({ name: 'build_total', type: 'integer', nullable: true }) + buildTotal!: number | null; + + @Column({ name: 'build_started_at', type: 'timestamptz', nullable: true }) + buildStartedAt!: Date | null; + + @Column({ name: 'ready_at', type: 'timestamptz', nullable: true }) + readyAt!: Date | null; + + /** 마지막으로 맞춘 때 */ + @Column({ name: 'last_synced_at', type: 'timestamptz', nullable: true }) + lastSyncedAt!: Date | null; + + /** 대리 접근이 막혀 멈춘 까닭 — 없으면 null (mail-search.types.ts) */ + @Column({ + name: 'paused_reason', + type: 'varchar', + length: SEARCH_WORD_COLUMN_LENGTH, + nullable: true, + }) + pausedReason!: SearchPausedReason | null; + + /** + * 이 줄의 색인을 만든 판 (mail-search.constants.ts 의 INDEX_FORMAT) — 다르면 building 으로 돌려 다시 채운다(그동안 검색은 엔진 방식). + * 판을 적기 전에 만든 줄은 1 + */ + @Column({ name: 'index_format', type: 'smallint', default: 1 }) + indexFormat!: number; + + /** 이어진 실패 수 — 성공하면 0. 많을수록 차례에서 뒤로 미룬다 */ + @Column({ name: 'failure_count', type: 'integer', default: 0 }) + failureCount!: number; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/mail-search/mail-search-sync.service.spec.ts b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts new file mode 100644 index 0000000..8431f07 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-sync.service.spec.ts @@ -0,0 +1,971 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; +import type { IndexRowValues } from './index-document'; +import { + IndexDeadlineError, + type IndexChanges, + type IndexDocument, + type IndexIdPage, + type IndexMutable, + type IndexReadOptions, + type MailIndexSource, +} from './mail-index-source'; +import { MailSearchBuildService } from './mail-search-build.service'; +import type { + MailSearchIndexRepository, + SearchIndexWrite, +} from './mail-search-index.repository'; +import { MailSearchRuns } from './mail-search-runs'; +import type { MailSearchStateEntity } from './mail-search-state.entity'; +import { MailSearchSyncService } from './mail-search-sync.service'; +import type { MailSearchTiming } from './mail-search-timing'; +import { + BUILD_BATCH_SIZE, + BUILD_RUN_BUDGET_MS, + INDEX_CALL_MIN_MS, + INDEX_FORMAT, + STEP_MIN_REMAINING_MS, + SYNC_CONCURRENCY, + SYNC_DEBOUNCE_MS, +} from './mail-search.constants'; + +const DOMAIN = 'arasystem.test'; +const ARA = `ara.kim@${DOMAIN}`; +/** 한국 시각 밤 23시 — 채우기 창 안 */ +const NIGHT = Date.parse('2026-11-05T23:00:00+09:00'); +/** 넉넉한 끝 시각 (시계는 쉬기 · 엔진 읽기 값만큼만 간다) */ +const LONG = 3_600_000; + +/** 시험의 시계 — 엔진 대역과 서비스가 함께 본다 */ +interface TestClock { + now(): number; + advance(ms: number): void; +} + +type ChangeEntry = { + created: string[]; + updated: string[]; + destroyed: string[]; +}; + +/** + * 대역 엔진 — 메일 · state · 변화 기록. 테스트가 메일을 넣고 · 바꾸고 · 지운다. + * 실제 어댑터처럼 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 읽지 않고 IndexDeadlineError, 읽을 때마다 callCostMs 만큼 시계가 간다 + */ +class FakeEngine implements MailIndexSource { + readonly emails = new Map< + string, + { receivedAt: number; mailboxIds: string[]; keywords: string[] } + >(); + private stateNumber = 0; + /** state 번호 → 그 state 가 된 변화 */ + private readonly log: ChangeEntry[] = []; + /** 이보다 오래된 state 는 셀 수 없다 */ + forgetBefore = 0; + /** 다음 부름에 던질 실패 */ + failNext: Error | null = null; + /** 엔진 읽기 한 번에 가는 시간 */ + callCostMs = 0; + /** 있으면 changes 가 답을 만든 뒤 이것이 풀릴 때까지 기다린다 (맞추는 중인 때를 만든다) */ + changesGate: Promise | null = null; + /** 지금 답을 기다리는 changes 수 · 가장 많았던 수 */ + inFlight = 0; + maxInFlight = 0; + readonly calls: string[] = []; + /** documents 에 실린 id 들 (부름마다) */ + readonly documentRequests: string[][] = []; + + constructor(private readonly clock: TestClock) {} + + get state(): string { + return `s${this.stateNumber}`; + } + + add(id: string, receivedAt: number, mailboxIds = ['inbox']): void { + this.emails.set(id, { receivedAt, mailboxIds, keywords: [] }); + this.record({ created: [id], updated: [], destroyed: [] }); + } + + move(id: string, mailboxIds: string[]): void { + const email = this.emails.get(id); + if (email) email.mailboxIds = mailboxIds; + this.record({ created: [], updated: [id], destroyed: [] }); + } + + remove(id: string): void { + this.emails.delete(id); + this.record({ created: [], updated: [], destroyed: [id] }); + } + + /** 엔진이 같은 쪽에 만든 메일을 바뀐 메일로도 함께 알린다 (RFC 8620 은 둘 다 실을 수 있다) */ + addReportedTwice(id: string, receivedAt: number): void { + this.emails.set(id, { receivedAt, mailboxIds: ['inbox'], keywords: [] }); + this.record({ created: [id], updated: [id, id], destroyed: [] }); + } + + /** 변화 기록 없이 넣는다 (지난 메일) */ + seed(count: number, prefix = 'old'): void { + for (let index = 0; index < count; index += 1) { + this.emails.set(`${prefix}${index}`, { + receivedAt: 1_000 + index, + mailboxIds: ['inbox'], + keywords: [], + }); + } + } + + private record(entry: ChangeEntry): void { + this.log.push(entry); + this.stateNumber += 1; + } + + private check(name: string, options: IndexReadOptions): void { + this.calls.push(name); + if (this.failNext) { + const error = this.failNext; + this.failNext = null; + throw error; + } + if (options.deadline - this.clock.now() < INDEX_CALL_MIN_MS) { + throw new IndexDeadlineError(); + } + this.clock.advance(this.callCostMs); + } + + snapshot(_user: string, options: IndexReadOptions) { + this.check('snapshot', options); + return Promise.resolve({ state: this.state, total: this.emails.size }); + } + + async changes( + _user: string, + sinceState: string, + maxChanges: number, + options: IndexReadOptions, + ): Promise { + this.check('changes', options); + const since = Number(sinceState.slice(1)); + let result: IndexChanges; + if (!Number.isInteger(since) || since < this.forgetBefore) { + result = { kind: 'cannotCalculate' }; + } else { + const entries = this.log.slice(since, since + maxChanges); + result = { + kind: 'changes', + created: entries.flatMap((entry) => entry.created), + updated: entries.flatMap((entry) => entry.updated), + destroyed: entries.flatMap((entry) => entry.destroyed), + newState: `s${since + entries.length}`, + hasMore: since + entries.length < this.stateNumber, + }; + } + this.inFlight += 1; + this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); + try { + if (this.changesGate) await this.changesGate; + } finally { + this.inFlight -= 1; + } + return result; + } + + documents( + _user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise { + this.check('documents', options); + this.documentRequests.push([...ids]); + return Promise.resolve( + ids.flatMap((id) => { + const email = this.emails.get(id); + if (!email) return []; + return [ + { + id, + threadId: `t-${id}`, + mailboxIds: email.mailboxIds, + keywords: email.keywords, + receivedAt: new Date(email.receivedAt).toISOString(), + size: 10, + messageIds: [`${id}@example.com`], + inReplyTo: [], + references: [], + subject: `제목 ${id}`, + from: [{ name: null, email: 'partner@example.com' }], + to: [{ name: null, email: ARA }], + cc: [], + attachmentNames: [], + hasAttachment: false, + preview: '', + bodyText: `본문 ${id}`, + }, + ]; + }), + ); + } + + mutable( + _user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise { + this.check('mutable', options); + return Promise.resolve( + ids.flatMap((id) => { + const email = this.emails.get(id); + return email + ? [{ id, mailboxIds: email.mailboxIds, keywords: email.keywords }] + : []; + }), + ); + } + + newestFirst(): string[] { + return [...this.emails.entries()] + .sort((a, b) => b[1].receivedAt - a[1].receivedAt) + .map(([id]) => id); + } + + pageIds( + _user: string, + from: { anchor: string | null; position: number }, + limit: number, + options: IndexReadOptions, + ): Promise { + this.check('pageIds', options); + const all = this.newestFirst(); + // anchor 가 지워졌으면 위치로 (실제 어댑터와 같다) + const anchorAt = from.anchor === null ? -1 : all.indexOf(from.anchor); + const start = anchorAt >= 0 ? anchorAt + 1 : from.position; + return Promise.resolve({ + ids: all.slice(start, start + limit), + position: start, + total: all.length, + }); + } + + allIds(_user: string, options: IndexReadOptions) { + this.check('allIds', options); + return Promise.resolve({ state: this.state, ids: this.newestFirst() }); + } +} + +/** 대역 표 — 상태 줄 · 색인 줄을 메모리에 (색인 줄은 한 직원 것만 본다) */ +class FakeRepository { + readonly states = new Map(); + readonly rows = new Map(); + readonly writes: SearchIndexWrite[] = []; + /** 판 되돌리기 — 부른 수 · 앞으로 실패할 수(DB 장애 흉내) */ + resetCalls = 0; + failResets = 0; + + /** 실제 SQL(mail-search-index.repository.ts)과 같은 판정 — 판이 다르고 주소가 그 도메인으로 끝나는 줄만 */ + resetOutdatedFormats(suffix: string, format: number, now: Date) { + this.resetCalls += 1; + if (this.failResets > 0) { + this.failResets -= 1; + return Promise.reject(new Error('db down')); + } + let count = 0; + for (const state of this.states.values()) { + if (state.indexFormat === format || !state.userEmail.endsWith(suffix)) { + continue; + } + Object.assign(state, { + status: 'building', + emailState: null, + buildAnchor: null, + buildDone: 0, + buildTotal: null, + buildStartedAt: null, + readyAt: null, + failureCount: 0, + indexFormat: format, + updatedAt: now, + }); + count += 1; + } + return Promise.resolve(count); + } + + addState( + overrides: Partial = {}, + ): MailSearchStateEntity { + const state: MailSearchStateEntity = { + userEmail: ARA, + status: 'building', + emailState: null, + buildAnchor: null, + buildDone: 0, + buildTotal: null, + buildStartedAt: null, + readyAt: null, + lastSyncedAt: null, + pausedReason: null, + failureCount: 0, + indexFormat: INDEX_FORMAT, + createdAt: new Date(0), + updatedAt: new Date(0), + ...overrides, + }; + this.states.set(state.userEmail, state); + return state; + } + + findState(user: string) { + return Promise.resolve(this.states.get(user) ?? null); + } + + syncCandidates() { + return Promise.resolve( + [...this.states.values()].filter( + (state) => state.emailState !== null && state.pausedReason === null, + ), + ); + } + + buildCandidates() { + return Promise.resolve( + [...this.states.values()].filter( + (state) => state.status === 'building' && state.pausedReason === null, + ), + ); + } + + recordFailure(user: string, now: Date) { + const state = this.states.get(user); + if (!state) return Promise.resolve(0); + state.failureCount += 1; + state.updatedAt = now; + return Promise.resolve(state.failureCount); + } + + pause(user: string, reason: MailSearchStateEntity['pausedReason']) { + const state = this.states.get(user); + if (state) state.pausedReason = reason; + return Promise.resolve(); + } + + countMessages() { + return Promise.resolve(this.rows.size); + } + + existingIds(_user: string, ids: readonly string[]) { + return Promise.resolve(new Set(ids.filter((id) => this.rows.has(id)))); + } + + indexedIds() { + return Promise.resolve([...this.rows.keys()]); + } + + write(user: string, change: SearchIndexWrite, now: Date) { + const state = this.states.get(user); + if (!state) { + return Promise.resolve({ written: false, updatedIds: new Set() }); + } + this.writes.push(change); + if (change.clearIndex) this.rows.clear(); + for (const id of change.deletes ?? []) this.rows.delete(id); + for (const row of change.upserts ?? []) this.rows.set(row.emailId, row); + const updatedIds = new Set(); + for (const update of change.updates ?? []) { + const row = this.rows.get(update.id); + if (!row) continue; + this.rows.set(update.id, { + ...row, + mailboxIds: [...update.mailboxIds], + keywords: [...update.keywords], + }); + updatedIds.add(update.id); + } + if (change.state) { + Object.assign(state, change.state, { failureCount: 0, updatedAt: now }); + } + return Promise.resolve({ written: true, updatedIds }); + } +} + +describe('검색 색인 맞추기 · 채우기 (MailSearchSyncService · MailSearchBuildService)', () => { + let engine: FakeEngine; + let repository: FakeRepository; + let clock: number; + let timing: MailSearchTiming; + let sync: MailSearchSyncService; + let build: MailSearchBuildService; + let logs: string[]; + + const changesCalls = () => + engine.calls.filter((call) => call === 'changes').length; + const failureLogs = () => + logs.filter((line) => line.includes('못했습니다') || line.includes('실패')); + + beforeEach(() => { + clock = NIGHT; + engine = new FakeEngine({ + now: () => clock, + advance: (ms) => { + clock += ms; + }, + }); + repository = new FakeRepository(); + timing = { + now: () => clock, + // 쉬는 동안 시계만 간다 — 진짜로 기다리지 않는다 + sleep: (ms) => { + clock += ms; + return Promise.resolve(); + }, + }; + const runs = new MailSearchRuns(); + const repo = repository as unknown as MailSearchIndexRepository; + sync = new MailSearchSyncService( + engine, + repo, + runs, + { domain: DOMAIN }, + timing, + ); + build = new MailSearchBuildService(engine, repo, runs, sync, timing); + logs = []; + const record = (message: unknown) => { + logs.push(String(message)); + }; + jest.spyOn(Logger.prototype, 'log').mockImplementation(record); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(record); + jest.spyOn(Logger.prototype, 'error').mockImplementation(record); + }); + + afterEach(() => { + sync.beforeApplicationShutdown(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('채우기 (B3)', () => { + it('시작할 때 state(S0) · 메일 수를 적고, 최신부터 묶음씩 넣은 뒤 따라잡기 · 전체 대조 → ready', async () => { + engine.seed(BUILD_BATCH_SIZE * 2 + 7); + repository.addState(); + + const outcome = await build.build(ARA, clock + LONG); + + expect(outcome).toBe('ready'); + const state = repository.states.get(ARA)!; + expect(state).toMatchObject({ + status: 'ready', + emailState: engine.state, + buildAnchor: null, + buildDone: BUILD_BATCH_SIZE * 2 + 7, + buildTotal: BUILD_BATCH_SIZE * 2 + 7, + }); + expect(state.buildStartedAt).not.toBeNull(); + expect(state.readyAt).not.toBeNull(); + expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 2 + 7); + // 묶음 셋(50 · 50 · 7) + 빈 쪽 — 최신부터 + expect(engine.calls.filter((call) => call === 'pageIds')).toHaveLength(4); + expect( + logs.some((line) => line.includes('검색 색인을 다 만들었습니다')), + ).toBe(true); + expect(logs.join('\n')).not.toContain(ARA); + }); + + it('채우는 동안 온 메일 · 옮긴 메일 · 지운 메일은 맞추기가 넣는다 — 색인에 없던 옮긴 메일은 채우기에 맡긴다', async () => { + engine.seed(BUILD_BATCH_SIZE * 2); + repository.addState(); + // 시작과 첫 묶음까지만 (시계는 묶음 사이 쉬는 동안만 간다 — 끝 시각을 짧게) + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + expect(repository.states.get(ARA)?.buildDone).toBe(BUILD_BATCH_SIZE); + + engine.add('new1', 999_999); + engine.move('old0', ['archive']); // 아직 넣지 않은 지난 메일 (가장 오래된 것) + engine.remove(`old${BUILD_BATCH_SIZE * 2 - 1}`); // 이미 넣은 최신 메일 + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + expect(repository.rows.has('new1')).toBe(true); + expect(repository.rows.has('old0')).toBe(false); + expect(repository.rows.has(`old${BUILD_BATCH_SIZE * 2 - 1}`)).toBe(false); + + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']); + expect(repository.rows.size).toBe(engine.emails.size); + }); + + it('anchor 가 지워지면 위치로 이어 가고(몇 통 건너뛸 수 있다) 마지막 전체 대조가 빠진 것을 채운다', async () => { + engine.seed(120); + repository.addState(); + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + const first = repository.states.get(ARA)!; + expect(first.buildDone).toBe(BUILD_BATCH_SIZE); + // 첫 묶음은 old119 ~ old70 — 그 마지막(anchor)을 지운다. 위치 50 은 이제 old68 이라 old69 를 건너뛴다 + expect(first.buildAnchor).toBe('old70'); + engine.remove('old70'); + + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + expect(repository.rows.has('old70')).toBe(false); + expect(repository.rows.has('old69')).toBe(true); + expect([...repository.rows.keys()].sort()).toEqual( + engine.newestFirst().sort(), + ); + }); + + it('판을 되돌리면 채우기의 첫 묶음이 옛 판 줄을 모두 지운다 — 위치로 건너뛴 메일도 마지막 대조가 새로 넣어 옛 판 줄이 남지 않는다 (리뷰 M1)', async () => { + engine.seed(120); + repository.addState(); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + // 옛 판(다른 접기 규칙)으로 만든 줄처럼 꾸민다 + for (const [id, row] of repository.rows) { + repository.rows.set(id, { ...row, foldBody: 'old-format' }); + } + repository.states.get(ARA)!.indexFormat = INDEX_FORMAT - 1; + + await build.onApplicationBootstrap(); + expect(repository.states.get(ARA)).toMatchObject({ + status: 'building', + buildStartedAt: null, + emailState: null, + indexFormat: INDEX_FORMAT, + }); + + // 시작 + 첫 묶음(old119 ~ old70) → 그 anchor 를 지워 위치로 건너뛰게(old69 를 건너뛴다) → 끝까지 + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + expect(repository.writes.some((write) => write.clearIndex)).toBe(true); + expect(repository.states.get(ARA)?.buildAnchor).toBe('old70'); + engine.remove('old70'); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + + const old = [...repository.rows.values()].filter( + (row) => row.foldBody === 'old-format', + ); + expect(old).toEqual([]); + expect([...repository.rows.keys()].sort()).toEqual( + engine.newestFirst().sort(), + ); + // 처음부터 시작할 때만 지운다 — 이어 하는 묶음은 지우지 않는다 + expect( + repository.writes.filter((write) => write.clearIndex), + ).toHaveLength(2); + }); + + it('서버가 뜰 때 판 되돌리기가 실패하면 1분 차례가 다시 해 본다(낮에도) — 한 번 되면 다시 하지 않고, 사내 도메인 줄만 (리뷰 L5)', async () => { + const state = repository.addState({ + status: 'ready', + emailState: 's', + buildStartedAt: new Date(0), + indexFormat: INDEX_FORMAT - 1, + }); + const other = repository.addState({ + userEmail: 'search.index@e2e.arasystem.test', + status: 'ready', + indexFormat: INDEX_FORMAT - 1, + }); + repository.failResets = 1; + + await build.onApplicationBootstrap(); + expect(state.status).toBe('ready'); + expect(logs.some((line) => line.includes('되돌리지 못했습니다'))).toBe( + true, + ); + + clock = Date.parse('2026-11-05T12:00:00+09:00'); + expect(await build.tick()).toBeNull(); + expect(state).toMatchObject({ + status: 'building', + buildStartedAt: null, + indexFormat: INDEX_FORMAT, + }); + // 다른 도메인 줄은 그쪽 서버의 몫 + expect(other).toMatchObject({ + status: 'ready', + indexFormat: INDEX_FORMAT - 1, + }); + expect(repository.resetCalls).toBe(2); + await build.tick(); + expect(repository.resetCalls).toBe(2); + // 낮이라 채우지는 않는다 + expect(engine.calls).toEqual([]); + }); + + it('채우는 중 엔진이 변화를 셀 수 없으면 대조하되 빠진 메일은 넣지 않는다(채우기가 넣는다) — 지운 것 · 옮긴 것은 맞춘다', async () => { + engine.seed(BUILD_BATCH_SIZE * 2); + repository.addState(); + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + const newest = engine.newestFirst()[0]; + repository.rows.set('ghost', { + ...repository.rows.get(newest)!, + emailId: 'ghost', + }); + engine.emails.get(newest)!.mailboxIds = ['trash']; + // 그사이 온 메일(가장 오래된 받은 시각 — 채우기의 끝에서 읽힌다) — 대조는 넣지 않고 state 만 앞으로 + engine.add('late', 5); + engine.forgetBefore = Number.MAX_SAFE_INTEGER; + + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + + const state = repository.states.get(ARA)!; + expect(state.status).toBe('building'); + expect(state.emailState).toBe(engine.state); + expect(repository.rows.has('ghost')).toBe(false); + expect(repository.rows.get(newest)?.mailboxIds).toEqual(['trash']); + // 아직 채우지 않은 쪽은 그대로 — 채우기가 넣는다 + expect(repository.rows.size).toBe(BUILD_BATCH_SIZE); + + engine.forgetBefore = 0; + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + expect(repository.rows.size).toBe(engine.emails.size); + expect(repository.rows.has('late')).toBe(true); + }); + + it('끝 시각 · 창이 닫힘에 닿으면 다음 묶음을 시작하지 않는다 — 다음 차례가 이어 한다', async () => { + engine.seed(BUILD_BATCH_SIZE * 3); + repository.addState(); + let open = true; + const outcome = await build.build(ARA, clock + LONG, () => { + const keep = open; + open = false; + return keep; + }); + expect(outcome).toBe('partial'); + expect(repository.states.get(ARA)?.status).toBe('building'); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + expect(repository.rows.size).toBe(BUILD_BATCH_SIZE * 3); + }); + + it('표에 넣을 수 없는 긴 id 는 anchor 로 쓰지 않는다 — 넣을 수 있는 마지막 id', async () => { + const longId = 'x'.repeat(65); + engine.seed(3); + engine.emails.set(longId, { + receivedAt: 1, + mailboxIds: ['inbox'], + keywords: [], + }); + repository.addState(); + expect(await build.build(ARA, clock + LONG)).toBe('ready'); + const anchors = repository.writes + .map((write) => write.state?.buildAnchor) + .filter((anchor): anchor is string => typeof anchor === 'string'); + expect(anchors).toEqual(['old0']); + expect(repository.rows.has(longId)).toBe(false); + }); + + it('1분 차례는 밤 창 안 · 사내 도메인 주소만 — 낮에는 아무것도 하지 않는다', async () => { + engine.seed(3); + repository.addState({ userEmail: 'search.index@e2e.arasystem.test' }); + repository.addState(); + clock = Date.parse('2026-11-05T12:00:00+09:00'); + expect(await build.tick()).toBeNull(); + expect(engine.calls).toEqual([]); + clock = NIGHT; + expect(await build.tick()).toBe('ready'); + expect( + repository.states.get('search.index@e2e.arasystem.test') + ?.buildStartedAt, + ).toBeNull(); + }); + + it('상태 줄이 그사이 지워지면(퇴사 마무리) 쓰지 않고 멈춘다', async () => { + engine.seed(BUILD_BATCH_SIZE * 2); + repository.addState(); + await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300); + repository.states.delete(ARA); + expect(await build.build(ARA, clock + LONG)).toBe('skipped'); + }); + }); + + describe('시간 끝은 실패가 아니다 (이어 하기)', () => { + it('엔진 읽기가 끝 시각에 걸리면(IndexDeadlineError) 그 묶음을 쓰지 않고 이어 하기 — 실패 수 · 경고 없음', async () => { + engine.seed(BUILD_BATCH_SIZE); + repository.addState(); + engine.failNext = new IndexDeadlineError(); + expect(await build.build(ARA, clock + LONG)).toBe('partial'); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + + repository.states.get(ARA)!.status = 'ready'; + repository.states.get(ARA)!.emailState = engine.state; + engine.failNext = new IndexDeadlineError(); + expect(await sync.syncUser(ARA, clock + LONG)).toBe(false); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + expect(failureLogs()).toEqual([]); + }); + + it('남은 시간이 모자라면 다음 쪽 · 묶음을 시작하지 않는다 — 엔진이 느려도(읽기 4초) 끝 시각 전에 멈추고 쓴 데까지 남긴다', async () => { + repository.addState({ status: 'ready', emailState: engine.state }); + for (let index = 0; index < 120; index += 1) + engine.add(`m${index}`, index); + engine.callCostMs = 4_000; + + // 20초: changes(4초) → 글 50통 둘(8초) → 다음 쪽 changes(4초) → 그 쪽의 글은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적어 시작하지 않는다 + expect(await sync.syncUser(ARA, clock + 20_000)).toBe(false); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + expect(failureLogs()).toEqual([]); + // 끝 시각 전에 멈췄다 — 엔진 읽기가 끝 시각을 넘지 않았다 + expect(engine.calls.filter((call) => call === 'documents')).toHaveLength( + 2, + ); + + // 다음 차례들이 이어 해서 끝내 따라잡는다 + let caught = false; + for (let round = 0; round < 10 && !caught; round += 1) { + caught = await sync.syncUser(ARA, clock + 60_000); + } + expect(caught).toBe(true); + expect(repository.rows.size).toBe(120); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + }); + + it('마무리(따라잡기 · 전체 대조)는 새 차례의 처음에만 시작하고, 시간 안에 끝나지 않아도 다음 차례에 이어 해 끝내 ready', async () => { + engine.seed(300); + // 지난 메일을 다 읽었는데(빈 쪽) 색인에는 하나도 없다 — 마무리의 대조가 300통을 넣어야 한다 + repository.addState({ + emailState: engine.state, + buildStartedAt: new Date(clock), + buildAnchor: 'old0', + buildDone: 300, + buildTotal: 300, + }); + engine.callCostMs = 10_000; + + // 빈 쪽을 읽은 뒤 남은 시간이 마무리에 모자라면(차례의 끝 무렵) 시작하지 않는다 + expect(await build.build(ARA, clock + 35_000)).toBe('partial'); + expect(engine.calls).not.toContain('allIds'); + + const outcomes: string[] = []; + for (let run = 0; run < 8; run += 1) { + const outcome = await build.build(ARA, clock + BUILD_RUN_BUDGET_MS); + outcomes.push(outcome); + if (outcome === 'ready') break; + } + expect(outcomes[outcomes.length - 1]).toBe('ready'); + expect(outcomes.length).toBeGreaterThan(1); + expect( + outcomes.slice(0, -1).every((outcome) => outcome === 'partial'), + ).toBe(true); + expect(repository.rows.size).toBe(300); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + expect(failureLogs()).toEqual([]); + }); + }); + + describe('맞추기 (B2)', () => { + beforeEach(() => { + repository.addState({ status: 'ready', emailState: engine.state }); + }); + + it('만든 · 바꾼 · 지운 메일을 넣고 state 를 함께 적는다 — 여러 쪽이면 이어 묻는다', async () => { + for (let index = 0; index < 150; index += 1) { + engine.add(`m${index}`, index); + } + engine.move('m0', ['archive']); + engine.remove('m1'); + + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + + expect(repository.rows.size).toBe(149); + expect(repository.rows.get('m0')?.mailboxIds).toEqual(['archive']); + expect(repository.states.get(ARA)?.emailState).toBe(engine.state); + expect(changesCalls()).toBeGreaterThan(1); + }); + + it('한 쪽에 만든 메일이 바뀐 메일로도 함께 오면 글을 한 번만 읽는다', async () => { + engine.addReportedTwice('dup', 1); + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + expect(engine.documentRequests).toEqual([['dup']]); + expect(engine.calls).not.toContain('mutable'); + expect(repository.rows.has('dup')).toBe(true); + }); + + it('다 만든 직원의 색인에 없던 바뀐 메일은 글째 넣는다', async () => { + engine.seed(1); + engine.move('old0', ['archive']); + await sync.syncUser(ARA, clock + LONG); + expect(repository.rows.get('old0')?.mailboxIds).toEqual(['archive']); + }); + + it('엔진이 변화를 셀 수 없으면 전체 대조 — 없어진 것은 지우고 빠진 것은 넣고 메일함을 다시 읽는다', async () => { + engine.add('a', 1); + engine.add('b', 2); + await sync.syncUser(ARA, clock + LONG); + // 색인에만 있는 줄 · 엔진에서 옮긴 메일 · 색인에 없는 메일 + repository.rows.set('ghost', { + ...repository.rows.get('a')!, + emailId: 'ghost', + }); + engine.emails.get('a')!.mailboxIds = ['trash']; + engine.seed(2); + engine.forgetBefore = Number.MAX_SAFE_INTEGER; + + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + + expect([...repository.rows.keys()].sort()).toEqual([ + 'a', + 'b', + 'old0', + 'old1', + ]); + expect(repository.rows.get('a')?.mailboxIds).toEqual(['trash']); + expect(repository.states.get(ARA)?.emailState).toBe(engine.state); + }); + + it('실시간 소식은 모아서 한 번 — 맞추는 중에 온 소식은 한 번 더(그사이 여러 번 와도 한 번), 끝나면 둘 다 들어 있다', async () => { + jest.useFakeTimers({ + doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'], + }); + const settle = async () => { + for (let round = 0; round < 10; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + let release: () => void = () => undefined; + engine.changesGate = new Promise((resolve) => { + release = resolve; + }); + + engine.add('a', 1); + sync.requestSync(ARA); + sync.requestSync(ARA); + sync.requestSync(' Ara.Kim@arasystem.test '); + await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS); + await settle(); + // 첫 맞추기가 엔진의 답을 기다리는 중 — 그사이 새 메일과 소식 셋 + expect(changesCalls()).toBe(1); + engine.add('b', 2); + for (let index = 0; index < 3; index += 1) { + sync.requestSync(ARA); + await jest.advanceTimersByTimeAsync(SYNC_DEBOUNCE_MS); + } + expect(changesCalls()).toBe(1); + + engine.changesGate = null; + release(); + await settle(); + + // 첫 맞추기(a 까지 본 답) 뒤 한 번 더 — b 까지 + expect(changesCalls()).toBe(2); + expect(repository.rows.has('a')).toBe(true); + expect(repository.rows.has('b')).toBe(true); + }); + + it('서버 전체에서 함께 도는 맞추기는 SYNC_CONCURRENCY 개까지 — 나머지는 기다렸다 모두 맞춘다', async () => { + const users = Array.from( + { length: SYNC_CONCURRENCY + 2 }, + (_, index) => `user${index}@${DOMAIN}`, + ); + for (const user of users) { + repository.addState({ + userEmail: user, + status: 'ready', + emailState: engine.state, + }); + } + let release: () => void = () => undefined; + engine.changesGate = new Promise((resolve) => { + release = resolve; + }); + const syncs = users.map((user) => sync.syncBeforeRead(user, LONG)); + for (let round = 0; round < 10; round += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(engine.inFlight).toBe(SYNC_CONCURRENCY); + + engine.changesGate = null; + release(); + expect(await Promise.all(syncs)).toEqual(users.map(() => true)); + expect(engine.maxInFlight).toBe(SYNC_CONCURRENCY); + expect(changesCalls()).toBe(users.length); + }); + + it('검색 바로 전 맞추기는 기다리는 만큼만 — 기다리는 맞추기가 있으면 함께 기다린다', async () => { + engine.add('a', 1); + const [first, second] = await Promise.all([ + sync.syncBeforeRead(ARA, 5_000), + sync.syncBeforeRead(ARA, 5_000), + ]); + expect(first).toBe(true); + expect(second).toBe(true); + expect(changesCalls()).toBe(1); + }); + + it('5분 차례는 맞출 기준이 있는 사내 직원만, 실패가 이어진 직원은 때가 될 때까지 건너뛴다', async () => { + repository.addState({ userEmail: `new@${DOMAIN}` }); // 채우기를 시작하지 않음 + repository.addState({ + userEmail: `failing@${DOMAIN}`, + status: 'ready', + emailState: engine.state, + failureCount: 3, + updatedAt: new Date(clock), + }); + // 같은 DB 의 다른 도메인 줄(e2e 등) — 맞추지 않는다 + repository.addState({ + userEmail: 'search.index@e2e.arasystem.test', + status: 'ready', + emailState: engine.state, + }); + engine.add('a', 1); + await sync.syncAll(); + expect(repository.rows.has('a')).toBe(true); + expect(changesCalls()).toBe(1); + expect(sync.isIndexedAddress('x@e2e.arasystem.test')).toBe(false); + expect(sync.isIndexedAddress(' Ara.Kim@ARASYSTEM.test ')).toBe(true); + }); + }); + + describe('실패', () => { + beforeEach(() => { + repository.addState({ status: 'ready', emailState: engine.state }); + engine.add('a', 1); + }); + + it.each([ + [ + '비밀번호 만료', + new PasswordExpiredException(ARA, 'ara.kim'), + 'password-expired', + ], + ['잠김', new AccountLoginDisabledException(ARA), 'locked'], + [ + '엔진에 없음', + new BusinessException('AUTH_001', { + context: { reason: 'impersonation-rejected' }, + }), + 'locked', + ], + ] as const)( + '대리 접근이 막히면(%s) 멈춘다 — 맞추기 차례에서 빠진다', + async (_name, error, reason) => { + engine.failNext = error; + expect(await sync.syncUser(ARA, clock + LONG)).toBe(false); + expect(repository.states.get(ARA)?.pausedReason).toBe(reason); + expect(await repository.syncCandidates()).toEqual([]); + expect(logs.join('\n')).not.toContain(ARA); + }, + ); + + it('엔진 장애는 실패 수를 더하고 처음 한 번만 경고한다 — 성공하면 0 (엔진 설명 문장은 남기지 않는다)', async () => { + const outage = () => + new BusinessException('MAIL_001', { + context: { + reason: 'http-error', + jmapType: 'serverFail', + description: '비밀 문장', + }, + }); + engine.failNext = outage(); + await sync.syncUser(ARA, clock + LONG); + engine.failNext = outage(); + await sync.syncUser(ARA, clock + LONG); + expect(repository.states.get(ARA)?.failureCount).toBe(2); + const warnings = logs.filter((line) => + line.includes('맞추지 못했습니다'), + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('MAIL_001/http-error/serverFail'); + expect(logs.join('\n')).not.toContain('비밀 문장'); + + expect(await sync.syncUser(ARA, clock + LONG)).toBe(true); + expect(repository.states.get(ARA)?.failureCount).toBe(0); + }); + }); +}); diff --git a/backend/src/modules/mail-search/mail-search-sync.service.ts b/backend/src/modules/mail-search/mail-search-sync.service.ts new file mode 100644 index 0000000..bae36c8 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-sync.service.ts @@ -0,0 +1,483 @@ +import { + BeforeApplicationShutdown, + Inject, + Injectable, + Logger, + Optional, +} from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { maskEmail } from '@/shared/utils/mask'; +import { settledWithin } from '@/shared/utils/settle'; +import { + isIndexableId, + toIndexRow, + type IndexRowValues, +} from './index-document'; +import { + IndexDeadlineError, + MAIL_INDEX_SOURCE, + type IndexChanges, + type IndexMutable, + type MailIndexSource, +} from './mail-index-source'; +import { + MailSearchIndexRepository, + type SearchStatePatch, +} from './mail-search-index.repository'; +import { ConcurrencyGate, MailSearchRuns } from './mail-search-runs'; +import { + MAIL_SEARCH_TIMING, + SYSTEM_SEARCH_TIMING, + type MailSearchTiming, +} from './mail-search-timing'; +import { + DOCUMENT_READ_BATCH, + FAILURE_BACKOFF_STEPS_MAX, + MUTABLE_READ_BATCH, + PRE_READ_SYNC_WAIT_MS, + RECONCILE_BUDGET_MS, + RECONCILE_WRITE_BATCH, + SEARCH_CRON_TIME_ZONE, + STEP_MIN_REMAINING_MS, + SYNC_BUDGET_MS, + SYNC_CHANGES_PAGE, + SYNC_CONCURRENCY, + SYNC_DEBOUNCE_MS, +} from './mail-search.constants'; +import type { + SearchIndexStatus, + SearchPausedReason, +} from './mail-search.types'; +import { isDueAfterFailures } from './search-index-schedule'; + +/** 전체 대조에서 무엇을 하나 */ +export interface ReconcileOptions { + /** 엔진에는 있는데 색인에 없는 메일을 넣는다 — 채우는 중(building)이면 채우기가 할 일이라 넣지 않는다 */ + readonly addMissing: boolean; + /** 색인에 있는 메일의 메일함 · 키워드를 다시 읽는다 — 변화를 놓쳤을 때(cannotCalculateChanges) */ + readonly refreshMutable: boolean; + /** 끝에 상태 줄에 함께 쓸 것 (채우기의 마무리 — ready) */ + readonly finalState?: (indexed: number) => SearchStatePatch; +} + +/** 줄을 나눈다 */ +function chunks(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let index = 0; index < items.length; index += size) { + out.push(items.slice(index, index + size)); + } + return out; +} + +/** 로그에 남길 실패 요약 — 코드 · 사유 · 엔진 오류 종류만 (엔진 설명 문장 · 주소 · 글은 넣지 않는다) */ +function failureSummary(error: unknown): string { + if (error instanceof BusinessException) { + const reason = error.context?.reason; + const jmapType = error.context?.jmapType; + return [ + error.code, + typeof reason === 'string' ? reason : null, + typeof jmapType === 'string' ? jmapType : null, + ] + .filter(Boolean) + .join('/'); + } + // DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만 + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** 대리 접근이 막힌 실패인가 → 멈출 까닭 (아니면 null) */ +function pausedReasonOf(error: unknown): SearchPausedReason | null { + if (error instanceof PasswordExpiredException) return 'password-expired'; + if (error instanceof AccountLoginDisabledException) return 'locked'; + // 엔진에 그 계정이 없다(대리 접근 401) — 로그인할 수 없는 계정이라 멈춘다 + if (error instanceof BusinessException && error.code === 'AUTH_001') { + return 'locked'; + } + return null; +} + +/** + * 검색 색인 맞추기 (14단계 설계 B2) — 엔진의 Email state 부터 변화(Email/changes)를 받아 색인에 넣는다. 엔진을 모른다(포트 MAIL_INDEX_SOURCE). + * + * 부르는 때: ① 실시간 연결의 state 소식(SYNC_DEBOUNCE_MS 모음 — mail-events.service.ts) ② 5분마다 모든 직원(한 사람씩 · SYNC_BUDGET_MS 안) + * ③ 검색 · 대화 · 묶은 목록 바로 전 한 번(PRE_READ_SYNC_WAIT_MS 까지 기다린다 — 넘으면 지금 색인으로 답하고 맞추기는 이어 한다 — 14단계 2차부터 부른다). + * 같은 직원은 한 번에 하나(MailSearchRuns) — 기다리는 맞추기가 있으면 새로 넣지 않고 그것을 함께 기다린다. + * 서버 전체에서는 SYNC_CONCURRENCY 개까지 함께 맞춘다(ConcurrencyGate) — 전 직원에게 메일 한 통이 와도 엔진에 한꺼번에 몰리지 않게. + * 맞추는 직원은 사내 도메인(MAIL_DOMAIN) 주소뿐이다 — 로그인할 수 있는 주소만 상태 줄이 생긴다(같은 DB 의 다른 도메인 줄 — e2e 등 — 은 건드리지 않는다). + * + * - 만든 메일 → 글을 읽어 넣는다. 바뀐 메일 → 메일함 · 키워드만(JMAP 에서 글은 바뀌지 않는다) — 색인에 없던 메일은 다 만든 직원(ready)이면 글을 읽어 넣고, + * 채우는 중이면 건너뛴다(채우기가 최신 글로 넣는다). 없어진 메일 → 지운다 + * - 한 쪽(SYNC_CHANGES_PAGE)마다 그 쪽의 쓰기와 새 state 를 한 트랜잭션에 — 끝 시각에 닿으면 쓴 데까지가 맞춘 곳이다 + * - **시간 끝은 실패가 아니다** — 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 다음 쪽 · 묶음을 시작하지 않고, 엔진 읽기가 끝 시각에 걸려 + * 멈추면(IndexDeadlineError) 쓴 데까지 두고 다음 차례에 이어 한다(실패 수 · 경고 · 미루기 없음) + * - 엔진이 셀 수 없다(cannotCalculateChanges) → 전체 대조(reconcile — 모든 id 를 견주어 없는 것은 지우고, 메일함 · 키워드를 다시 읽는다) + * - 대리 접근이 막혔다(비밀번호 만료 · 잠김 · 계정 없음) → 멈춘다(paused_reason — 다음 로그인 · 비밀번호 바꾸기 때 푼다). 그 밖의 실패는 실패 수를 더하고 + * 다음 차례를 늦춘다(isDueAfterFailures). 로그는 처음 실패 · 오래 이어질 때만 — 코드 · 사유뿐이다(엔진 설명 문장 · 메일 글 · 주소 없음) + * + * ⚠️ 차례 · 모음 · 동시 수(debounces · queued · syncingAll · gate)는 프로세스 메모리다 — 인스턴스를 늘리면 옮긴다 (nestjs 스킬의 Redis 목록) + */ +@Injectable() +export class MailSearchSyncService implements BeforeApplicationShutdown { + private readonly logger = new Logger(MailSearchSyncService.name); + private readonly debounces = new Map(); + /** 줄에 넣었지만 아직 시작하지 않은 맞추기 — 같은 직원의 새 요청이 함께 기다린다 */ + private readonly queued = new Map>(); + /** 서버 전체에서 함께 도는 맞추기 수 */ + private readonly gate = new ConcurrencyGate(SYNC_CONCURRENCY); + private syncingAll = false; + private stopping = false; + private readonly timing: MailSearchTiming; + /** 맞추는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무도 맞추지 않는다) */ + private readonly addressSuffix: string | null; + + constructor( + @Inject(MAIL_INDEX_SOURCE) private readonly source: MailIndexSource, + private readonly repository: MailSearchIndexRepository, + private readonly runs: MailSearchRuns, + @Inject(MAIL_SETTINGS) settings: Pick, + @Optional() @Inject(MAIL_SEARCH_TIMING) timing?: MailSearchTiming, + ) { + this.timing = timing ?? SYSTEM_SEARCH_TIMING; + const domain = settings.domain?.trim().toLowerCase(); + this.addressSuffix = domain ? `@${domain}` : null; + } + + /** 색인하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null) — 판이 바뀐 줄 되돌리기가 쓴다 */ + get indexedAddressSuffix(): string | null { + return this.addressSuffix; + } + + /** 색인하는 주소인가 — 사내 도메인(MAIL_DOMAIN)의 주소만 (5분 맞추기 · 밤 채우기의 차례가 본다) */ + isIndexedAddress(user: string): boolean { + return ( + this.addressSuffix !== null && + user.trim().toLowerCase().endsWith(this.addressSuffix) + ); + } + + /** 실시간 소식 — SYNC_DEBOUNCE_MS 동안 모아 맞추기 한 번 (던지지 않는다) */ + requestSync(user: string): void { + const key = user.trim().toLowerCase(); + if (this.stopping || key === '' || this.debounces.has(key)) return; + const timer = setTimeout(() => { + this.debounces.delete(key); + void this.enqueueSync(key); + }, SYNC_DEBOUNCE_MS); + timer.unref(); + this.debounces.set(key, timer); + } + + /** + * 검색 · 대화 · 묶은 목록 바로 전 — 맞추기를 `waitMs` 까지 기다린다 → 그 안에 다 맞췄으면 true. + * 넘으면 false 이고 맞추기는 뒤에서 이어 한다 (부른 쪽은 지금 색인으로 답한다). 던지지 않는다 + */ + async syncBeforeRead( + user: string, + waitMs: number = PRE_READ_SYNC_WAIT_MS, + ): Promise { + const key = user.trim().toLowerCase(); + if (this.stopping || key === '') return false; + const sync = this.enqueueSync(key); + const settled = await settledWithin(sync, waitMs); + return settled && (await sync); + } + + /** 5분마다 — 맞출 기준이 있는 사내 직원을 한 사람씩 (실패가 이어진 직원은 때가 될 때까지 건너뛴다) */ + @Cron('0 */5 * * * *', { + name: 'mail-search-sync', + timeZone: SEARCH_CRON_TIME_ZONE, + }) + async syncAll(): Promise { + if (this.syncingAll || this.stopping) return; + this.syncingAll = true; + try { + const candidates = await this.repository.syncCandidates(); + for (const state of candidates) { + if (this.stopping) break; + if ( + !this.isIndexedAddress(state.userEmail) || + !isDueAfterFailures( + state.failureCount, + state.updatedAt, + this.timing.now(), + ) + ) { + continue; + } + await this.enqueueSync(state.userEmail); + } + } catch (error) { + this.logger.error( + `[SYS_001] 검색 색인 맞추기 차례를 읽지 못했습니다: ${failureSummary(error)}`, + ); + } finally { + this.syncingAll = false; + } + } + + beforeApplicationShutdown(): void { + this.stopping = true; + for (const timer of this.debounces.values()) clearTimeout(timer); + this.debounces.clear(); + } + + /** 서버가 닫히는 중인가 — 채우기도 멈춘다 */ + get isStopping(): boolean { + return this.stopping; + } + + /** + * 한 직원을 끝 시각까지 맞춘다 → 다 따라잡았으면 true. 던지지 않는다 — 실패는 상태 줄(멈춤 · 실패 수)과 로그로, + * 시간 끝(IndexDeadlineError)은 실패로 세지 않고 false 다(다음 차례에 이어 한다). 그 직원의 차례(MailSearchRuns) 안에서 부른다 + */ + async syncUser(user: string, deadline: number): Promise { + try { + return await this.catchUp(user, deadline); + } catch (error) { + if (!(error instanceof IndexDeadlineError)) { + await this.handleFailure(user, error, 'sync'); + } + return false; + } + } + + /** + * syncUser 의 던지는 판 — 채우기의 마무리가 쓴다. 상태 줄이 없거나 · 멈췄거나 · 맞출 기준(email_state)이 없으면 false. + * 끝 시각 · 서버 종료에 닿아도 false (쓴 데까지가 맞춘 곳이다). 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다 + */ + async catchUp(user: string, deadline: number): Promise { + const state = await this.repository.findState(user); + if (!state || state.pausedReason !== null || state.emailState === null) { + return false; + } + let since = state.emailState; + for (;;) { + if (!this.hasTime(deadline)) return false; + const changes = await this.source.changes( + user, + since, + SYNC_CHANGES_PAGE, + { deadline }, + ); + if (changes.kind === 'cannotCalculate') { + // 변화를 놓쳤다 — 모든 메일을 견준다(더 긴 시간 안에서). 채우는 중이면 없는 메일은 채우기가 넣는다 + return this.reconcile( + user, + Math.max(deadline, this.timing.now() + RECONCILE_BUDGET_MS), + { + addMissing: state.status === 'ready', + refreshMutable: true, + }, + ); + } + const applied = await this.applyChanges( + user, + state.status, + changes, + deadline, + ); + if (!applied) return false; + since = changes.newState; + if (!changes.hasMore) return true; + } + } + + /** + * 전체 대조 — 엔진의 모든 메일 id 와 색인을 견주어 색인에만 있는 것을 지우고, 고르면 없는 것을 넣고 · 메일함 · 키워드를 다시 읽는다. + * 끝에 그 대조를 시작하기 바로 전의 state 를 맞출 기준으로 적는다(그 뒤의 변화는 다음 맞추기가 다시 한다 — 넣기 · 지우기는 몇 번 해도 같다). + * 끝 시각에 닿으면 false 이고 기준은 그대로다 — 지운 것 · 넣은 것은 남으므로 다음 대조는 남은 것만 한다(메일함이 커도 끝내 끝난다). 던진다 + */ + async reconcile( + user: string, + deadline: number, + options: ReconcileOptions, + ): Promise { + const engine = await this.source.allIds(user, { deadline }); + const engineIds = engine.ids.filter(isIndexableId); + const engineSet = new Set(engineIds); + const indexed = new Set(await this.repository.indexedIds(user)); + + const stale = [...indexed].filter((id) => !engineSet.has(id)); + for (const batch of chunks(stale, RECONCILE_WRITE_BATCH)) { + if (!(await this.write(user, { deletes: batch }))) return false; + } + + if (options.addMissing) { + const missing = engineIds.filter((id) => !indexed.has(id)); + for (const batch of chunks(missing, DOCUMENT_READ_BATCH)) { + const rows = await this.readRows(user, batch, deadline); + if (rows === null) return false; + if (!(await this.write(user, { upserts: rows }))) return false; + } + } + + if (options.refreshMutable) { + const kept = engineIds.filter((id) => indexed.has(id)); + for (const batch of chunks(kept, MUTABLE_READ_BATCH)) { + if (!this.hasTime(deadline)) return false; + const updates = await this.source.mutable(user, batch, { deadline }); + if (!(await this.write(user, { updates }))) return false; + } + } + + const indexedCount = await this.repository.countMessages(user); + return this.write(user, { + state: { + emailState: engine.state, + lastSyncedAt: new Date(this.timing.now()), + ...(options.finalState ? options.finalState(indexedCount) : {}), + }, + }); + } + + /** + * 실패 하나를 다룬다 — 대리 접근이 막혔으면 멈추고(warn), 아니면 실패 수를 더한다(처음 · FAILURE_BACKOFF_STEPS_MAX 번째에만 로그). 던지지 않는다. + * 시간 끝(IndexDeadlineError)은 실패가 아니라 아무것도 하지 않는다 + */ + async handleFailure( + user: string, + error: unknown, + phase: 'sync' | 'build', + ): Promise { + if (error instanceof IndexDeadlineError) return; + const now = new Date(this.timing.now()); + const summary = failureSummary(error); + try { + const reason = pausedReasonOf(error); + if (reason !== null) { + await this.repository.pause(user, reason, now); + this.logger.warn( + `[${error instanceof BusinessException ? error.code : 'MAIL_001'}] 대리 접근이 막혀 검색 색인을 멈춥니다(다음 로그인 때 다시): user=${maskEmail(user)}, reason=${reason}`, + ); + return; + } + const failures = await this.repository.recordFailure(user, now); + const code = error instanceof BusinessException ? error.code : 'SYS_001'; + if (failures === 1) { + this.logger.warn( + `[${code}] 검색 색인을 ${phase === 'sync' ? '맞추지' : '채우지'} 못했습니다(뒤에 다시): user=${maskEmail(user)}, ${summary}`, + ); + } else if (failures === FAILURE_BACKOFF_STEPS_MAX) { + this.logger.error( + `[${code}] 검색 색인 ${phase === 'sync' ? '맞추기' : '채우기'}가 ${failures}번 이어 실패했습니다: user=${maskEmail(user)}, ${summary}`, + ); + } + } catch (recordError) { + this.logger.error( + `[SYS_001] 검색 색인의 실패를 적지 못했습니다: user=${maskEmail(user)}, ${summary}, record=${failureSummary(recordError)}`, + ); + } + } + + /** + * 줄에 맞추기를 넣는다 — 기다리는 맞추기(아직 엔진에 묻기 전 — 그 직원의 차례 · 서버 전체 자리를 기다리는 중)가 있으면 그것을 함께 기다린다 + */ + private enqueueSync(user: string): Promise { + const waiting = this.queued.get(user); + if (waiting) return waiting; + const sync = this.runs.run(user, () => + this.gate.run(() => { + // 시작했다 — 이 뒤의 소식은 맞추기를 하나 더 넣는다 (이 맞추기가 놓칠 수 있다) + this.queued.delete(user); + if (this.stopping) return Promise.resolve(false); + return this.syncUser(user, this.timing.now() + SYNC_BUDGET_MS); + }), + ); + this.queued.set(user, sync); + void sync.catch(() => this.queued.delete(user)); + return sync; + } + + /** 한 쪽의 변화를 한 트랜잭션에 → 썼으면 true (상태 줄이 없거나 끝 시각이면 false) */ + private async applyChanges( + user: string, + status: SearchIndexStatus, + changes: Extract, + deadline: number, + ): Promise { + const destroyed = new Set(changes.destroyed); + const created = new Set( + changes.created.filter((id) => isIndexableId(id) && !destroyed.has(id)), + ); + // 만든 메일은 글째 넣으므로 바뀐 메일에서 뺀다 (한 쪽에 둘 다 오면 Email/get 에 두 번 싣지 않게) + const updated = [ + ...new Set( + changes.updated.filter( + (id) => isIndexableId(id) && !destroyed.has(id) && !created.has(id), + ), + ), + ]; + + const existing = await this.repository.existingIds(user, updated); + // 색인에 없던 바뀐 메일 — 다 만든 직원이면 글째 넣는다 (채우는 중이면 채우기가 넣는다) + const unknown = + status === 'ready' ? updated.filter((id) => !existing.has(id)) : []; + const rows = await this.readRows(user, [...created, ...unknown], deadline); + if (rows === null) return false; + + let updates: IndexMutable[] = []; + const known = updated.filter((id) => existing.has(id)); + if (known.length > 0) { + if (!this.hasTime(deadline)) return false; + updates = await this.source.mutable(user, known, { deadline }); + } + return this.write(user, { + deletes: [...destroyed], + upserts: rows, + updates, + state: { + emailState: changes.newState, + lastSyncedAt: new Date(this.timing.now()), + }, + }); + } + + /** + * 메일 글을 DOCUMENT_READ_BATCH 씩 읽어 색인 줄로 (그사이 없어진 메일은 빠진다) — 다음 묶음을 시작할 시간(STEP_MIN_REMAINING_MS)이 없으면 null. + * 묶음 안의 엔진 읽기가 끝 시각에 걸리면 IndexDeadlineError 를 던진다 + */ + async readRows( + user: string, + ids: readonly string[], + deadline: number, + ): Promise { + const rows: IndexRowValues[] = []; + for (const batch of chunks(ids, DOCUMENT_READ_BATCH)) { + if (!this.hasTime(deadline)) return null; + const documents = await this.source.documents(user, batch, { deadline }); + for (const document of documents) { + if (isIndexableId(document.id)) rows.push(toIndexRow(document)); + } + } + return rows; + } + + private async write( + user: string, + change: Parameters[1], + ): Promise { + const result = await this.repository.write( + user, + change, + new Date(this.timing.now()), + ); + return result.written; + } + + /** 다음 쪽 · 묶음을 시작할 시간이 있나 — 끝 시각까지 STEP_MIN_REMAINING_MS 이상 */ + private hasTime(deadline: number): boolean { + return ( + !this.stopping && this.timing.now() <= deadline - STEP_MIN_REMAINING_MS + ); + } +} diff --git a/backend/src/modules/mail-search/mail-search-timing.ts b/backend/src/modules/mail-search/mail-search-timing.ts new file mode 100644 index 0000000..42b4b24 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search-timing.ts @@ -0,0 +1,19 @@ +/** + * 검색 색인의 시계 · 쉬기 (14단계) — 테스트가 바꿔 끼운다(고정 대기 없이 — testing 규칙). + * 앱은 Date.now · setTimeout(unref — 종료를 붙잡지 않는다)이다 + */ +export const MAIL_SEARCH_TIMING = Symbol('MAIL_SEARCH_TIMING'); + +export interface MailSearchTiming { + now(): number; + sleep(ms: number): Promise; +} + +export const SYSTEM_SEARCH_TIMING: MailSearchTiming = { + now: () => Date.now(), + sleep: (ms) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref(); + }), +}; diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts new file mode 100644 index 0000000..ef50865 --- /dev/null +++ b/backend/src/modules/mail-search/mail-search.constants.ts @@ -0,0 +1,178 @@ +/** + * 메일 검색 색인 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕) 의 상수. + * 묶음 크기 · 시간 한도는 개발 엔진에서 잰 뒤 바꾼다 (설계 E 의 2 — 1만 통 재기). 바꿀 때는 이 파일만 고친다. + */ + +/** 표의 이름 — 마이그레이션 `AddMailSearchIndex` 와 같다 */ +export const SEARCH_MESSAGES_TABLE = 'mail_search_messages'; +export const SEARCH_STATES_TABLE = 'mail_search_states'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +export const SEARCH_EMAIL_COLUMN_LENGTH = 254; +/** 엔진 id(메일 · 대화)의 열 길이 — Stalwart 는 13자 안팎이다. 넘는 id 는 색인하지 않는다 (index-document.ts) */ +export const SEARCH_ID_COLUMN_LENGTH = 64; +/** 짧은 낱말 열(상태 · 멈춘 까닭) */ +export const SEARCH_WORD_COLUMN_LENGTH = 32; + +/** + * 본문 글을 색인에 담는 길이 — 처음 이만큼의 코드 포인트 (설계 A1, 처음 값 20,000 — 잰 뒤 정한다). + * 그 뒤의 글로는 찾지 못한다 (긴 메일의 뒷부분) + */ +export const INDEX_BODY_MAX_CHARS = 20_000; +/** 제목을 담는 길이 (코드 포인트) — 바깥에서 온 메일의 아주 긴 제목이 줄을 키우지 않게 */ +export const INDEX_SUBJECT_MAX_CHARS = 1_000; +/** 보낸 사람 · 받는 사람 · 참조 한 칸에 담는 주소 수 — 받는 사람이 수천인 메일이 줄을 키우지 않게 */ +export const INDEX_ADDRESSES_MAX = 200; +/** 주소 한 줄("이름 <주소>")의 길이 (코드 포인트) */ +export const INDEX_ADDRESS_LINE_MAX_CHARS = 400; +/** + * 첫 보낸 사람의 이름을 담는 길이 (코드 포인트 — 열 `from_name` 의 varchar 길이와 같다). 묶은 목록의 참여자 이름이다 (14단계 3차 리뷰 L3). + * 주소(`from_email`)는 SEARCH_EMAIL_COLUMN_LENGTH 까지 — 넘으면 담지 않는다 + */ +export const INDEX_FROM_NAME_MAX_CHARS = 100; +/** 첨부 이름을 담는 수 · 이름 하나의 길이 (코드 포인트) */ +export const INDEX_ATTACHMENT_NAMES_MAX = 100; +export const INDEX_ATTACHMENT_NAME_MAX_CHARS = 255; +/** 미리 보기(엔진 preview)의 길이 (코드 포인트) — 엔진은 256자 안쪽을 준다 */ +export const INDEX_PREVIEW_MAX_CHARS = 512; +/** 답장 관계(In-Reply-To ∪ References)를 담는 수 · Message-ID 하나의 길이 — 넘는 것은 담지 않는다 */ +export const INDEX_REFS_MAX = 50; +export const INDEX_MESSAGE_ID_MAX_LENGTH = 255; + +/** + * 시간 끝 — 차례(맞추기 · 채우기)의 끝 시각에 닿아 멈추는 것은 실패가 아니라 "이어 하기"다 (실패 수 · 경고 · 미루기 없음). + * - 엔진 읽기 한 번은 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고(IndexDeadlineError), 한 번에 INDEX_CALL_TIMEOUT_MS 까지만 기다린다 + * - 맞추기 한 쪽 · 채우기 한 묶음 · 대조의 한 걸음은 남은 시간이 STEP_MIN_REMAINING_MS 보다 적으면 시작하지 않는다 — 시작한 묶음이 짧은 시간에 잘리지 않게 + * - 채우기의 마무리(따라잡기 · 전체 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만(= 새 차례의 처음) 시작한다 + */ +export const INDEX_CALL_MIN_MS = 2_000; +export const INDEX_CALL_TIMEOUT_MS = 15_000; +export const STEP_MIN_REMAINING_MS = 5_000; +export const FINISH_MIN_REMAINING_MS = 30_000; +/** 전체 대조(엔진이 변화를 셀 수 없을 때)의 시간 — 모든 메일의 메일함 · 키워드를 다시 읽는다 */ +export const RECONCILE_BUDGET_MS = 120_000; + +/** + * 본문 값을 한 번에 읽는 메일의 크기 상한 — 메일 **전체** 크기(엔진의 size)의 합. 엔진은 값을 만들며 원문을 읽으므로, + * 큰 첨부가 든 메일 여럿을 한 요청에 묶으면 엔진(메모리 1g)이 한꺼번에 그만큼 읽는다. 이보다 큰 메일 한 통은 혼자 읽는다. + * (처음 값 — 큰 첨부 메일로 잰 뒤 다시 정한다) + */ +export const BODY_READ_BATCH_MESSAGE_BYTES = 32 * 1024 * 1024; +export const BODY_READ_SOLO_MESSAGE_BYTES = 16 * 1024 * 1024; + +/** 지난 메일 채우기의 한 묶음 — 받은 차례(최신부터)로 이만큼씩 읽어 넣는다 (설계 B3, 잰 뒤 정한다) */ +export const BUILD_BATCH_SIZE = 50; +/** 한 묶음을 넣은 뒤 쉬는 시간 — 엔진(메모리 1g)이 다른 요청을 받을 틈 */ +export const BUILD_BATCH_PAUSE_MS = 200; +/** 밤 채우기 한 차례(1분마다 깨어 한 사람) 의 시간 */ +export const BUILD_RUN_BUDGET_MS = 50_000; + +/** 맞추기(Email/changes) 한 번에 받는 변화 수 — 만든 메일의 글을 같은 차례에 읽으므로 작게 */ +export const SYNC_CHANGES_PAGE = 100; +/** 맞추기 한 차례의 시간 — 5분마다 한 사람씩 · 실시간 소식 (설계 B2) */ +export const SYNC_BUDGET_MS = 20_000; +/** 실시간 소식(state)을 모으는 시간 — 잇달아 온 소식을 맞추기 한 번으로 */ +export const SYNC_DEBOUNCE_MS = 300; +/** 검색 · 대화 · 묶은 목록 바로 전의 맞추기를 기다리는 시간 — 넘으면 지금 색인으로 답하고 맞추기는 뒤에서 이어 한다 */ +export const PRE_READ_SYNC_WAIT_MS = 1_500; +/** + * 서버 전체에서 함께 도는 맞추기 수 — 전 직원에게 메일 한 통이 오면 실시간 소식이 직원 수만큼 한꺼번에 온다. + * 넘는 맞추기는 차례를 기다린다(그동안 같은 직원의 새 소식은 기다리는 맞추기에 합친다) + */ +export const SYNC_CONCURRENCY = 4; + +/** 메일 글을 한 번에 읽는 수 (Email/get — 엔진의 maxObjectsInGet 500 이하, 본문은 따로 나눠 읽는다) */ +export const DOCUMENT_READ_BATCH = 50; +/** 표시 · 메일함만 한 번에 읽는 수 (바뀐 메일 — maxObjectsInGet 500) */ +export const MUTABLE_READ_BATCH = 500; +/** 전체 대조에서 엔진의 메일 id 를 한 번에 묻는 수 — 엔진이 줄이면 받은 만큼 이어 묻는다 */ +export const ALL_IDS_PAGE = 5_000; +/** 전체 대조에서 우리 표에 한 번에 넣고 지우는 수 */ +export const RECONCILE_WRITE_BATCH = 500; + +/** 실패가 이만큼 이어지면 5분 맞추기 · 밤 채우기의 차례에서 뒤로 미룬다 (failure_count — 성공하면 0) */ +export const FAILURE_BACKOFF_STEPS_MAX = 6; +/** 실패한 직원을 다시 해 보기까지 — 실패 수마다 두 배(5분 · 10분 · … 상한 FAILURE_BACKOFF_STEPS_MAX 단계) */ +export const FAILURE_BACKOFF_BASE_MS = 5 * 60_000; + +/** + * 밤 채우기의 창 (한국 시간, 하루의 분) — 22:00 ~ 02:45 · 05:00 ~ 07:30. + * 03:00 · 04:00 · 04:30 엔진 정리(휴지통 · 스팸메일함 비우기)와 03:30 백업(엔진을 잠깐 멈춘다)을 피한다 (설계 B3) + */ +export const BUILD_WINDOWS_KST: readonly (readonly [ + startMinute: number, + endMinute: number, +])[] = [ + [22 * 60, 24 * 60], + [0, 2 * 60 + 45], + [5 * 60, 7 * 60 + 30], +]; + +/** 실행 시각 표의 시간대 — 컨테이너는 UTC 로 돈다 */ +export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul'; + +/** + * 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format). + * 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다. + * 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게) + * · 3: 대화 키를 잇는다(14단계 3차 — 답장 관계 + 말머리를 뗀 제목. 판 2 까지의 줄은 키가 메일마다 따로다) + * · 4: 첫 보낸 사람의 주소 · 이름 칸(from_email · from_name — 3차 리뷰 L3, 마이그레이션 AddMailSearchSender. 판 3 까지의 줄은 칸이 비었다) + */ +export const INDEX_FORMAT = 4; + +/** + * 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼 · 2026-11-06 메일함 범위 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 + * 비싸 그 수를 묶는다: + * - 후보: 주소 + 묶음 + 메일함(GIN 하나 — 마이그레이션 SearchIndexUserGramsMailboxes 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 + * 읽는다(줄의 글을 풀지 않는다 — 1만 통 직원의 흔한 낱말이 전체 · 큰 메일함 10~30ms, 작은 메일함 1~7ms. 맞은 줄이 표에 흩어져 있으면 그 줄 수만큼 더 든다) + * - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다 + * - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 + * SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초 — 캐시가 찬 때. + * 2026-11-06 1만 통 직원의 드문 구절로 3,000줄 0.51~0.58초). + * 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext) + * - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null + */ +export const SEARCH_CANDIDATE_COUNT_LIMIT = 2_000; +export const SEARCH_CHECK_ROWS_LIMIT = 3_000; +export const SEARCH_FIRST_CHECK_ROWS = 100; +export const SEARCH_CHECK_ROWS_GROWTH = 4; +export const SEARCH_TOTAL_LIMIT = 1_000; + +/** + * 한 글자 낱말(글자 두 개 묶음이 없는 낱말)만으로 찾을 때 본문은 훑지 않는다 — 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾는다. + * 두 글자 넘는 낱말과 함께면 한 글자 낱말은 확인에만 쓴다(본문 포함). false 로 두면 한 글자 낱말만이어도 본문까지 훑는다(확인 상한 안에서) + */ +export const SINGLE_CHAR_TERMS_SKIP_BODY = true; + +/** 목록의 미리 보기 둘레 — 맞은 말 앞 글자 수 · 모두 글자 수(남짓 — 말 경계에서 자른다) · 말 경계를 찾는 여유 (UTF-16 — 서로게이트 쌍은 가르지 않는다) */ +export const SNIPPET_LEAD_CHARS = 20; +export const SNIPPET_TOTAL_CHARS = 80; +export const SNIPPET_BOUNDARY_SLACK = 10; + +/** + * 대화 (14단계 3차 — 설계 C3 · C4). 같은 대화 칸의 한 쪽(기본이자 최대 — 화면이 한 번에 보인다) · 묶은 목록 줄의 참여자 수 상한(보낸 사람 — 받은 차례로 앞에서부터) + * · 묶은 목록 줄의 messageIds 상한(그 메일함 안 — 최신부터, 3차 리뷰 L4. 넘는 오래된 메일은 줄의 여러 통 처리에 들지 않는다) + */ +export const CONVERSATION_PAGE_LIMIT = 100; +export const CONVERSATION_PARTICIPANTS_MAX = 10; +export const CONVERSATION_MESSAGE_IDS_MAX = 500; + +/** + * 엔진 방식 대화(색인 전 · 첫 쪽 DB 장애의 대신 — modules/mail/jmap/jmap-conversations.ts)의 상한 (3차 리뷰 H1) — GitHub · Jira 알림처럼 + * 수천 통인 스레드에서도 한 요청이 엔진을 크게 쓰지 않게. + * - 거르기(휴지통 · 스팸메일함에만 든 메일 · 내부 초안)는 값싼 속성만 읽고, 요약 속성은 내보낼 메일(같은 대화 — 한 쪽 · 묶은 목록 — 줄마다 마지막 메일)만 읽는다 + * - 같은 대화: 한 요청이 훑는 메일 CONVERSATION_ENGINE_SCAN_MAX 통까지 — 스레드가 그 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 필요한 만큼만(total null) + * - 묶은 목록: 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통 · 한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통까지(작은 스레드부터 다 주고 남는 몫을 나눈다) + * - 한 요청의 시간 CONVERSATION_ENGINE_BUDGET_MS (메일함 처리의 BulkRun 과 같은 결) — 훑는 호출은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS" + * 중 짧은 쪽(마지막 요약 읽기 몫을 늘 남긴다 — 3차 재확인 N1), 마지막 읽기만 남은 시간을 다 쓴다. 남은 시간이 그 몫보다 적으면 더 훑지 않고, + * 훑던 호출이 시간 때문에 끊기면 읽은 데까지로 답한다(같은 대화: 다 세야 하는 작은 스레드 · 아무것도 못 읽음은 503 MAIL_001 / 묶은 목록: 어림 줄) + */ +export const CONVERSATION_ENGINE_SCAN_MAX = 1_000; +export const CONVERSATION_ENGINE_THREAD_SCAN_MAX = 500; +export const CONVERSATION_ENGINE_LIST_SCAN_MAX = 2_000; +export const CONVERSATION_ENGINE_BUDGET_MS = 15_000; +export const CONVERSATION_ENGINE_RESERVE_MS = 3_000; + +/** 색인의 키워드(엔진이 준 그대로 — JMAP 표준) 가운데 묶은 목록이 보는 것 — 읽음 · 중요 표시 */ +export const SEEN_KEYWORD = '$seen'; +export const FLAGGED_KEYWORD = '$flagged'; diff --git a/backend/src/modules/mail-search/mail-search.module.ts b/backend/src/modules/mail-search/mail-search.module.ts new file mode 100644 index 0000000..259d1df --- /dev/null +++ b/backend/src/modules/mail-search/mail-search.module.ts @@ -0,0 +1,49 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { JmapIndexSource } from '@/modules/mail/jmap/jmap-index-source'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { MailConversationQueryService } from './mail-conversation-query.service'; +import { MAIL_INDEX_SOURCE } from './mail-index-source'; +import { MailSearchBuildService } from './mail-search-build.service'; +import { MailSearchIndexRepository } from './mail-search-index.repository'; +import { MailSearchIndexService } from './mail-search-index.service'; +import { MailSearchQueryService } from './mail-search-query.service'; +import { MailSearchMessageEntity } from './mail-search-message.entity'; +import { MailSearchRuns } from './mail-search-runs'; +import { MailSearchStateEntity } from './mail-search-state.entity'; +import { MailSearchSyncService } from './mail-search-sync.service'; +import { SearchIndexController } from './search-index.controller'; + +/** + * 메일 검색 색인 모듈 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕). + * 엔진을 바꿀 때는 `MAIL_INDEX_SOURCE` 의 구현(useClass)만 바꾼다 — 맞추기 · 채우기는 포트만 안다. + * 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/jmap-index-source.ts` 에 있다. + * + * 메일 모듈(실시간 소식 → 맞추기)과 인증 모듈(로그인 → 상태 줄)이 가져다 쓴다 — 이 모듈은 둘을 가져오지 않는다 (순환 없음). + * 운영 도구(서버와 다른 프로세스)는 이 모듈을 가져오지 않는다 — 퇴사 마무리는 표만 지운다 (ops-data.service.ts) + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([MailSearchMessageEntity, MailSearchStateEntity]), + MailEngineModule, + ], + controllers: [SearchIndexController], + providers: [ + { provide: MAIL_INDEX_SOURCE, useClass: JmapIndexSource }, + MailSearchIndexRepository, + MailSearchRuns, + MailSearchSyncService, + MailSearchBuildService, + MailSearchIndexService, + MailSearchQueryService, + MailConversationQueryService, + ], + exports: [ + MailSearchIndexService, + MailSearchSyncService, + MailSearchQueryService, + MailConversationQueryService, + ], +}) +export class MailSearchModule {} diff --git a/backend/src/modules/mail-search/mail-search.types.ts b/backend/src/modules/mail-search/mail-search.types.ts new file mode 100644 index 0000000..f5f52ea --- /dev/null +++ b/backend/src/modules/mail-search/mail-search.types.ts @@ -0,0 +1,79 @@ +/** + * 메일 검색 색인의 도메인 타입 (14단계) — 엔진을 모른다. + */ + +/** 색인 상태 — 지난 메일을 채우는 중 · 다 만들었음 (설계 A2) */ +export type SearchIndexStatus = 'building' | 'ready'; + +/** + * 맞추기를 멈춘 까닭 — 대리 접근이 막혔다 (설계 B2). 다음 로그인(또는 검색 준비 상태를 물을 때) 푼다. + * - password-expired: 비밀번호가 만료됐다 (엔진이 메일 기능을 막는다 — 13단계) + * - locked: 로그인할 수 없는 계정 — 잠김(로그인 권한 없음) · 엔진에 없음 + */ +export type SearchPausedReason = 'password-expired' | 'locked'; + +/** GET /api/search-index 의 응답 (설계 C1) — 화면의 "검색 준비 중" 줄 */ +export interface SearchIndexStatusView { + readonly status: SearchIndexStatus; + /** 채운 메일 수 — 다 만들었으면 그때 색인에 든 수 */ + readonly done: number; + /** 채울 메일 수(채우기를 시작할 때의 엔진 메일 수) — 아직 시작하지 않았으면 null */ + readonly total: number | null; +} + +/** + * 검색 조건 (GET /api/messages — 14단계 설계 C1) — DTO 가 검사한 값. 글 칸은 앞뒤 공백을 뺀 값이고 빈 글은 없는 것이다. + * after · before 는 ISO 8601 시각 — 받은 시각이 after 이상 · before 미만 + */ +export interface SearchCriteria { + /** 검색어(q) — 보낸 사람 · 받는 사람 · 참조 · 제목 · 첨부 이름 · 본문 */ + readonly text?: string; + readonly from?: string; + /** 받는 사람 — 참조도 */ + readonly to?: string; + readonly subject?: string; + readonly body?: string; + readonly after?: string; + readonly before?: string; + /** true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정) */ + readonly hasAttachment?: boolean; +} + +/** 검색어 밖의 조건 — 엔진 방식 검색이 JMAP 필터로 쓴다 */ +export type SearchConditions = Omit; + +/** + * 찾는 범위 (엔진이 알려 준다 — 메일 포트의 searchScope): 메일함 하나, 또는 빼는 메일함(휴지통 · 스팸메일함)에만 있지 않은 메일. + * 목록에서 빼는 키워드(발송 중인 내부 초안)가 있는 메일은 찾지 않는다 + */ +export interface SearchScope { + readonly mailboxId: string | null; + readonly excludedMailboxIds: readonly string[]; + readonly excludedKeywords: readonly string[]; +} + +/** 맞은 곳 — [시작, 끝) UTF-16 */ +export type SearchRange = readonly [start: number, end: number]; + +/** 목록 항목의 강조 (색인으로 찾았을 때만 — 엔진 방식이면 null) */ +export interface MessageListHighlight { + readonly subject: SearchRange[]; + /** 본문에서 맞았으면 맞은 말 둘레, 아니면 엔진의 미리 보기 */ + readonly preview: { readonly text: string; readonly ranges: SearchRange[] }; +} + +/** 읽기 화면의 강조 — 첨부는 맞은 것만(첨부 id → 맞은 곳) */ +export interface MessageDetailHighlight { + readonly subject: SearchRange[]; + readonly attachments: Record; + readonly textBody: SearchRange[]; +} + +/** 대화의 참여자 — 보낸 사람 (메일 모듈의 MailAddress 와 같은 모양) */ +export interface ConversationParticipant { + readonly name: string | null; + readonly email: string; +} + +/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목 — 14단계 B4) · 엔진의 스레드(색인을 다 만들기 전) */ +export type ConversationBasis = 'index' | 'engine'; diff --git a/backend/src/modules/mail-search/search-cursor.spec.ts b/backend/src/modules/mail-search/search-cursor.spec.ts new file mode 100644 index 0000000..ab9f27b --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.spec.ts @@ -0,0 +1,137 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor'; +import type { SearchCriteria } from './mail-search.types'; +import { + cursorMethod, + decodeIndexCursor, + encodeIndexCursor, + searchScopeKey, +} from './search-cursor'; + +const criteria = (overrides: Partial): SearchCriteria => ({ + hasAttachment: false, + ...overrides, +}); + +function codeOf(run: () => unknown): string | null { + try { + run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('색인 검색의 커서 (search-cursor — 14단계 설계 C1)', () => { + const position = { + receivedAt: new Date('2026-11-02T01:23:45.678Z'), + emailId: 'm9x', + }; + const key = searchScopeKey(undefined, criteria({ text: '계약' })); + + it('자리(받은 시각 ms · id)를 그대로 되돌린다 — URL 에 그대로 넣을 수 있는 글자만', () => { + const cursor = encodeIndexCursor(position, key); + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeIndexCursor(cursor, key)).toEqual(position); + }); + + it('방식을 커서로 안다 — 색인 커서는 index, 엔진 커서(1판) · 알 수 없는 값은 engine(엔진 쪽이 검사한다)', () => { + expect(cursorMethod(encodeIndexCursor(position, key))).toBe('index'); + expect( + cursorMethod( + encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }), + ), + ).toBe('engine'); + for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600), 'bnVsbA']) { + expect(cursorMethod(cursor)).toBe('engine'); + } + }); + + it('범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 하나라도 다르면 다른 검색의 커서라 VAL_001', () => { + const base = criteria({ + text: '계약', + from: '이도윤', + after: '2026-10-31T15:00:00.000Z', + }); + const cursor = encodeIndexCursor(position, searchScopeKey('a', base)); + + expect(decodeIndexCursor(cursor, searchScopeKey('a', base))).toEqual( + position, + ); + for (const [mailboxId, other] of [ + [undefined, base], + ['b', base], + ['a', { ...base, text: '견적' }], + ['a', { ...base, from: undefined }], + ['a', { ...base, to: '이도윤' }], + ['a', { ...base, subject: '계약' }], + ['a', { ...base, body: '계약' }], + ['a', { ...base, after: '2026-10-30T15:00:00.000Z' }], + ['a', { ...base, before: '2026-11-30T15:00:00.000Z' }], + ['a', { ...base, hasAttachment: true }], + ] as const) { + expect( + codeOf(() => + decodeIndexCursor(cursor, searchScopeKey(mailboxId, other)), + ), + ).toBe('VAL_001'); + } + }); + + it('칸이 달라도 이은 글이 같아지지 않는다 — 같은 글을 다른 칸에 둔 검색은 다른 범위', () => { + expect(searchScopeKey(undefined, criteria({ from: '계약' }))).not.toBe( + searchScopeKey(undefined, criteria({ to: '계약' })), + ); + expect(searchScopeKey('a', criteria({}))).not.toBe( + searchScopeKey(undefined, criteria({})), + ); + }); + + it('모양이 틀린 커서는 VAL_001 — 엔진 커서 · 틀린 판 · 이상한 id · 음수 시각 · 너무 긴 값', () => { + const forge = (payload: object) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + for (const cursor of [ + encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }), + forge({ v: 3, m: 'i', t: 1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'e', t: 1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: -1, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: 1.5, id: 'm1', s: 'x' }), + forge({ v: 2, m: 'i', t: 1, id: '../x', s: 'x' }), + forge({ v: 2, m: 'i', t: 1, id: 'm1' }), + 'not-json', + 'a'.repeat(600), + ]) { + expect(codeOf(() => decodeIndexCursor(cursor, key))).toBe('VAL_001'); + } + }); + + it('Date 로 읽을 수 없는 시각(안전한 정수지만 ±8.64e15 ms 밖)의 커서는 VAL_001 — 범위 지문이 맞아도 500 이 아니다 (리뷰 L1)', () => { + // 이 검색의 진짜 커서에서 시각만 바꾼다 — 범위 지문(s)은 맞는다 + const real = JSON.parse( + Buffer.from(encodeIndexCursor(position, key), 'base64url').toString( + 'utf8', + ), + ) as Record; + const withTime = (t: number) => + Buffer.from(JSON.stringify({ ...real, t })).toString('base64url'); + expect(Number.isSafeInteger(9e15)).toBe(true); + expect(codeOf(() => decodeIndexCursor(withTime(9e15), key))).toBe( + 'VAL_001', + ); + // 한도 끝(8.64e15)까지는 날짜다 — 그대로 읽는다 + expect(decodeIndexCursor(withTime(8.64e15), key)).toEqual({ + receivedAt: new Date(8.64e15), + emailId: position.emailId, + }); + }); + + it('범위 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸을 흉내 내도 다른 범위 (리뷰 L7)', () => { + const NL = String.fromCharCode(10); + expect(searchScopeKey(undefined, criteria({ from: `a${NL}b` }))).not.toBe( + searchScopeKey(undefined, criteria({ from: 'a', to: 'b' })), + ); + expect(searchScopeKey(undefined, criteria({ text: `계약${NL}` }))).not.toBe( + searchScopeKey(undefined, criteria({ text: '계약', from: '' })), + ); + }); +}); diff --git a/backend/src/modules/mail-search/search-cursor.ts b/backend/src/modules/mail-search/search-cursor.ts new file mode 100644 index 0000000..6d796b7 --- /dev/null +++ b/backend/src/modules/mail-search/search-cursor.ts @@ -0,0 +1,122 @@ +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants'; +import type { SearchCriteria } from './mail-search.types'; +import type { SearchPosition } from './search-sql'; + +/** + * 색인으로 찾은 검색의 커서 (14단계 설계 C1 — 클라이언트에게는 불투명 문자열). + * `{ v: 2, m: 'i', t: 받은 시각(ms), id, s: 범위 지문 }` — 그 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 이어 찾는다. + * 다 찾지 못하고 확인 상한에 닿은 쪽(이어 찾기)도 같은 꼴이다 — 마지막으로 **훑은** 줄이 자리다. + * + * 방식(m)이 커서에 실려, 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 그 검색은 처음 방식대로 이어 간다 — + * 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로, 이 커서는 색인으로. + * 범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 다른 검색의 커서를 섞어 쓰면 VAL_001. 지문은 짧은 해시라 글을 담지 않는다 + */ +const CURSOR_VERSION = 2; +const INDEX_METHOD = 'i'; +const MAX_CURSOR_LENGTH = 512; +const SCOPE_HASH_LENGTH = 16; +/** "모든 메일함"(휴지통 · 스팸메일함 제외) 범위 — 메일함 id 형식에 없는 글자 */ +const ALL_MAILBOXES_SCOPE = '*'; + +interface EncodedIndexCursor { + v: number; + m: string; + t: number; + id: string; + s: string; +} + +/** 검색 방식 — 커서를 읽어 본다. 색인 커서가 아니면(엔진 방식 · 모양이 틀림) 'engine' — 엔진 쪽이 검사한다 */ +export function cursorMethod(cursor: string): 'index' | 'engine' { + if (cursor.length > MAX_CURSOR_LENGTH) return 'engine'; + try { + const parsed = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as Partial | null; + return parsed?.v === CURSOR_VERSION && parsed.m === INDEX_METHOD + ? 'index' + : 'engine'; + } catch { + return 'engine'; + } +} + +/** + * 범위 지문의 글 — 메일함 · 검색어 · 조건을 정해진 차례로 (빈 칸은 빈 글). + * 배열을 JSON 으로 잇는다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않게 (from 에 줄바꿈을 넣어 to 칸을 흉내 낼 수 없다) + */ +export function searchScopeKey( + mailboxId: string | undefined, + criteria: SearchCriteria, +): string { + return JSON.stringify([ + mailboxId ?? ALL_MAILBOXES_SCOPE, + criteria.text ?? '', + criteria.from ?? '', + criteria.to ?? '', + criteria.subject ?? '', + criteria.body ?? '', + criteria.after ?? '', + criteria.before ?? '', + criteria.hasAttachment === true, + ]); +} + +function scopeHash(scopeKey: string): string { + return createHash('sha256') + .update(scopeKey) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +function invalidCursor(reason: string): BusinessException { + return new BusinessException('VAL_001', { + context: { reason: `invalid-cursor:${reason}` }, + }); +} + +export function encodeIndexCursor( + position: SearchPosition, + scopeKey: string, +): string { + const payload: EncodedIndexCursor = { + v: CURSOR_VERSION, + m: INDEX_METHOD, + t: position.receivedAt.getTime(), + id: position.emailId, + s: scopeHash(scopeKey), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 읽는다 — 모양이 틀리거나 다른 검색의 커서면 VAL_001 */ +export function decodeIndexCursor( + cursor: string, + scopeKey: string, +): SearchPosition { + 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 { v, m, t, id, s } = (parsed ?? {}) as Partial; + const valid = + v === CURSOR_VERSION && + m === INDEX_METHOD && + typeof t === 'number' && + Number.isSafeInteger(t) && + t >= 0 && + // 날짜로 읽을 수 있는 시각만 — Date 의 한도(±8.64e15 ms) 밖이면 Invalid Date 라 쿼리 값으로 쓰면 500 이었다 + Number.isFinite(new Date(t).getTime()) && + typeof id === 'string' && + MAIL_ID_PATTERN.test(id) && + typeof s === 'string'; + if (!valid) throw invalidCursor('shape'); + if (s !== scopeHash(scopeKey)) throw invalidCursor('other-list'); + return { receivedAt: new Date(t), emailId: id }; +} diff --git a/backend/src/modules/mail-search/search-highlight.spec.ts b/backend/src/modules/mail-search/search-highlight.spec.ts new file mode 100644 index 0000000..e8bdc1e --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.spec.ts @@ -0,0 +1,382 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { foldText, matchRanges } from '@/shared/utils/search-text'; +import { + SNIPPET_BOUNDARY_SLACK, + SNIPPET_LEAD_CHARS, + SNIPPET_TOTAL_CHARS, +} from './mail-search.constants'; +import { + highlightedSubject, + listHighlight, + snippetAround, +} from './search-highlight'; +import type { HighlightTerms } from './search-plan'; + +const terms = (...words: string[]): HighlightTerms => ({ + subject: words, + body: words, + attachment: words, + query: words, +}); + +/** 색인의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (listHighlight 의 otherFields) */ +const otherFields = (fields: { + from?: string; + to?: string; + subject?: string; + attachment?: string; +}): string[] => + [fields.from, fields.to, fields.subject, fields.attachment].map((field) => + foldText(field ?? ''), + ); + +/** 글에서 낱말이 처음 맞은 곳 */ +const firstMatch = (body: string, word: string) => + matchRanges(body, [word], 1)[0]; + +/** 범위가 가리키는 글 */ +const slices = (text: string, ranges: readonly (readonly [number, number])[]) => + ranges.map(([start, end]) => text.slice(start, end)); + +describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () => { + describe('맞은 말 둘레 (snippetAround)', () => { + it('짧은 본문은 그대로 — 잘라 낸 글이 없으면 "…" 도 없다', () => { + const body = '이번 달 계약 갱신 일정입니다.'; + expect(snippetAround(body, firstMatch(body, '계약'))).toBe(body); + }); + + it(`맞은 곳 앞 ${SNIPPET_LEAD_CHARS}자 남짓부터 모두 ${SNIPPET_TOTAL_CHARS}자 남짓 — 낱말 경계(공백)에서 자르고 앞뒤에 "…"`, () => { + const before = Array.from({ length: 30 }, (_, i) => `앞말${i}`).join(' '); + const after = Array.from({ length: 30 }, (_, i) => `뒷말${i}`).join(' '); + const body = `${before} 계약서를 보내 드립니다 ${after}`; + + const snippet = snippetAround(body, firstMatch(body, '계약')); + + expect(snippet.startsWith('…')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + const inner = snippet.slice(1, -1); + expect(inner).toContain('계약서를'); + // 낱말 가운데를 자르지 않았다 — 앞 · 뒤 조각이 본문의 낱말 그대로 + const words = inner.split(' '); + for (const word of [words[0], words[words.length - 1]]) { + expect(body.split(' ')).toContain(word); + } + // 맞은 곳 앞은 SNIPPET_LEAD_CHARS + 여유 안, 모두 SNIPPET_TOTAL_CHARS + 여유 안 + expect(inner.indexOf('계약')).toBeLessThanOrEqual( + SNIPPET_LEAD_CHARS + SNIPPET_BOUNDARY_SLACK, + ); + expect(inner.length).toBeLessThanOrEqual( + SNIPPET_TOTAL_CHARS + SNIPPET_BOUNDARY_SLACK * 2, + ); + }); + + it('공백이 없는 긴 글은 그 자리에서 자르고 "…" — 맞은 곳은 늘 담는다', () => { + const body = `${'가'.repeat(100)}계약${'나'.repeat(200)}`; + const snippet = snippetAround(body, firstMatch(body, '계약')); + + expect(snippet).toBe( + `…${'가'.repeat(SNIPPET_LEAD_CHARS)}계약${'나'.repeat(SNIPPET_TOTAL_CHARS - SNIPPET_LEAD_CHARS - 2)}…`, + ); + }); + + it('맞은 말이 쪽보다 길어도 다 담는다', () => { + const long = '계'.repeat(150); + const body = `안내 ${long} 끝`; + const snippet = snippetAround(body, [3, 3 + long.length]); + expect(snippet).toContain(long); + }); + + it('줄바꿈 · 탭 · 여러 칸 공백은 띄어쓰기 하나로 — 잘라 낸 쪽이 공백뿐이면 "…" 를 붙이지 않는다', () => { + const body = `${' '.repeat(30)}계약\n\n\t갱신 일정${'\n'.repeat(5)}`; + expect(snippetAround(body, firstMatch(body, '계약'))).toBe( + '계약 갱신 일정', + ); + }); + + it('서로게이트 쌍(이모지) 가운데를 가르지 않는다', () => { + const body = `${'😀'.repeat(40)}계약${'😀'.repeat(60)}`; + const snippet = snippetAround(body, firstMatch(body, '계약')); + // 짝이 없는 서로게이트가 없다 + expect(snippet).toBe(snippet.normalize()); + expect(() => encodeURIComponent(snippet)).not.toThrow(); + expect(snippet).toContain('계약'); + }); + }); + + describe('항목 하나의 강조 (listHighlight)', () => { + it('제목의 맞은 곳(합친 범위) · 본문에서 처음 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳', () => { + const highlight = listHighlight( + { + subject: '[공지] 계약 갱신 · 계약서', + preview: '엔진의 미리 보기', + bodyText: + '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + otherFields: null, + }, + terms('계약', '갱신'), + ); + + expect(slices('[공지] 계약 갱신 · 계약서', highlight.subject)).toEqual([ + '계약', + '갱신', + '계약', + ]); + expect(highlight.preview.text).toBe( + '안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.', + ); + expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([ + '계약', + '갱신', + '갱신', + '계약', + ]); + }); + + it('본문에 맞은 곳이 없으면(제목에서만 맞음) 엔진의 미리 보기 — 한 줄로, 맞은 곳이 있으면 그 자리', () => { + const highlight = listHighlight( + { + subject: '계약 안내', + preview: '첨부한 계약서를\n확인해 주세요', + bodyText: null, + otherFields: null, + }, + terms('계약'), + ); + expect(highlight.preview).toEqual({ + text: '첨부한 계약서를 확인해 주세요', + ranges: [[4, 6]], + }); + + const notInBody = listHighlight( + { + subject: '계약 안내', + preview: '첨부 확인', + bodyText: '첨부 확인', + otherFields: otherFields({ subject: '계약 안내' }), + }, + terms('계약'), + ); + expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] }); + expect(notInBody.subject).toEqual([[0, 2]]); + }); + + it('본문에 강조할 말이 없으면(제목 조건만) 미리 보기는 맞은 곳 없이 엔진의 것', () => { + const highlight = listHighlight( + { + subject: '견적 요청', + preview: '견적 부탁드립니다', + bodyText: '견적 부탁드립니다', + otherFields: null, + }, + { subject: ['견적'], body: [], attachment: [], query: [] }, + ); + expect(highlight).toEqual({ + subject: [[0, 2]], + preview: { text: '견적 부탁드립니다', ranges: [] }, + }); + }); + + it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => { + const nfd = '계약 PROJECT'.normalize('NFD'); + const highlight = listHighlight( + { + subject: 'PROJECT 안내', + preview: nfd, + bodyText: null, + otherFields: null, + }, + terms('project', '계약'), + ); + expect(highlight.subject).toEqual([[0, 7]]); + expect(highlight.preview.text).toBe('계약 PROJECT'); + expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([ + '계약', + 'PROJECT', + ]); + }); + + it('응답의 제목은 NFC — 강조 위치의 기준', () => { + const nfd = '계약 안내'.normalize('NFD'); + expect(highlightedSubject(nfd)).toBe('계약 안내'); + expect(highlightedSubject(nfd).length).toBe(5); + }); + }); + + describe('답장의 원문 인용(> 줄)과 미리 보기 (2026-11-06 사용자 관점 점검)', () => { + /** 미리 보기 — 본문 · 강조할 말 · 색인의 접은 칸(없으면 null) */ + const previewOf = ( + bodyText: string, + words: HighlightTerms, + fields: string[] | null = null, + ) => + listHighlight( + { + subject: '제목', + preview: '엔진의 미리 보기', + bodyText, + otherFields: fields, + }, + words, + ).preview; + + const REPLY_SUBJECT = 'RE: 해오름 프로젝트 견적 검토 요청'; + const REPLY_FIELDS = otherFields({ + from: '이도윤 ', + to: '김아라 ', + subject: REPLY_SUBJECT, + }); + + it('인용 밖과 안에서 모두 맞으면 인용 밖의 맞은 곳 — 본문에서 먼저 나온 인용 안의 맞은 곳보다 먼저', () => { + const preview = previewOf( + '> 견적 부탁드립니다.\n\n견적서 확인했습니다. 금요일까지 회신 드리겠습니다.', + terms('견적'), + ); + expect(preview).toEqual({ + text: '…견적서 확인했습니다. 금요일까지 회신 드리겠습니다.', + ranges: [[1, 3]], + }); + }); + + it('인용 밖에서 맞은 곳의 둘레는 인용 줄에 닿기 전까지 — 뒤따르는 인용은 싣지 않고 "…"', () => { + const preview = previewOf( + '금요일까지 견적서 회신 드리겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 견적 자료를 보냅니다.', + terms('견적'), + ); + expect(preview).toEqual({ + text: '금요일까지 견적서 회신 드리겠습니다.…', + ranges: [[6, 8]], + }); + }); + + it('인용 안에서만 맞고 그 말이 다른 칸(제목 · 사람 · 첨부 이름)에서도 맞으면 인용 앞의 글 — 그 글에 맞은 곳이 없으면 범위도 없다', () => { + const body = + '네, 확인했습니다. 다음 주에 뵙겠습니다.\n\n> 제목: 해오름 프로젝트 견적 검토 요청\n> > 도윤 님, 견적 부탁드립니다.'; + const expected = { + text: '네, 확인했습니다. 다음 주에 뵙겠습니다.…', + ranges: [], + }; + // 제목에서 맞음 + expect(previewOf(body, terms('견적'), REPLY_FIELDS)).toEqual(expected); + // 보낸 사람에서 맞음 + expect(previewOf(body, terms('도윤'), REPLY_FIELDS)).toEqual(expected); + // 첨부 이름에서 맞음 + expect( + previewOf( + body, + terms('검토'), + otherFields({ subject: '회신', attachment: '검토 의견.pdf' }), + ), + ).toEqual(expected); + }); + + it('인용 안에서만 맞고 다른 칸에서는 맞지 않으면 인용 조각 — 줄 앞의 > 를 뺀 글을 그 인용 안에서만, 위치는 보이는 글 기준', () => { + const preview = previewOf( + '네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.', + terms('R9A51'), + otherFields({ subject: 'RE: 해오름 프로젝트 안내' }), + ); + expect(preview).toEqual({ + text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ranges: [[16, 21]], + }); + expect(slices(preview.text, preview.ranges)).toEqual(['R9A51']); + }); + + it('본문에서 맞은 말 가운데 하나라도 다른 칸에서 맞지 않으면 인용 조각 — 내용 조건의 말은 본문에서만 찾으므로 다른 칸에 있어도 인용 조각', () => { + const body = + '네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.'; + const fields = otherFields({ subject: 'RE: 해오름 프로젝트 안내' }); + + const both = previewOf(body, terms('해오름', 'R9A51'), fields); + expect(both.text).toBe( + '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ); + expect(slices(both.text, both.ranges)).toEqual(['해오름', 'R9A51']); + + const bodyCondition = previewOf( + body, + { subject: [], body: ['해오름'], attachment: [], query: [] }, + fields, + ); + expect(bodyCondition.text).toBe( + '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ); + expect(slices(bodyCondition.text, bodyCondition.ranges)).toEqual([ + '해오름', + ]); + }); + + it('인용으로 시작하는 본문(인용 아래에 쓴 답장)은 인용 밖의 첫 글 — 본문이 모두 인용이면 다른 칸에서 맞아도 인용 조각', () => { + expect( + previewOf( + '> 해오름 프로젝트 견적 부탁드립니다.\n\n네, 금요일까지 보내 드리겠습니다.', + terms('견적'), + REPLY_FIELDS, + ), + ).toEqual({ text: '…네, 금요일까지 보내 드리겠습니다.', ranges: [] }); + + const quoteOnly = previewOf( + '> 해오름 프로젝트 견적 부탁드립니다.\n> 감사합니다.', + terms('견적'), + REPLY_FIELDS, + ); + expect(quoteOnly.text).toBe( + '해오름 프로젝트 견적 부탁드립니다. 감사합니다.', + ); + expect(slices(quoteOnly.text, quoteOnly.ranges)).toEqual(['견적']); + }); + + it('인용 줄 알아보기 — 앞 공백 · 탭 뒤의 > · 겹친 표시(>> · > >) · CRLF, 줄 가운데의 > 는 인용이 아니다', () => { + expect( + previewOf('a > b 견적 정리\r\n >> > 견적 원문', terms('견적')), + ).toEqual({ text: 'a > b 견적 정리…', ranges: [[6, 8]] }); + + expect( + previewOf(' >> > 견적 원문\r\n\t> 두 번째 견적', terms('견적')), + ).toEqual({ + text: '견적 원문 두 번째 견적', + ranges: [ + [0, 2], + [11, 13], + ], + }); + }); + + it('인용 조각도 서로게이트 쌍을 가르지 않고 위치는 NFC 글 기준 — NFD 로 적은 말도 맞는다', () => { + const body = `확인했습니다.\n\n> ${'😀'.repeat(15)} 계약서 보내 드립니다 ${'😀'.repeat(40)}`; + const preview = previewOf(body, terms('계약'.normalize('NFD'))); + + expect(preview.text).not.toContain('>'); + expect(() => encodeURIComponent(preview.text)).not.toThrow(); + expect(slices(preview.text, preview.ranges)).toEqual(['계약']); + expect(preview.text.startsWith('…')).toBe(true); + }); + + it('본문 길이에 비례한다 — 인용 줄이 많은 긴 본문(색인 본문 상한 남짓)에서 끝의 인용 조각을 골라도', () => { + const bodyOf = (lines: number) => + `확인했습니다.\n\n${Array.from( + { length: lines }, + (_, index) => `> > 인용 줄 ${index} 가나다라마바사`, + ).join('\n')}\n> 마지막 견적 줄`; + const run = (body: string) => { + for (let round = 0; round < 20; round += 1) { + previewOf(body, terms('견적')); + } + }; + const small = bodyOf(50); + const large = bodyOf(1_000); + expect(previewOf(large, terms('견적')).text).toContain('마지막 견적 줄'); + run(small); + + const smallCpu = startCpuTimer(); + run(small); + const smallMs = smallCpu(); + const largeCpu = startCpuTimer(); + run(large); + const largeMs = largeCpu(); + // 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts) + expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10); + expect(largeMs).toBeLessThan(5_000); + }); + }); +}); diff --git a/backend/src/modules/mail-search/search-highlight.ts b/backend/src/modules/mail-search/search-highlight.ts new file mode 100644 index 0000000..9aa450d --- /dev/null +++ b/backend/src/modules/mail-search/search-highlight.ts @@ -0,0 +1,345 @@ +import { + foldTerms, + foldText, + isSpaceCode, + matchFoldedRanges, + matchRanges, + type MatchRange, +} from '@/shared/utils/search-text'; +import { + SNIPPET_BOUNDARY_SLACK, + SNIPPET_LEAD_CHARS, + SNIPPET_TOTAL_CHARS, +} from './mail-search.constants'; +import type { MessageListHighlight, SearchRange } from './mail-search.types'; +import type { HighlightTerms } from './search-plan'; + +/** + * 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄. + * - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다) + * - 미리 보기: 본문(색인의 글 — NFC)에서 맞은 곳을 아래 차례로 고르고(2026-11-06 사용자 관점 점검 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이라 + * 셋째 줄로 답장끼리 가를 수 없었다), 본문에서 맞지 않았으면 엔진의 미리 보기 + * 1. 인용 줄(앞 공백 뒤 `>` 로 시작하는 줄) 밖에서 처음 맞은 곳의 둘레 — 인용 줄에 닿기 전까지만 + * 2. 인용 안에서만 맞았고 본문에서 맞은 말이 모두 다른 칸(제목 · 사람 · 첨부 이름 — 검색어의 낱말)에서도 맞았으면 인용 밖의 첫 글(보통 인용 앞의 글) + * 3. 그 밖에는 인용 안에서 처음 맞은 곳의 둘레 — 줄 앞의 `>` 표시를 뺀 글로, 그 인용(이어진 인용 줄) 안에서만 + * 다른 메일 프로그램의 인용 꼴(`>` 없이 "-----Original Message-----" 아래에 두는 것 등)은 알아보지 않는다 + * - 둘레는 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤에 잘라 낸 글이 있으면 "…". + * 공백은 하나로 줄인 한 줄이고 맞은 곳은 보이는 그 글에서 다시 찾는다(그 글에 맞은 곳이 없으면 범위도 없다) + * 위치는 UTF-16 [시작, 끝) + */ + +const ELLIPSIS = '…'; +/** 인용 줄의 표시 `>` */ +const QUOTE_MARK_CODE = 0x3e; +const LINE_BREAK = '\n'; + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 앞으로 */ +function alignStart(text: string, index: number): number { + if (index <= 0 || index >= text.length) return Math.max(0, index); + return isHighSurrogate(text.charCodeAt(index - 1)) ? index - 1 : index; +} + +/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 뒤로 */ +function alignEnd(text: string, index: number): number { + if (index <= 0 || index >= text.length) return Math.min(text.length, index); + return isHighSurrogate(text.charCodeAt(index - 1)) ? index + 1 : index; +} + +/** `from` 에서 `to` 쪽으로 가며 처음 만나는 공백의 자리 — 없으면 -1 (from · to 를 포함) */ +function findSpace(text: string, from: number, to: number): number { + const step = from <= to ? 1 : -1; + for (let index = from; step > 0 ? index <= to : index >= to; index += step) { + if ( + index >= 0 && + index < text.length && + isSpaceCode(text.charCodeAt(index)) + ) { + return index; + } + } + return -1; +} + +/** [from, to) 에 공백이 아닌 글자가 있나 — 잘라 낸 쪽에 보이는 글이 있을 때만 "…" 를 붙이려고 */ +function hasVisible(text: string, from: number, to: number): boolean { + for (let index = from; index < to; index += 1) { + if (!isSpaceCode(text.charCodeAt(index))) return true; + } + return false; +} + +/** 공백류의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (한 줄로 보이는 미리 보기) */ +function oneLine(text: string): string { + let line = ''; + let pendingSpace = false; + for (let index = 0; index < text.length; index += 1) { + if (isSpaceCode(text.charCodeAt(index))) { + pendingSpace = line !== ''; + continue; + } + if (pendingSpace) line += ' '; + pendingSpace = false; + line += text[index]; + } + return line; +} + +/** + * 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다. + * `bounds`([시작, 끝) — 처음 값은 글 전체) 밖으로는 나가지 않는다(인용 줄에 닿기 전까지 등). + * 앞 · 뒤에 잘라 낸 글(공백만이 아닌 — `bounds` 밖도 센다)이 있으면 "…" 를 붙이고 한 줄로 만든다 + */ +export function snippetAround( + body: string, + first: MatchRange, + bounds: MatchRange = [0, body.length], +): string { + const [low, high] = bounds; + const [matchStart, matchEnd] = first; + // 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리 + const lead = Math.max(low, matchStart - SNIPPET_LEAD_CHARS); + let start = lead; + if (lead > low) { + const before = findSpace( + body, + lead, + Math.max(low, lead - SNIPPET_BOUNDARY_SLACK), + ); + const after = + before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1; + const space = before >= 0 ? before : after; + if (space >= 0) start = space + 1; + } + start = Math.max(low, alignStart(body, start)); + // 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다 + const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS); + let end = Math.min(high, target); + if (end < high) { + const back = findSpace( + body, + end, + Math.max(matchEnd, end - SNIPPET_BOUNDARY_SLACK), + ); + const forward = + back < 0 + ? findSpace(body, end, Math.min(high - 1, end + SNIPPET_BOUNDARY_SLACK)) + : -1; + const space = back >= 0 ? back : forward; + if (space >= 0) end = space; + } + end = Math.min(high, alignEnd(body, end)); + const text = oneLine(body.slice(start, end)); + const head = hasVisible(body, 0, start) ? ELLIPSIS : ''; + const tail = hasVisible(body, end, body.length) ? ELLIPSIS : ''; + return `${head}${text}${tail}`; +} + +/** 본문의 한 줄 — [start, end) (줄바꿈 글자는 빼고). 인용 줄이면 `quoteText` 가 표시 다음 글의 시작, 아니면 null */ +interface BodyLine { + readonly start: number; + readonly end: number; + readonly quoteText: number | null; +} + +/** 인용 줄이면 표시 다음 글의 시작 — 앞 공백 뒤 `>` 로 시작하는 줄 (`> > 글` 처럼 겹친 표시와 그 사이 · 뒤 공백을 모두 넘는다) */ +function quoteTextStart( + body: string, + start: number, + end: number, +): number | null { + let index = start; + while (index < end && isSpaceCode(body.charCodeAt(index))) index += 1; + if (index >= end || body.charCodeAt(index) !== QUOTE_MARK_CODE) return null; + while (index < end) { + const code = body.charCodeAt(index); + if (code !== QUOTE_MARK_CODE && !isSpaceCode(code)) break; + index += 1; + } + return index; +} + +/** 본문의 줄들 — 한 번 훑는다 */ +function bodyLines(body: string): BodyLine[] { + const lines: BodyLine[] = []; + for (let start = 0; ; ) { + const found = body.indexOf(LINE_BREAK, start); + const end = found < 0 ? body.length : found; + lines.push({ start, end, quoteText: quoteTextStart(body, start, end) }); + if (found < 0) return lines; + start = found + 1; + } +} + +/** 그 자리를 담은 줄의 차례 (줄은 시작 차례로 놓여 있다) */ +function lineIndexAt(lines: readonly BodyLine[], position: number): number { + let low = 0; + let high = lines.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (lines[middle].start <= position) low = middle; + else high = middle - 1; + } + return low; +} + +/** 인용 줄을 공백으로 덮은 글 — 위치 · 길이는 그대로 (인용 밖에서만 맞추려고) */ +function maskQuotes(text: string, lines: readonly BodyLine[]): string { + let masked = ''; + let from = 0; + for (const line of lines) { + if (line.quoteText === null) continue; + masked += text.slice(from, line.start) + ' '.repeat(line.end - line.start); + from = line.end; + } + return masked + text.slice(from); +} + +/** 그 줄과 같은 쪽(인용 · 인용 밖)인 줄들의 이어진 범위 [시작, 끝) — 미리 보기가 인용과 인용 밖을 섞지 않게 */ +function runAround(lines: readonly BodyLine[], index: number): MatchRange { + const quoted = lines[index].quoteText !== null; + const same = (line: BodyLine) => (line.quoteText !== null) === quoted; + let first = index; + while (first > 0 && same(lines[first - 1])) first -= 1; + let last = index; + while (last + 1 < lines.length && same(lines[last + 1])) last += 1; + return [lines[first].start, lines[last].end]; +} + +/** 인용 밖의 첫 글 — 보이는 글이 있는 첫 인용 밖 줄부터 다음 인용 줄 전까지 (없으면 null) */ +function firstPlainRun( + body: string, + lines: readonly BodyLine[], +): MatchRange | null { + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.quoteText === null && hasVisible(body, line.start, line.end)) { + return [line.start, runAround(lines, index)[1]]; + } + } + return null; +} + +/** 인용 표시를 뺀 글 — 줄은 그대로 잇고, 원래 글의 자리를 이 글의 자리로 바꾸는 함수를 함께 준다 */ +function stripQuoteMarks( + body: string, + lines: readonly BodyLine[], +): { readonly text: string; readonly at: (position: number) => number } { + const parts: string[] = []; + const starts: number[] = []; + let length = 0; + lines.forEach((line, index) => { + if (index > 0) { + parts.push(LINE_BREAK); + length += LINE_BREAK.length; + } + starts.push(length); + const from = line.quoteText ?? line.start; + parts.push(body.slice(from, line.end)); + length += line.end - from; + }); + return { + text: parts.join(''), + at: (position) => { + const index = lineIndexAt(lines, position); + const line = lines[index]; + const from = line.quoteText ?? line.start; + return ( + starts[index] + Math.min(Math.max(0, position - from), line.end - from) + ); + }, + }; +} + +/** + * 본문에서 맞은 말(`words` — 접은 말)이 모두 다른 칸에서도 맞았나 — 검색어의 낱말이고(내용 조건의 낱말은 본문에서만 찾는다) + * 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름) 가운데 하나에 든다. 칸을 모르면(null) 아니다 + */ +function matchedElsewhere( + folded: string, + words: readonly string[], + query: readonly string[], + otherFields: readonly string[] | null, +): boolean { + if (otherFields === null) return false; + const queryWords = new Set(foldTerms(query)); + return words + .filter((word) => folded.includes(word)) + .every( + (word) => + queryWords.has(word) && + otherFields.some((field) => field.includes(word)), + ); +} + +/** 본문에서 고른 미리 보기 한 줄 (위의 1 · 2 · 3) — 본문에서 맞지 않았으면 null */ +function bodySnippet( + text: string, + terms: HighlightTerms, + otherFields: readonly string[] | null, +): string | null { + // 접은 글은 NFC 본문과 위치가 같다 — 줄 · 인용은 본문에서, 맞추기는 접은 글에서 (색인의 본문은 이미 NFC 다 — 거의 그대로 돌아온다) + const body = text.normalize('NFC'); + const folded = foldText(body); + const words = foldTerms(terms.body); + const lines = bodyLines(body); + const [outside] = matchFoldedRanges(maskQuotes(folded, lines), words, 1); + if (outside) { + return snippetAround( + body, + outside, + runAround(lines, lineIndexAt(lines, outside[0])), + ); + } + const [inside] = matchFoldedRanges(folded, words, 1); + if (!inside) return null; + if (matchedElsewhere(folded, words, terms.query, otherFields)) { + const run = firstPlainRun(body, lines); + if (run) return snippetAround(body, [run[0], run[0]], run); + } + // 인용 조각 — 그 인용(이어진 인용 줄)에 닿기 전까지, `>` 표시를 뺀 글에서 + const stripped = stripQuoteMarks(body, lines); + const [runStart, runEnd] = runAround(lines, lineIndexAt(lines, inside[0])); + return snippetAround( + stripped.text, + [stripped.at(inside[0]), stripped.at(inside[1])], + [stripped.at(runStart), stripped.at(runEnd)], + ); +} + +function toRanges(ranges: readonly MatchRange[]): SearchRange[] { + return ranges.map(([start, end]) => [start, end] as const); +} + +/** + * 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null), + * `otherFields` 는 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 — 없으면 null) + */ +export function listHighlight( + input: { + readonly subject: string; + readonly preview: string; + readonly bodyText: string | null; + readonly otherFields: readonly string[] | null; + }, + terms: HighlightTerms, +): MessageListHighlight { + const subject = toRanges(matchRanges(input.subject, terms.subject)); + const fromBody = + input.bodyText && terms.body.length > 0 + ? bodySnippet(input.bodyText, terms, input.otherFields) + : null; + const text = fromBody ?? oneLine(input.preview.normalize('NFC')); + return { + subject, + preview: { text, ranges: toRanges(matchRanges(text, terms.body)) }, + }; +} + +/** 응답에 실을 제목 — 강조 위치의 기준인 NFC (화면에는 같게 보인다) */ +export function highlightedSubject(subject: string): string { + return subject.normalize('NFC'); +} diff --git a/backend/src/modules/mail-search/search-index-schedule.spec.ts b/backend/src/modules/mail-search/search-index-schedule.spec.ts new file mode 100644 index 0000000..bb46d60 --- /dev/null +++ b/backend/src/modules/mail-search/search-index-schedule.spec.ts @@ -0,0 +1,91 @@ +import { MailSearchRuns } from './mail-search-runs'; +import { FAILURE_BACKOFF_BASE_MS } from './mail-search.constants'; +import { isBuildWindow, isDueAfterFailures } from './search-index-schedule'; + +/** 한국 시각 'HH:MM' → 그 시각(2026-11-05 한국 날짜)의 Date */ +function kst(time: string): Date { + return new Date(`2026-11-05T${time}:00+09:00`); +} + +describe('isBuildWindow (밤 채우기의 창 — 한국 시각)', () => { + it.each([ + ['22:00', true], + ['23:59', true], + ['00:00', true], + ['02:44', true], + ['02:45', false], + ['03:00', false], + ['03:30', false], + ['04:30', false], + ['04:59', false], + ['05:00', true], + ['07:29', true], + ['07:30', false], + ['12:00', false], + ['21:59', false], + ])('%s → %s', (time, inside) => { + expect(isBuildWindow(kst(time))).toBe(inside); + }); +}); + +describe('isDueAfterFailures (실패가 이어진 직원을 다시 해 볼 때)', () => { + const at = new Date('2026-11-05T00:00:00Z'); + + it('실패가 없으면 늘 된다', () => { + expect(isDueAfterFailures(0, at, at.getTime())).toBe(true); + }); + + it('실패 수마다 기다림이 두 배 — 상한 단계에서 멈춘다', () => { + expect( + isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS - 1), + ).toBe(false); + expect( + isDueAfterFailures(1, at, at.getTime() + FAILURE_BACKOFF_BASE_MS), + ).toBe(true); + expect( + isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 3), + ).toBe(false); + expect( + isDueAfterFailures(3, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 4), + ).toBe(true); + // 100번이어도 상한(6단계 — 32배)까지만 기다린다 + expect( + isDueAfterFailures(100, at, at.getTime() + FAILURE_BACKOFF_BASE_MS * 32), + ).toBe(true); + }); +}); + +describe('MailSearchRuns (직원마다 한 번에 하나)', () => { + it('같은 직원의 일은 차례로, 다른 직원은 함께 — 앞 일이 실패해도 다음 일은 한다', async () => { + const runs = new MailSearchRuns(); + const order: string[] = []; + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const first = runs.run('Ara@x', async () => { + order.push('ara-1 시작'); + await gate; + order.push('ara-1 끝'); + throw new Error('실패'); + }); + const second = runs.run('ara@x', () => { + order.push('ara-2'); + return Promise.resolve(2); + }); + const other = runs.run('doyun@x', () => { + order.push('doyun'); + return Promise.resolve(3); + }); + + await other; + expect(runs.isBusy('ARA@x')).toBe(true); + release(); + await expect(first).rejects.toThrow('실패'); + await expect(second).resolves.toBe(2); + expect(order).toEqual(['ara-1 시작', 'doyun', 'ara-1 끝', 'ara-2']); + await new Promise((resolve) => setImmediate(resolve)); + expect(runs.isBusy('ara@x')).toBe(false); + }); +}); diff --git a/backend/src/modules/mail-search/search-index-schedule.ts b/backend/src/modules/mail-search/search-index-schedule.ts new file mode 100644 index 0000000..9236434 --- /dev/null +++ b/backend/src/modules/mail-search/search-index-schedule.ts @@ -0,0 +1,33 @@ +import { kstMinuteOfDay } from '@/shared/utils/kst-date'; +import { + BUILD_WINDOWS_KST, + FAILURE_BACKOFF_BASE_MS, + FAILURE_BACKOFF_STEPS_MAX, +} from './mail-search.constants'; + +/** + * 검색 색인의 때 고르기 (순수 함수 — 14단계 설계 B2 · B3). + */ + +/** 밤 채우기의 창 안인가 — 한국 시각 22:00 ~ 02:45 · 05:00 ~ 07:30 (끝 분은 들지 않는다) */ +export function isBuildWindow(at: Date): boolean { + const minute = kstMinuteOfDay(at); + return BUILD_WINDOWS_KST.some( + ([start, end]) => minute >= start && minute < end, + ); +} + +/** + * 실패가 이어진 직원을 다시 해 볼 때가 됐나 — 실패 수마다 두 배(5분 · 10분 · 20분 …, FAILURE_BACKOFF_STEPS_MAX 단계에서 멈춤). + * 실패가 없으면 늘 된다. `lastAttemptAt` 은 마지막 실패를 적은 때(상태 줄의 updated_at) + */ +export function isDueAfterFailures( + failureCount: number, + lastAttemptAt: Date, + now: number, +): boolean { + if (failureCount <= 0) return true; + const steps = Math.min(failureCount, FAILURE_BACKOFF_STEPS_MAX); + const wait = FAILURE_BACKOFF_BASE_MS * 2 ** (steps - 1); + return now - lastAttemptAt.getTime() >= wait; +} diff --git a/backend/src/modules/mail-search/search-index.controller.ts b/backend/src/modules/mail-search/search-index.controller.ts new file mode 100644 index 0000000..796e245 --- /dev/null +++ b/backend/src/modules/mail-search/search-index.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiSessionErrors, + 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 { SearchIndexStatusDto } from './dto/search-index-status.dto'; +import { MailSearchIndexService } from './mail-search-index.service'; + +/** 검색 준비 상태 라우트 (`/api/search-index`) — 14단계 */ +export const SEARCH_INDEX_ROUTE = 'search-index'; + +// 검색 준비 상태 (14단계 — 자체 검색 색인). 줄이 없던 직원은 이때 만든다 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@Controller(SEARCH_INDEX_ROUTE) +export class SearchIndexController { + constructor(private readonly searchIndex: MailSearchIndexService) {} + + @Get() + @ApiOperation({ + summary: '검색 준비 상태', + description: + '지난 메일의 검색 색인을 만드는 중(building)이면 그 직원의 검색은 엔진 방식이다 — 화면은 "검색 준비 중" 줄을 보인다. 색인은 밤에 한 사람씩 채운다. 처음 묻는 직원(상태 줄 없음)은 이때 building 으로 만든다.', + }) + @ApiStandardResponse(SearchIndexStatusDto, { description: '지금 상태' }) + status(@CurrentUser() user: AuthenticatedUser) { + return this.searchIndex.status(user.email); + } +} diff --git a/backend/src/modules/mail-search/search-plan.spec.ts b/backend/src/modules/mail-search/search-plan.spec.ts new file mode 100644 index 0000000..5a5b560 --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.spec.ts @@ -0,0 +1,300 @@ +import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants'; +import type { SearchCriteria } from './mail-search.types'; +import { + ALL_FOLD_FIELDS, + highlightTermsOf, + isSearchCriteria, + planSearch, +} from './search-plan'; + +const criteria = (overrides: Partial): SearchCriteria => ({ + hasAttachment: false, + ...overrides, +}); + +describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => { + it('검색인가 — 검색어 · 글 조건 · 기간 · 첨부 가운데 하나라도 (빈 글 · 첨부 false 는 없는 것)', () => { + expect(isSearchCriteria(criteria({}))).toBe(false); + expect(isSearchCriteria(criteria({ text: '', subject: '' }))).toBe(false); + for (const overrides of [ + { text: '계약' }, + { from: '이도윤' }, + { to: 'ara' }, + { subject: '견적' }, + { body: '일정' }, + { after: '2026-10-31T15:00:00.000Z' }, + { before: '2026-11-30T15:00:00.000Z' }, + { hasAttachment: true }, + ]) { + expect(isSearchCriteria(criteria(overrides))).toBe(true); + } + }); + + it('검색어의 낱말은 다섯 칸 어디서든 — 접은 낱말로 확인하고 글자 두 개 묶음으로 후보를 좁힌다', () => { + const plan = planSearch(criteria({ text: '계약 PROJECT' })); + + expect(plan.checks).toEqual([ + { term: '계약', fields: ALL_FOLD_FIELDS }, + { term: 'project', fields: ALL_FOLD_FIELDS }, + ]); + expect(plan.grams).toEqual(['계약', 'pr', 'ro', 'oj', 'je', 'ec', 'ct']); + expect(plan.matchesNothing).toBe(false); + expect(plan.bodySkipped).toBe(false); + // 강조는 접기 전 낱말 그대로 (맞추기가 접는다) + expect(plan.highlight).toEqual({ + subject: ['계약', 'PROJECT'], + body: ['계약', 'PROJECT'], + attachment: ['계약', 'PROJECT'], + query: ['계약', 'PROJECT'], + }); + }); + + it('큰따옴표 구절은 띄어쓰기까지 그대로 하나의 확인 — 묶음은 공백을 넘지 않는다, 앞뒤 문장부호를 뗀 낱말 · 같은 낱말은 한 번', () => { + const plan = planSearch(criteria({ text: '"계약 갱신" (계약), 계약' })); + + expect(plan.checks.map((check) => check.term)).toEqual([ + '계약 갱신', + '계약', + ]); + expect(plan.grams).toEqual(['계약', '갱신']); + }); + + it('조건의 낱말은 그 칸에서만 — 받는 사람(to)은 받는 사람 · 참조를 담은 칸, 보낸 사람 · 받는 사람은 강조하지 않는다', () => { + const plan = planSearch( + criteria({ + from: '이도윤', + to: 'ara.kim', + subject: '견적', + body: '"단가 조정"', + }), + ); + + expect(plan.checks).toEqual([ + { term: '이도윤', fields: ['fold_from'] }, + { term: 'ara.kim', fields: ['fold_to'] }, + { term: '견적', fields: ['fold_subject'] }, + { term: '단가 조정', fields: ['fold_body'] }, + ]); + expect(plan.highlight).toEqual({ + subject: ['견적'], + body: ['단가 조정'], + attachment: [], + query: [], + }); + }); + + it('같은 낱말이라도 찾는 칸이 다르면 따로 확인한다', () => { + const plan = planSearch(criteria({ text: '견적', subject: '견적' })); + expect(plan.checks).toEqual([ + { term: '견적', fields: ALL_FOLD_FIELDS }, + { term: '견적', fields: ['fold_subject'] }, + ]); + expect(plan.grams).toEqual(['견적']); + }); + + it('한 글자 낱말은 두 글자 넘는 낱말과 함께면 확인에만 쓴다 — 본문에서도 찾는다', () => { + const plan = planSearch(criteria({ text: '묵 계약' })); + + expect(plan.bodySkipped).toBe(false); + expect(plan.grams).toEqual(['계약']); + expect(plan.checks).toEqual([ + { term: '묵', fields: ALL_FOLD_FIELDS }, + { term: '계약', fields: ALL_FOLD_FIELDS }, + ]); + // 본문 강조는 두 글자 이상 낱말만 — 한 글자 낱말은 제목 · 첨부 이름에만 (2026-11-06 실사용 시험) + expect(plan.highlight).toEqual({ + subject: ['묵', '계약'], + body: ['계약'], + attachment: ['묵', '계약'], + query: ['묵', '계약'], + }); + }); + + it('본문 강조는 본문을 찾은 두 글자 이상 낱말만 — 내용 조건과 섞여도 · 묶음이 없는 구절(한 글자씩 띄운 것)도 한 글자 낱말과 같다', () => { + // 내용 조건의 두 글자 이상 낱말과 섞임 — 검색어의 한 글자 낱말은 본문에 강조하지 않는다 + expect( + planSearch(criteria({ text: '견', body: '계약서' })).highlight.body, + ).toEqual(['계약서']); + // 두 글자 이상 낱말이 강조하지 않는 칸(보낸 사람)에만 있어도 섞인 검색이다 — 본문에 강조할 말이 없다 + const withSender = planSearch(criteria({ text: '견', from: '김아라' })); + expect(withSender.bodySkipped).toBe(false); + expect(withSender.highlight.body).toEqual([]); + expect(withSender.highlight.subject).toEqual(['견']); + // 한 글자씩 띄운 구절은 묶음이 없다 + expect(planSearch(criteria({ text: '"a b" 계약' })).highlight.body).toEqual( + ['계약'], + ); + }); + + it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => { + expect(SINGLE_CHAR_TERMS_SKIP_BODY).toBe(true); + const plan = planSearch(criteria({ text: '묵 뷁', from: '김' })); + + expect(plan.bodySkipped).toBe(true); + expect(plan.grams).toEqual([]); + expect(plan.matchesNothing).toBe(false); + expect(plan.checks).toEqual([ + { + term: '묵', + fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'], + }, + { + term: '뷁', + fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'], + }, + { term: '김', fields: ['fold_from'] }, + ]); + // 본문을 찾지 않았으니 본문(미리 보기 · 읽기 화면)에는 강조하지 않는다 — 제목 · 첨부 이름에는 그대로 (2026-11-06 실사용 시험) + expect(plan.highlight).toEqual({ + subject: ['묵', '뷁'], + body: [], + attachment: ['묵', '뷁'], + query: ['묵', '뷁'], + }); + }); + + it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => { + expect(planSearch(criteria({ body: '묵' })).matchesNothing).toBe(true); + expect( + planSearch(criteria({ text: '묵', body: '뷁' })).matchesNothing, + ).toBe(true); + // 두 글자 넘는 낱말과 함께면 한 글자 내용 조건도 확인한다 + const together = planSearch(criteria({ text: '계약', body: '묵' })); + expect(together.matchesNothing).toBe(false); + expect(together.checks).toContainEqual({ + term: '묵', + fields: ['fold_body'], + }); + }); + + it('글을 준 칸에 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다', () => { + for (const overrides of [ + { text: '…' }, + { text: '""' }, + { from: '-' }, + { text: '계약', subject: '—' }, + ]) { + expect(planSearch(criteria(overrides)).matchesNothing).toBe(true); + } + }); + + it('기간 · 첨부만의 검색은 확인도 묶음도 없다 — 받은 시각 · 첨부 표시로만', () => { + const plan = planSearch( + criteria({ + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }), + ); + + expect(plan.checks).toEqual([]); + expect(plan.grams).toEqual([]); + expect(plan.matchesNothing).toBe(false); + expect(plan.after?.toISOString()).toBe('2026-10-31T15:00:00.000Z'); + expect(plan.before?.toISOString()).toBe('2026-11-30T15:00:00.000Z'); + expect(plan.hasAttachment).toBe(true); + }); + + it('전각 · 대문자 · 공백류는 접어서 확인한다 — 구절 안의 줄바꿈도 띄어쓰기 하나와 같다', () => { + const plan = planSearch(criteria({ text: '"ERP\u3000도입"' })); + expect(plan.checks.map((check) => check.term)).toEqual(['erp 도입']); + expect(plan.grams).toEqual(['er', 'rp', '도입']); + }); + + it('두 글자 낱말뿐인 검색어는 묶음만으로 정해진다(exactByGrams) — 세 글자 · 구절 · 한 글자 · 조건 칸이 섞이면 확인한다', () => { + // 서로게이트 쌍 글자(CJK 확장 B — 글자)도 코드 포인트 하나로 센다 + const astralAndLetter = `${String.fromCodePoint(0x20000)}가`; + for (const text of [ + '계약', + '계약 확인', + 'ab', + 'AB', + astralAndLetter, + '(계약),', + ]) { + expect(planSearch(criteria({ text })).exactByGrams).toBe(true); + } + expect( + planSearch( + criteria({ + text: '계약', + after: '2026-10-31T15:00:00.000Z', + hasAttachment: true, + }), + ).exactByGrams, + ).toBe(true); + for (const overrides of [ + { text: '계약서' }, + { text: '"계약 갱신"' }, + { text: '묵 계약' }, + { text: '계약', subject: '견적' }, + { subject: '계약' }, + { hasAttachment: true }, + ]) { + expect(planSearch(criteria(overrides)).exactByGrams).toBe(false); + } + }); + + describe('읽기 화면의 강조할 말 (highlightTermsOf — 설계 C2)', () => { + it('검색어는 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에', () => { + expect( + highlightTermsOf({ + text: '계약', + subject: '견적', + body: '"단가 조정"', + }), + ).toEqual({ + subject: ['계약', '견적'], + body: ['계약', '단가 조정'], + attachment: ['계약'], + query: ['계약'], + }); + }); + + it('강조할 말이 없으면 null — 검색어 · 제목 · 내용 조건이 없거나 문장부호뿐', () => { + expect(highlightTermsOf({})).toBeNull(); + expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull(); + }); + + it('본문에는 목록과 같이 본문을 찾은 낱말만 — 한 글자 낱말뿐이면 제목 · 첨부 이름에만, 섞이면 두 글자 이상 낱말만 (보낸 사람 · 받는 사람 조건도 섞임을 정한다)', () => { + expect(highlightTermsOf({ text: '견' })).toEqual({ + subject: ['견'], + body: [], + attachment: ['견'], + query: ['견'], + }); + expect(highlightTermsOf({ text: '견 계약서' })?.body).toEqual(['계약서']); + expect(highlightTermsOf({ text: '견', to: '김아라' })?.body).toEqual([]); + // 한 글자 내용 조건뿐 — 찾을 곳이 없어 강조할 말도 없다 + expect(highlightTermsOf({ body: '견' })).toBeNull(); + // 목록의 계획과 같은 강조 + for (const given of [ + { text: '견' }, + { text: '견 계약서', subject: '회' }, + { text: '견', from: '김아라', body: '단가' }, + ]) { + expect(highlightTermsOf(given)).toEqual( + planSearch(criteria(given)).highlight, + ); + } + }); + }); + + it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => { + const NUL = String.fromCharCode(0); + const FFFD = String.fromCharCode(0xfffd); + const plan = planSearch(criteria({ text: `계${NUL}약`, body: `a${NUL}b` })); + expect(plan.checks.map((check) => check.term)).toEqual([ + `계${FFFD}약`, + `a${FFFD}b`, + ]); + expect(plan.grams).toEqual([ + `계${FFFD}`, + `${FFFD}약`, + `a${FFFD}`, + `${FFFD}b`, + ]); + expect(JSON.stringify(plan)).not.toContain(NUL); + expect(plan.highlight.body).toEqual([`계${FFFD}약`, `a${FFFD}b`]); + }); +}); diff --git a/backend/src/modules/mail-search/search-plan.ts b/backend/src/modules/mail-search/search-plan.ts new file mode 100644 index 0000000..3b4ed10 --- /dev/null +++ b/backend/src/modules/mail-search/search-plan.ts @@ -0,0 +1,231 @@ +import { parseSearchTerms } from '@/shared/utils/search-query'; +import { foldText, gramsOf, isSpaceCode } from '@/shared/utils/search-text'; +import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants'; +import type { SearchCriteria } from './mail-search.types'; + +/** + * 검색 조건 → 색인에서 찾을 계획 (순수 함수 — 14단계 설계 C1 · G). + * + * - 검색어(q)의 낱말 · 구절은 다섯 칸(보낸 사람 · 받는 사람(참조 포함) · 제목 · 첨부 이름 · 본문) 가운데 어디든, 조건 칸(from · to · subject · body)의 + * 낱말 · 구절은 그 칸에서 — 모두 맞아야 한다(AND). 낱말 · 구절 나누기는 엔진 검색과 같다(parseSearchTerms — 큰따옴표 구절 · 앞뒤 문장부호 떼기) + * - 낱말마다 접기(foldText) 뒤 부분 일치(strpos)로 확인하고, 모든 낱말의 글자 두 개 묶음(gramsOf)으로 후보를 좁힌다(GIN `grams @>`) — + * 낱말이 한 칸 안에 이어 있으니 그 묶음은 늘 그 줄의 묶음 안에 있다 + * - 한 글자 낱말(묶음이 없는 낱말): 묶음이 있는 낱말과 함께면 확인에만 쓴다. 한 글자 낱말뿐이면(SINGLE_CHAR_TERMS_SKIP_BODY) 본문은 훑지 않는다 — + * 검색어의 낱말은 제목 · 사람 · 첨부 이름에서만, 내용(body) 조건의 낱말은 찾을 곳이 없어 아무것도 맞지 않는다 + * - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다) + * - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams). + * 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다 + * - 본문(목록 미리 보기 · 읽기 화면)에는 본문을 찾은 낱말만 강조한다(2026-11-06 실사용 시험 — bodyHighlightOf). 제목 · 첨부 이름 강조는 낱말 그대로다 + */ + +/** 확인하는 접기 칸 — 표의 열 이름 */ +export type FoldField = + | 'fold_from' + | 'fold_to' + | 'fold_subject' + | 'fold_attachment' + | 'fold_body'; + +/** 검색어(q)의 낱말을 찾는 칸 — 다섯 칸 모두 */ +export const ALL_FOLD_FIELDS: readonly FoldField[] = [ + 'fold_from', + 'fold_to', + 'fold_subject', + 'fold_attachment', + 'fold_body', +]; + +/** 조건 칸 → 그 칸의 접기 열 */ +const CONDITION_FIELDS = { + from: 'fold_from', + to: 'fold_to', + subject: 'fold_subject', + body: 'fold_body', +} as const satisfies Record; + +type ConditionName = keyof typeof CONDITION_FIELDS; +const CONDITION_NAMES = Object.keys(CONDITION_FIELDS) as ConditionName[]; + +/** 낱말 하나의 확인 — 접은 낱말이 그 칸들 가운데 하나에 들어 있어야 한다 */ +export interface TermCheck { + readonly term: string; + readonly fields: readonly FoldField[]; +} + +/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */ +export interface HighlightTerms { + readonly subject: readonly string[]; + /** 본문을 찾은 낱말만 (bodyHighlightOf) — 한 글자 낱말은 들지 않는다 */ + readonly body: readonly string[]; + readonly attachment: readonly string[]; + /** + * 검색어(q)의 낱말 — 다섯 칸 어디서든 찾는다. 목록 미리 보기가 본문의 인용 안에서만 맞은 말을 다른 칸(제목 · 사람 · 첨부 이름)에서도 + * 찾았는지 볼 때 쓴다(search-highlight.ts). 내용 조건(body)의 낱말은 본문에서만 찾으므로 들지 않는다 + */ + readonly query: readonly string[]; +} + +export interface SearchPlan { + /** 낱말 확인 — 모두 맞아야 한다. 비었으면 범위 · 기간 · 첨부만으로 찾는다 */ + readonly checks: readonly TermCheck[]; + /** 후보를 좁히는 글자 두 개 묶음 — 비었으면 GIN 을 쓰지 못한다 */ + readonly grams: readonly string[]; + readonly after: Date | null; + readonly before: Date | null; + readonly hasAttachment: boolean; + /** 아무것도 맞을 수 없다 — 묻지 않고 빈 결과 */ + readonly matchesNothing: boolean; + /** 한 글자 낱말뿐이라 본문을 훑지 않았다 (SINGLE_CHAR_TERMS_SKIP_BODY) */ + readonly bodySkipped: boolean; + /** 묶음만으로 정해진다 — 검색어(다섯 칸)의 두 글자 낱말뿐이라 확인(strpos)이 늘 참이다 */ + readonly exactByGrams: boolean; + readonly highlight: HighlightTerms; +} + +/** 검색인가 — 검색어나 조건이 하나라도 있으면 (빈 글은 없는 것) */ +export function isSearchCriteria(criteria: SearchCriteria): boolean { + return ( + Boolean(criteria.text) || + CONDITION_NAMES.some((name) => Boolean(criteria[name])) || + criteria.after !== undefined || + criteria.before !== undefined || + criteria.hasAttachment === true + ); +} + +/** 글을 낱말 · 구절로 — 접기 전 글(강조)과 접은 글(확인) */ +function termsOf(text: string | undefined): { + readonly given: boolean; + readonly raw: string[]; +} { + if (!text) return { given: false, raw: [] }; + return { given: true, raw: parseSearchTerms(text).map((term) => term.text) }; +} + +/** 두 글자 낱말인가 — 코드 포인트 둘 · 공백 없음 (그 낱말의 묶음 하나가 곧 그 낱말이다) */ +function isGramWord(term: string): boolean { + const points = [...term]; + return ( + points.length === 2 && + points.every((point) => !isSpaceCode(point.charCodeAt(0))) + ); +} + +function dateOf(value: string | undefined): Date | null { + if (value === undefined) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : new Date(time); +} + +/** 묶음이 있는 낱말인가 — 접어서 글자 두 개 묶음이 하나라도 나온다 (한 글자 낱말 · 한 글자씩 띄운 구절은 없다) */ +function hasGrams(raw: string): boolean { + return gramsOf(foldText(raw)).size > 0; +} + +/** + * 본문에 강조할 말 (2026-11-06 실사용 시험) — 본문을 찾은 낱말만이다. 검색어 · 내용 조건의 낱말(`raws`) 가운데 + * - 한 글자 낱말뿐이라 본문을 훑지 않았으면(bodySkipped) 없다 + * - 묶음이 있는 낱말(두 글자 이상)이 하나라도 있으면(`anyGrams` — 다섯 칸의 낱말 어디든) 그 낱말만 — 한 글자 낱말은 함께 찾아 본문도 확인했지만 + * 본문 곳곳에 맞아 미리 보기 · 읽기 화면의 강조가 흩어진다 + * - 묶음이 하나도 없는데 본문을 훑었으면(SINGLE_CHAR_TERMS_SKIP_BODY 를 끈 때) 모두 + */ +function bodyHighlightOf( + raws: readonly string[], + bodySkipped: boolean, + anyGrams: boolean, +): string[] { + if (bodySkipped) return []; + return anyGrams ? raws.filter(hasGrams) : [...raws]; +} + +export function planSearch(criteria: SearchCriteria): SearchPlan { + const query = termsOf(criteria.text); + const conditions = CONDITION_NAMES.map((name) => ({ + name, + ...termsOf(criteria[name]), + })); + // 글을 준 칸에 찾을 낱말이 없다 — 아무것도 맞지 않는다 + let matchesNothing = + (query.given && query.raw.length === 0) || + conditions.some( + (condition) => condition.given && condition.raw.length === 0, + ); + + const grams = new Set(); + const pending: { term: string; fields: readonly FoldField[] }[] = []; + const add = (raw: string, fields: readonly FoldField[]) => { + const term = foldText(raw); + gramsOf(term, grams); + pending.push({ term, fields }); + }; + for (const raw of query.raw) add(raw, ALL_FOLD_FIELDS); + for (const condition of conditions) { + for (const raw of condition.raw) + add(raw, [CONDITION_FIELDS[condition.name]]); + } + + // 한 글자 낱말뿐 — 본문은 훑지 않는다 (제목 · 사람 · 첨부 이름만) + const bodySkipped = + SINGLE_CHAR_TERMS_SKIP_BODY && pending.length > 0 && grams.size === 0; + const seen = new Set(); + const checks: TermCheck[] = []; + for (const check of pending) { + const fields = bodySkipped + ? check.fields.filter((field) => field !== 'fold_body') + : check.fields; + if (fields.length === 0) { + matchesNothing = true; + continue; + } + const key = `${check.term}\n${fields.join(',')}`; + if (seen.has(key)) continue; + seen.add(key); + checks.push({ term: check.term, fields }); + } + + const exactByGrams = + checks.length > 0 && + checks.every( + (check) => + check.fields.length === ALL_FOLD_FIELDS.length && + isGramWord(check.term), + ); + const subjectTerms = conditions.find((c) => c.name === 'subject')?.raw ?? []; + const bodyTerms = conditions.find((c) => c.name === 'body')?.raw ?? []; + return { + checks, + grams: [...grams], + after: dateOf(criteria.after), + before: dateOf(criteria.before), + hasAttachment: criteria.hasAttachment === true, + matchesNothing, + bodySkipped, + exactByGrams, + highlight: { + subject: [...query.raw, ...subjectTerms], + body: bodyHighlightOf( + [...query.raw, ...bodyTerms], + bodySkipped, + grams.size > 0, + ), + attachment: [...query.raw], + query: [...query.raw], + }, + }; +} + +/** + * 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에 — 목록과 같은 계획(planSearch)에서 꺼낸다. + * 본문에는 본문을 찾은 낱말만(한 글자 낱말 — bodyHighlightOf). 보낸 사람 · 받는 사람 조건은 강조하지 않지만 받는다 — 두 글자 이상 낱말이 + * 그 칸에만 있어도 한 글자 낱말과 "섞인" 검색이다(목록의 찾기와 같은 판정). 강조할 말이 하나도 없으면 null + */ +export function highlightTermsOf( + criteria: Pick, +): HighlightTerms | null { + const { highlight } = planSearch({ ...criteria, hasAttachment: false }); + // 첨부 이름의 말(검색어)은 늘 제목의 말 안에 든다 + if (highlight.subject.length === 0 && highlight.body.length === 0) { + return null; + } + return highlight; +} diff --git a/backend/src/modules/mail-search/search-sql.spec.ts b/backend/src/modules/mail-search/search-sql.spec.ts new file mode 100644 index 0000000..56528b0 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.spec.ts @@ -0,0 +1,196 @@ +import type { SearchCriteria, SearchScope } from './mail-search.types'; +import { planSearch } from './search-plan'; +import { + candidatesSql, + checkHitsSql, + highlightTextsSql, + keysetCountSql, + keysetFrontierSql, + keysetHitsSql, + plainCountSql, + plainPageSql, +} from './search-sql'; + +const USER = 'ara.kim@arasystem.test'; +const ALL: SearchScope = { + mailboxId: null, + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], +}; +const INBOX: SearchScope = { + mailboxId: 'a', + excludedMailboxIds: [], + excludedKeywords: [], +}; + +const plan = (overrides: Partial) => + planSearch({ hasAttachment: false, ...overrides }); + +/** SQL 의 공백을 하나로 (비교 편하게) */ +const flat = (sql: string) => sql.replace(/\s+/g, ' '); + +describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => { + it('후보 — 주소(찾기 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => { + const query = candidatesSql( + USER, + ALL, + plan({ text: '계약 갱신' }), + null, + 3_001, + ); + + expect(flat(query.sql)).toBe( + 'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE lower("user_email") = $1 ' + + 'AND NOT ("mailbox_ids" <@ $2::text[]) AND NOT ("keywords" && $3::text[]) AND "grams" @> $4::text[] ' + + 'ORDER BY "received_at" DESC, "email_id" DESC LIMIT $5', + ); + expect(query.params).toEqual([ + USER, + ['b', 'c'], + ['$ara-sending'], + ['계약', '갱신'], + 3_001, + ]); + // 주소 그대로("user_email" = …)는 btree 로 그 직원의 줄을 다 읽게 둔다 — 후보 쿼리에는 쓰지 않는다 + expect(query.sql).not.toContain('"user_email" ='); + expect(query.sql).not.toContain('strpos'); + }); + + it('메일함 범위는 찾기 GIN 이 함께 맞추는 꼴(@>) — = ANY 로 적으면 그 직원의 묶음 줄을 모두 읽고 메일함을 줄마다 거른다 (2026-11-06 실사용 시험), 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => { + const after = { + receivedAt: new Date('2026-11-01T00:00:00Z'), + emailId: 'm9', + }; + const query = candidatesSql( + USER, + INBOX, + plan({ + text: '계약', + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }), + after, + 10, + ); + + expect(flat(query.sql)).toContain( + 'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' + + 'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]', + ); + expect(query.sql).not.toContain('ANY("mailbox_ids")'); + expect(query.params).toEqual([ + USER, + ['a'], + new Date('2026-10-31T15:00:00.000Z'), + new Date('2026-11-30T15:00:00.000Z'), + after.receivedAt, + 'm9', + ['계약'], + 10, + ]); + }); + + it('확인 — 후보 id 의 줄만 기본 키로 읽어 낱말마다 그 칸들 가운데 하나의 strpos', () => { + const query = checkHitsSql(USER, plan({ text: '계약', from: '이도윤' }), [ + 'm1', + 'm2', + ]); + + expect(flat(query.sql)).toBe( + 'SELECT "email_id" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[]) ' + + 'AND (strpos("fold_from", $3) > 0 OR strpos("fold_to", $3) > 0 OR strpos("fold_subject", $3) > 0 ' + + 'OR strpos("fold_attachment", $3) > 0 OR strpos("fold_body", $3) > 0) AND (strpos("fold_from", $4) > 0)', + ); + expect(query.params).toEqual([USER, ['m1', 'm2'], '계약', '이도윤']); + }); + + it('묶음이 없는 찾기(한 글자 낱말뿐) — 안쪽에서 확인 상한만큼 받은 차례로 자른 뒤 바깥에서 확인하고 쪽 크기에서 멈춘다, 묶음은 보지 않는다', () => { + const query = keysetHitsSql( + USER, + INBOX, + plan({ text: '묵' }), + null, + 3_000, + 21, + ); + const sql = flat(query.sql); + + expect(sql).toMatch( + /^SELECT "email_id", "received_at" FROM \(SELECT "email_id", "received_at", "fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body" FROM "mail_search_messages" WHERE "user_email" = \$1 AND "mailbox_ids" @> \$2::text\[\] ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$3\) AS "scan" WHERE \(strpos\("fold_from", \$4\) > 0 .+\) ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$5$/, + ); + expect(sql).not.toContain('"grams"'); + // 본문은 훑지 않는다 (SINGLE_CHAR_TERMS_SKIP_BODY) + expect(sql).not.toContain('strpos("fold_body"'); + expect(query.params.slice(2)).toEqual([3_000, '묵', 21]); + }); + + it('훑은 창의 끝 · 창 안의 맞은 수 — 끝은 확인 없이 그 창의 가장 오래된 줄과 줄 수, 수는 countLimit 까지', () => { + const frontier = keysetFrontierSql( + USER, + INBOX, + plan({ text: '묵' }), + null, + 3_000, + ); + expect(flat(frontier.sql)).toMatch( + /^SELECT "email_id", "received_at", count\(\*\) OVER \(\)::int AS "scanned" FROM \(SELECT "email_id", "received_at" FROM .+ LIMIT \$3\) AS "scan" ORDER BY "received_at" ASC, "email_id" ASC LIMIT 1$/, + ); + expect(frontier.sql).not.toContain('strpos'); + + const count = keysetCountSql( + USER, + INBOX, + plan({ text: '묵' }), + 3_000, + 1_001, + ); + expect(flat(count.sql)).toMatch( + /^SELECT count\(\*\)::int AS "count" FROM \(SELECT 1 FROM \(SELECT .+ LIMIT \$3\) AS "scan" WHERE .+strpos.+ LIMIT \$5\) AS "hits"$/, + ); + expect(count.params.slice(2)).toEqual([3_000, '묵', 1_001]); + }); + + it('낱말 확인이 없을 때(기간 · 첨부만) — 묶음 · strpos 없이 받은 차례로 · 수도 같은 조건으로', () => { + const only = plan({ hasAttachment: true }); + const page = plainPageSql(USER, ALL, only, null, 21); + expect(flat(page.sql)).toBe( + 'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE "user_email" = $1 AND NOT ("mailbox_ids" <@ $2::text[]) ' + + 'AND NOT ("keywords" && $3::text[]) AND "has_attachment" ORDER BY "received_at" DESC, "email_id" DESC LIMIT $4', + ); + const count = plainCountSql(USER, ALL, only, 1_001); + expect(flat(count.sql)).toContain( + 'AND "has_attachment" LIMIT $4) AS "matches"', + ); + expect(count.params).toEqual([USER, ['b', 'c'], ['$ara-sending'], 1_001]); + }); + + it('검색 글은 SQL 에 이어 붙이지 않는다 — 따옴표 · 세미콜론이 든 낱말도 매개변수로만', () => { + const hostile = `x'); DROP TABLE users; --`; + const checked = [ + checkHitsSql(USER, plan({ text: hostile }), ['m1']), + keysetHitsSql(USER, ALL, plan({ subject: hostile }), null, 10, 10), + ]; + for (const query of [ + ...checked, + candidatesSql(USER, ALL, plan({ body: hostile }), null, 10), + ]) { + expect(query.sql).not.toMatch(/drop|users|x'/i); + } + // 낱말은 접은 글로 매개변수에만 (앞뒤 문장부호를 뗀 x · drop · table · users) + for (const query of checked) { + expect(query.params).toEqual( + expect.arrayContaining(['x', 'drop', 'table', 'users']), + ); + } + }); + + it('강조에 쓰는 글 — 그 직원의 찾은 줄만 (본문 밖의 접은 칸 넷도 — 미리 보기가 인용 안에서만 맞은 말을 다른 칸에서 찾았는지 본다)', () => { + const query = highlightTextsSql(USER, ['m1', 'm2']); + expect(flat(query.sql)).toBe( + 'SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" ' + + 'FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])', + ); + expect(query.params).toEqual([USER, ['m1', 'm2']]); + }); +}); diff --git a/backend/src/modules/mail-search/search-sql.ts b/backend/src/modules/mail-search/search-sql.ts new file mode 100644 index 0000000..210cbb8 --- /dev/null +++ b/backend/src/modules/mail-search/search-sql.ts @@ -0,0 +1,279 @@ +import { SEARCH_MESSAGES_TABLE } from './mail-search.constants'; +import type { SearchScope } from './mail-search.types'; +import type { SearchPlan } from './search-plan'; + +/** + * 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다. + * + * 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다: + * - 후보(candidatesSql): 주소 + 묶음 + 메일함을 GIN 하나(`IDX_mail_search_messages_user_grams_mailboxes` — 마이그레이션 SearchIndexUserGramsMailboxes)에서만 + * 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. 메일함 범위는 `"mailbox_ids" @> $n` 그대로 둔다 — + * 같은 GIN 이 메일함까지 맞춰 맞은 줄만 읽는다(`= ANY` 로 적으면 그 직원의 그 묶음 줄을 모두 읽고 메일함을 줄마다 거른다). + * 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms). + * 메일함만 담은 GIN 이 있던 때는 계획기가 그것만으로 메일함의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-06 실사용 시험 — 1만 통 메일함 안 1.1초). + * 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다 + * - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다 + * 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는 + * 받은 차례로 바로 읽는다. + */ + +const TABLE = `"${SEARCH_MESSAGES_TABLE}"`; +/** 받은 시각 최신부터 (같은 시각은 id 로) — 색인 IDX_mail_search_messages_received 의 거꾸로 */ +const NEWEST_FIRST = '"received_at" DESC, "email_id" DESC'; +const OLDEST_FIRST = '"received_at" ASC, "email_id" ASC'; +/** 확인에 쓰는 접기 칸 — 안쪽 쿼리가 바깥으로 넘긴다 */ +const FOLD_COLUMNS = + '"fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body"'; + +/** 이어 찾을 자리 — 이 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 */ +export interface SearchPosition { + readonly receivedAt: Date; + readonly emailId: string; +} + +export interface SqlQuery { + readonly sql: string; + readonly params: unknown[]; +} + +/** 매개변수를 차례로 쌓는다 */ +class Params { + readonly values: unknown[] = []; + + add(value: unknown): string { + this.values.push(value); + return `$${this.values.length}`; + } +} + +/** + * 직원을 거르는 길 — `gin`: 찾기 GIN 의 식(lower)으로 묶음 · 메일함과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키 — + * 메일함 조건은 줄을 읽은 뒤 거른다. 이 길의 쿼리는 bitmap 을 끈다) + * 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다 + */ +type UserMatch = 'gin' | 'btree'; + +/** 값싼 조건 — 직원 · 범위 · 기간 · 첨부 · 빼는 키워드 · 이어 찾을 자리 (`gin` 이면 묶음도) */ +function cheapConditions( + params: Params, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + userMatch: UserMatch, +): string { + const where = [ + userMatch === 'gin' + ? `lower("user_email") = ${params.add(user)}` + : `"user_email" = ${params.add(user)}`, + ]; + if (scope.mailboxId !== null) { + where.push(`"mailbox_ids" @> ${params.add([scope.mailboxId])}::text[]`); + } + if (scope.excludedMailboxIds.length > 0) { + // 빼는 메일함에만 든 메일을 뺀다 — 다른 메일함에도 있으면 찾는다 (엔진의 inMailboxOtherThan 과 같다) + where.push( + `NOT ("mailbox_ids" <@ ${params.add([...scope.excludedMailboxIds])}::text[])`, + ); + } + if (scope.excludedKeywords.length > 0) { + where.push( + `NOT ("keywords" && ${params.add([...scope.excludedKeywords])}::text[])`, + ); + } + if (plan.after !== null) { + where.push(`"received_at" >= ${params.add(plan.after)}::timestamptz`); + } + if (plan.before !== null) { + where.push(`"received_at" < ${params.add(plan.before)}::timestamptz`); + } + if (plan.hasAttachment) where.push('"has_attachment"'); + if (after !== null) { + where.push( + `("received_at", "email_id") < (${params.add(after.receivedAt)}::timestamptz, ${params.add(after.emailId)})`, + ); + } + if (userMatch === 'gin') { + where.push(`"grams" @> ${params.add([...plan.grams])}::text[]`); + } + return where.join(' AND '); +} + +/** 낱말 확인 — 낱말마다 그 칸들 가운데 하나에 들어 있어야 한다 (모두 AND, 확인할 낱말이 없으면 늘 참) */ +function checkConditions(params: Params, plan: SearchPlan): string { + if (plan.checks.length === 0) return 'TRUE'; + return plan.checks + .map((check) => { + const term = params.add(check.term); + return `(${check.fields.map((field) => `strpos("${field}", ${term}) > 0`).join(' OR ')})`; + }) + .join(' AND '); +} + +/** 후보 — 주소 + 묶음 + 메일함(GIN 하나)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */ +export function candidatesSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + limit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, after, 'gin'); + return { + sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(limit)}`, + params: params.values, + }; +} + +/** 확인 — 후보 id 가운데 낱말이 모두 맞은 줄의 id (기본 키로 그 줄만 읽는다) */ +export function checkHitsSql( + user: string, + plan: SearchPlan, + ids: readonly string[], +): SqlQuery { + const params = new Params(); + const where = `"user_email" = ${params.add(user)} AND "email_id" = ANY(${params.add([...ids])}::text[])`; + const checks = checkConditions(params, plan); + return { + sql: `SELECT "email_id" FROM ${TABLE} WHERE ${where} AND ${checks}`, + params: params.values, + }; +} + +/** 받은 차례 쿼리의 안쪽 — 값싼 조건을 지난 줄을 최신부터 `scanLimit` 까지 (묶음은 보지 않는다) */ +function scanSubquery( + params: Params, + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, + columns: string, +): string { + const where = cheapConditions(params, user, scope, plan, after, 'btree'); + return `SELECT ${columns} FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(scanLimit)}`; +} + +/** + * 묶음이 없는 찾기(한 글자 낱말뿐) — 받은 차례로 `scanLimit` 줄까지 확인하며 맞은 줄을 `pageLimit` 개까지 (차면 멈춘다). + * 묶음 배열을 줄마다 풀지 않게 묶음이 있는 계획에는 쓰지 않는다(candidatesSql) + */ +export function keysetHitsSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, + pageLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + after, + scanLimit, + `"email_id", "received_at", ${FOLD_COLUMNS}`, + ); + const checks = checkConditions(params, plan); + return { + sql: `SELECT "email_id", "received_at" FROM (${scan}) AS "scan" WHERE ${checks} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`, + params: params.values, + }; +} + +/** 받은 차례로 훑은 창의 끝 — 그 창의 마지막 줄과 줄 수 (확인하지 않는다 — 다 훑었는지 · 이어 찾을 자리) */ +export function keysetFrontierSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + scanLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + after, + scanLimit, + '"email_id", "received_at"', + ); + return { + sql: `SELECT "email_id", "received_at", count(*) OVER ()::int AS "scanned" FROM (${scan}) AS "scan" ORDER BY ${OLDEST_FIRST} LIMIT 1`, + params: params.values, + }; +} + +/** 받은 차례로 훑은 창 안의 맞은 줄 수 — `countLimit` 까지 */ +export function keysetCountSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + scanLimit: number, + countLimit: number, +): SqlQuery { + const params = new Params(); + const scan = scanSubquery( + params, + user, + scope, + plan, + null, + scanLimit, + `"email_id", "received_at", ${FOLD_COLUMNS}`, + ); + const checks = checkConditions(params, plan); + return { + sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM (${scan}) AS "scan" WHERE ${checks} LIMIT ${params.add(countLimit)}) AS "hits"`, + params: params.values, + }; +} + +/** 낱말 확인이 없을 때(기간 · 첨부 · 메일함만) — 받은 차례로 `pageLimit` 개 */ +export function plainPageSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + after: SearchPosition | null, + pageLimit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, after, 'btree'); + return { + sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`, + params: params.values, + }; +} + +/** 낱말 확인이 없을 때의 수 — `countLimit` 까지 */ +export function plainCountSql( + user: string, + scope: SearchScope, + plan: SearchPlan, + countLimit: number, +): SqlQuery { + const params = new Params(); + const where = cheapConditions(params, user, scope, plan, null, 'btree'); + return { + sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM ${TABLE} WHERE ${where} LIMIT ${params.add(countLimit)}) AS "matches"`, + params: params.values, + }; +} + +/** + * 강조에 쓰는 글 — 찾은 쪽의 줄만. 본문 밖의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름)은 미리 보기를 고를 때 + * 본문의 인용 안에서만 맞은 말을 그 칸에서도 찾았는지 본다(search-highlight.ts) + */ +export function highlightTextsSql( + user: string, + ids: readonly string[], +): SqlQuery { + return { + sql: `SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`, + params: [user, [...ids]], + }; +} diff --git a/backend/src/modules/mail-search/thread-keys.spec.ts b/backend/src/modules/mail-search/thread-keys.spec.ts new file mode 100644 index 0000000..40988f2 --- /dev/null +++ b/backend/src/modules/mail-search/thread-keys.spec.ts @@ -0,0 +1,59 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { mergeThreadKeys } from './thread-keys'; + +describe('대화 키 합치기 (mergeThreadKeys — 14단계 설계 B4)', () => { + it('이어진 두 키는 작은 키로 — 바뀌는 쪽만 돌려준다', () => { + expect([...mergeThreadKeys([['m9', 'm2']])]).toEqual([['m9', 'm2']]); + expect([...mergeThreadKeys([['m2', 'm9']])]).toEqual([['m9', 'm2']]); + }); + + it('잇닿은 쌍은 하나로 — 대화 전체가 가장 작은 키 (늦게 온 원본이 두 답장을 잇는 꼴)', () => { + const renames = mergeThreadKeys([ + ['reply-b', 'orig-z'], + ['reply-a', 'orig-z'], + ['reply-c', 'reply-b'], + ]); + // 키는 글자 차례로 견준다 — 'orig-z' < 'reply-a' + expect(Object.fromEntries(renames)).toEqual({ + 'reply-a': 'orig-z', + 'reply-b': 'orig-z', + 'reply-c': 'orig-z', + }); + }); + + it('따로인 대화는 따로 — 쌍의 차례와 상관없이 같은 결과', () => { + const links: [string, string][] = [ + ['b', 'a'], + ['y', 'x'], + ['c', 'b'], + ['z', 'y'], + ]; + const expected = { b: 'a', c: 'a', y: 'x', z: 'x' }; + expect(Object.fromEntries(mergeThreadKeys(links))).toEqual(expected); + expect(Object.fromEntries(mergeThreadKeys([...links].reverse()))).toEqual( + expected, + ); + }); + + it('같은 키끼리 · 빈 쌍은 바꿀 것이 없다', () => { + expect(mergeThreadKeys([])).toEqual(new Map()); + expect(mergeThreadKeys([['a', 'a']])).toEqual(new Map()); + }); + + it('긴 사슬도 쌍 수에 비례한다 (CPU 시간)', () => { + const links = Array.from( + { length: 50_000 }, + (_, index) => + [ + `k${String(index + 1).padStart(6, '0')}`, + `k${String(index).padStart(6, '0')}`, + ] as const, + ); + const cpuMs = startCpuTimer(); + const renames = mergeThreadKeys(links); + // 지금 수십 ms — 부하를 견디게 10배 넘게 + expect(cpuMs()).toBeLessThan(1_000); + expect(renames.size).toBe(50_000); + expect(new Set(renames.values())).toEqual(new Set(['k000000'])); + }); +}); diff --git a/backend/src/modules/mail-search/thread-keys.ts b/backend/src/modules/mail-search/thread-keys.ts new file mode 100644 index 0000000..28eab96 --- /dev/null +++ b/backend/src/modules/mail-search/thread-keys.ts @@ -0,0 +1,46 @@ +/** + * 대화 키 합치기 (순수 함수 — 14단계 설계 B4). + * 색인 줄마다 대화 키(thread_key)가 있다 — 처음에는 그 메일의 id 다. 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 + * 두 키의 줄을 모두 **가장 작은 키**로 바꾼다. 이미 합친 대화는 한 키를 나눠 쓰므로, 새로 들어온 메일과 바로 이어진 메일의 키만 합치면 + * 대화 전체가 한 키가 된다(늦게 온 원본이 둘로 갈려 있던 답장들을 잇는 것도 같다). + * + * `links` 는 [키, 키] 쌍들 — 이어진 두 메일의 지금 키. 돌려주는 것은 바꿀 키 → 새 키(가장 작은 키)이다. 바꿀 것이 없으면 빈 Map. + * 쌍 수에 거의 비례한다(경로 줄이기 · 크기로 잇기). + */ +export function mergeThreadKeys( + links: readonly (readonly [string, string])[], +): Map { + const parent = new Map(); + const find = (key: string): string => { + let root = key; + for (;;) { + const up = parent.get(root); + if (up === undefined || up === root) break; + root = up; + } + // 지나온 키를 뿌리에 바로 잇는다 (다음 찾기가 짧게) + let node = key; + while (node !== root) { + const up = parent.get(node) as string; + parent.set(node, root); + node = up; + } + return root; + }; + for (const [a, b] of links) { + if (!parent.has(a)) parent.set(a, a); + if (!parent.has(b)) parent.set(b, b); + const rootA = find(a); + const rootB = find(b); + if (rootA === rootB) continue; + // 가장 작은 키가 뿌리가 되게 — 합친 대화의 키는 늘 그 대화의 가장 작은 키다 + if (rootA < rootB) parent.set(rootB, rootA); + else parent.set(rootA, rootB); + } + const renames = new Map(); + for (const key of parent.keys()) { + const root = find(key); + if (root !== key) renames.set(key, root); + } + return renames; +} diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts index e52d968..7770471 100644 --- a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts @@ -46,4 +46,15 @@ describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', ( 'whitelistValidation', ]); }); + + it('대화로 묶어 보기(14단계)는 참 · 거짓만 — 글로 쓴 값 · 숫자 · null 은 형식 오류', async () => { + for (const groupByConversation of [true, false]) { + expect(await constraintsOf({ groupByConversation })).toEqual([]); + } + for (const groupByConversation of ['true', 1, 0, null, 'on']) { + expect(await constraintsOf({ groupByConversation })).toEqual([ + 'isBoolean', + ]); + } + }); }); diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts index 73f5850..3ca5bda 100644 --- a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, ValidateIf } from 'class-validator'; +import { IsBoolean, IsIn, ValidateIf } from 'class-validator'; import { EXTERNAL_IMAGE_MODES, @@ -16,6 +16,8 @@ const whenSent = (_object: object, value: unknown) => value !== undefined; const UNDO_DESCRIPTION = `보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` + '예약 발송에는 더하지 않는다'; +const GROUP_DESCRIPTION = + '목록을 대화로 묶어 보기(14단계) — true 면 화면이 메일함 목록을 GET /api/conversations 로 받는다. 처음 false. 검색 결과는 묶지 않는다'; const IMAGES_DESCRIPTION = '외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' + '스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)'; @@ -35,6 +37,9 @@ export class MailUserSettingsDto implements MailUserSettings { example: 'ask', }) externalImages!: ExternalImageMode; + + @ApiProperty({ description: GROUP_DESCRIPTION, example: false }) + groupByConversation!: boolean; } /** @@ -63,4 +68,9 @@ export class MailUserSettingsPatchDto implements MailUserSettingsPatch { message: '외부 이미지 설정을 다시 골라 주세요.', }) externalImages?: ExternalImageMode; + + @ApiPropertyOptional({ description: GROUP_DESCRIPTION, example: true }) + @ValidateIf(whenSent) + @IsBoolean({ message: '대화로 묶어 보기를 다시 골라 주세요.' }) + groupByConversation?: boolean; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts index 98dc12c..2eb1377 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts @@ -33,7 +33,7 @@ export class MailUserSettingsController { @ApiOperation({ summary: '보내기 · 읽기 설정', description: - '저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.', + '저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask", groupByConversation: false } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다. groupByConversation 은 화면만 본다(14단계).', }) @ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' }) get(@CurrentUser() user: AuthenticatedUser) { @@ -49,7 +49,7 @@ export class MailUserSettingsController { @ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' }) @ApiErrorResponse( 'VAL_001', - '빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드', + '빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, 대화로 묶기가 true · false 아님, null) · 모르는 필드', ) update( @CurrentUser() user: AuthenticatedUser, @@ -58,6 +58,7 @@ export class MailUserSettingsController { return this.settings.update(user.email, { undoSendSeconds: dto.undoSendSeconds, externalImages: dto.externalImages, + groupByConversation: dto.groupByConversation, }); } } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts index 291139d..f57b520 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.entity.ts @@ -32,6 +32,13 @@ export class MailUserSettingsEntity { }) externalImages!: string; + /** + * 목록을 대화로 묶어 보기 (14단계 — 기본 끔). 열은 14단계 1차의 마이그레이션(AddMailSearchIndex)이 만들고, + * 읽고 바꾸는 API(GET · PATCH /api/mail-settings 의 groupByConversation)는 3차에 더한다 + */ + @Column({ name: 'group_by_conversation', type: 'boolean', default: false }) + groupByConversation!: boolean; + @Column({ name: 'updated_at', type: 'timestamptz' }) updatedAt!: Date; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts index b0e40ec..986f46c 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts @@ -12,6 +12,7 @@ import type { const COLUMNS: Readonly> = { undoSendSeconds: 'undo_send_seconds', externalImages: 'external_images', + groupByConversation: 'group_by_conversation', }; /** RETURNING 한 줄 (열 이름이 키다) */ @@ -19,6 +20,7 @@ interface ReturnedRow { readonly user_email: string; readonly undo_send_seconds: number; readonly external_images: string; + readonly group_by_conversation: boolean; readonly updated_at: Date; } @@ -58,6 +60,8 @@ export class MailUserSettingsRepository { userEmail, undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds, externalImages: patch.externalImages ?? base.externalImages, + groupByConversation: + patch.groupByConversation ?? base.groupByConversation, updatedAt: now, }) .orUpdate([...changed, 'updated_at'], ['user_email']) @@ -66,6 +70,7 @@ export class MailUserSettingsRepository { 'userEmail', 'undoSendSeconds', 'externalImages', + 'groupByConversation', 'updatedAt', ]) .execute(); @@ -74,6 +79,7 @@ export class MailUserSettingsRepository { saved.userEmail = row?.user_email ?? userEmail; saved.undoSendSeconds = Number(row?.undo_send_seconds); saved.externalImages = row?.external_images ?? ''; + saved.groupByConversation = row?.group_by_conversation === true; saved.updatedAt = row?.updated_at ?? now; return saved; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts index 463a047..65f7dd7 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts @@ -20,11 +20,13 @@ function row( userEmail: string, undoSendSeconds: number, externalImages: string, + groupByConversation = false, ): MailUserSettingsEntity { const entity = new MailUserSettingsEntity(); entity.userEmail = userEmail; entity.undoSendSeconds = undoSendSeconds; entity.externalImages = externalImages; + entity.groupByConversation = groupByConversation; entity.updatedAt = new Date('2026-10-29T00:00:00.000Z'); return entity; } @@ -48,6 +50,9 @@ function fakeRepository() { current?.undoSendSeconds ?? base.undoSendSeconds, patch.externalImages ?? current?.externalImages ?? base.externalImages, + patch.groupByConversation ?? + current?.groupByConversation ?? + base.groupByConversation, ); rows.set(userEmail, saved); return Promise.resolve(saved); @@ -94,10 +99,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await expect(service.get(ARA)).resolves.toEqual({ undoSendSeconds: 10, externalImages: 'ask', + groupByConversation: false, }); expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({ undoSendSeconds: 10, externalImages: 'ask', + groupByConversation: false, }); expect(fake.upsert).not.toHaveBeenCalled(); }); @@ -111,10 +118,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await expect(service.get(ARA)).resolves.toEqual({ undoSendSeconds: 10, externalImages: 'always', + groupByConversation: false, }); await expect(service.get('b@arasystem.test')).resolves.toEqual({ undoSendSeconds: 30, externalImages: 'ask', + groupByConversation: false, }); }); @@ -141,7 +150,11 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await expect( service.update(ARA, { externalImages: 'always' }), - ).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' }); + ).resolves.toEqual({ + undoSendSeconds: 20, + externalImages: 'always', + groupByConversation: false, + }); expect(fake.upsert).toHaveBeenCalledWith( ARA, { externalImages: 'always' }, @@ -151,6 +164,7 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await expect(service.get(ARA)).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always', + groupByConversation: false, }); expect(fake.find).toHaveBeenCalledTimes(1); @@ -190,10 +204,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await expect(reading).resolves.toEqual({ undoSendSeconds: 30, externalImages: 'ask', + groupByConversation: false, }); await expect(service.get(ARA)).resolves.toEqual({ undoSendSeconds: 30, externalImages: 'ask', + groupByConversation: false, }); expect(fake.find).toHaveBeenCalledTimes(1); }); @@ -215,4 +231,41 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () await service.get('first@arasystem.test'); expect(fake.find).toHaveBeenCalledTimes(1); }); + + it('대화로 묶어 보기(14단계) — 처음 끔, 보낸 칸만 바꾸고 다른 칸은 그대로 · 로그에 켬/끔', async () => { + const fake = fakeRepository(); + fake.rows.set(ARA, row(ARA, 20, 'trusted')); + const service = new MailUserSettingsService(fake.repository); + + await expect(service.get(ARA)).resolves.toMatchObject({ + groupByConversation: false, + }); + await expect( + service.update(ARA, { groupByConversation: true }), + ).resolves.toEqual({ + undoSendSeconds: 20, + externalImages: 'trusted', + groupByConversation: true, + }); + expect(fake.upsert).toHaveBeenCalledWith( + ARA, + { groupByConversation: true }, + DEFAULT_MAIL_USER_SETTINGS, + expect.any(Date), + ); + const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n'); + expect(lines).toContain('대화로 묶기 켬'); + }); + + it('표의 대화로 묶기 값이 참 · 거짓이 아니면(손으로 고친 값) 처음 값(끔)으로 읽는다', async () => { + const fake = fakeRepository(); + const odd = row(ARA, 20, 'trusted'); + (odd as unknown as { groupByConversation: unknown }).groupByConversation = + 'yes'; + fake.rows.set(ARA, odd); + const service = new MailUserSettingsService(fake.repository); + await expect(service.get(ARA)).resolves.toMatchObject({ + groupByConversation: false, + }); + }); }); diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts index bd13302..76fb919 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts @@ -39,11 +39,16 @@ function toSettings(row: MailUserSettingsEntity | null): MailUserSettings { externalImages: isExternalImageMode(row.externalImages) ? row.externalImages : DEFAULT_MAIL_USER_SETTINGS.externalImages, + groupByConversation: + typeof row.groupByConversation === 'boolean' + ? row.groupByConversation + : DEFAULT_MAIL_USER_SETTINGS.groupByConversation, }; } /** - * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다. + * 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계 · 14단계의 대화로 묶기) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다. + * 대화로 묶기는 화면만 본다(서버는 GET /api/conversations 를 부르면 묶는다 — 설정과 상관없이). * - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다 * - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다 * @@ -83,7 +88,8 @@ export class MailUserSettingsService { ): Promise { if ( patch.undoSendSeconds === undefined && - patch.externalImages === undefined + patch.externalImages === undefined && + patch.groupByConversation === undefined ) { throw new BusinessException('VAL_001', { context: { reason: 'empty-mail-settings-patch' }, @@ -99,7 +105,7 @@ export class MailUserSettingsService { ); this.remember(cacheKey(userEmail), saved); this.logger.log( - `${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`, + `${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}, 대화로 묶기 ${saved.groupByConversation ? '켬' : '끔'}`, ); return saved; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts index 90e6fde..aa328c5 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts @@ -17,15 +17,18 @@ export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number]; export interface MailUserSettings { readonly undoSendSeconds: UndoSendSeconds; readonly externalImages: ExternalImageMode; + /** 목록을 대화로 묶어 보기 (14단계 — 처음 끔: 전환 직후 보던 목록을 바꾸지 않는다) */ + readonly groupByConversation: boolean; } /** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */ export type MailUserSettingsPatch = Partial; -/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */ +/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기 · 대화로 묶지 않음) */ export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({ undoSendSeconds: 10, externalImages: 'ask', + groupByConversation: false, }); /** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */ diff --git a/backend/src/modules/mail/conversation.controller.ts b/backend/src/modules/mail/conversation.controller.ts new file mode 100644 index 0000000..5070aa9 --- /dev/null +++ b/backend/src/modules/mail/conversation.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiSessionErrors, + 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 { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants'; +import { MailConversationService } from './conversation.service'; +import { + ConversationListQueryDto, + ConversationQueryDto, +} from './dto/conversation-query.dto'; +import { + ConversationListPageDto, + ConversationPageDto, +} from './dto/mail-response.dto'; +import { MESSAGES_ROUTE } from './mail.constants'; +import { MailIdPipe } from './mail-id.pipe'; + +/** + * 대화 (14단계 — 계약 2절 "같은 대화" · "대화로 묶어 보기"). 같은 대화는 메일 자원 아래(`/api/messages/{id}/conversation`), + * 묶은 목록은 따로(`/api/conversations`) — 검색은 묶지 않는다 + */ +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors( + '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', +) +@Controller() +export class ConversationController { + constructor(private readonly conversations: MailConversationService) {} + + @Get(`${MESSAGES_ROUTE}/:id/conversation`) + @ApiOperation({ + summary: '같은 대화 — 그 메일과 답장 · 전달로 이어진 메일 (오래된 차례)', + description: + '보낸편지함을 포함한 모든 메일함에서 — 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나다. ' + + '기준(basis): 그 직원의 색인이 준비됐으면 index — 답장 관계(References · In-Reply-To)가 있고 말머리(Re · Fw · 회신 · 답장 · 전달 · [꼬리표] — 여러 겹)를 뗀 제목이 같은 메일, ' + + '준비 중이면 engine — 엔진의 스레드. 쪽을 넘기는 중에는 커서의 기준대로 이어 간다. ' + + `limit 은 처음 값이자 최대가 ${CONVERSATION_PAGE_LIMIT} 다. 항목은 목록과 같은 요약(기다리는 발송 pendingSend 포함)이다.`, + }) + @ApiStandardResponse(ConversationPageDto, { description: '같은 대화' }) + @ApiErrorResponse( + 'VAL_001', + `limit 범위(1~${CONVERSATION_PAGE_LIMIT}) 밖 · 다른 대화의 커서 · 모르는 쿼리`, + ) + @ApiErrorResponse('RES_001', '없는 메일 (남의 메일 포함)') + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + conversation( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + @Query() query: ConversationQueryDto, + ) { + return this.conversations.conversation(user, id, query); + } + + @Get('conversations') + @ApiOperation({ + summary: + '대화로 묶은 목록 — 그 메일함의 대화 하나가 한 줄 (마지막 받은 시각 최신부터)', + description: + '그 메일함 안의 메일을 대화로 묶는다 — 줄마다 그 메일함 안 마지막 메일(latest) · 보낸 사람들 · 대화 전체의 통 수 · 그 메일함 안 메일 id(여러 통 처리에) · 읽음 · 중요 · 첨부. ' + + '기준(basis)은 같은 대화와 같다(색인 준비 전에는 엔진의 스레드 — collapseThreads). 발송 중인 내부 초안은 빠지고, 붙잡힌 메일(보내기 취소 · 예약 — pendingSend)은 목록처럼 보인다. ' + + '검색은 묶지 않는다(GET /api/messages).', + }) + @ApiStandardResponse(ConversationListPageDto, { + description: '대화로 묶은 목록', + }) + @ApiErrorResponse( + 'VAL_001', + 'mailboxId 없음 · 형식이 틀림 · limit 범위 밖 · 다른 메일함의 커서 · 모르는 쿼리', + ) + @ApiErrorResponse('RES_001', '없는 메일함') + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: ConversationListQueryDto, + ) { + return this.conversations.list(user, query); + } +} diff --git a/backend/src/modules/mail/conversation.service.spec.ts b/backend/src/modules/mail/conversation.service.spec.ts new file mode 100644 index 0000000..6d30600 --- /dev/null +++ b/backend/src/modules/mail/conversation.service.spec.ts @@ -0,0 +1,342 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeConversationCursor } from '@/modules/mail-search/conversation-cursor'; +import type { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service'; +import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; +import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; +import { MailConversationService } from './conversation.service'; +import { encodeCursor } from './jmap/jmap-cursor'; +import type { MailProvider } from './mail.provider'; +import type { MessageSummary } from './mail.types'; + +const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; +const SCOPE = { + mailboxId: null, + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], +}; + +function summary(id: string, mailboxIds = ['a']): MessageSummary { + return { + id, + threadId: 't1', + mailboxIds, + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [], + subject: `제목 ${id}`, + preview: '', + receivedAt: '2026-11-02T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 1, + pendingSend: null, + }; +} + +const forged = (payload: Record) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + +function setup(ready = true) { + const summaries = (ids: readonly string[]) => + ids + .filter((id) => id !== 'gone') + .map((id) => summary(id, id === 'moved' ? ['e'] : ['a'])); + const provider = { + conversationScope: jest.fn().mockResolvedValue(SCOPE), + getSummaries: jest.fn((_user: string, ids: readonly string[]) => + Promise.resolve(summaries(ids)), + ), + getSummariesWithHeld: jest.fn((_user: string, ids: readonly string[]) => + Promise.resolve({ + summaries: summaries(ids), + heldIds: new Set(['m1', 'elsewhere']), + }), + ), + getConversation: jest.fn().mockResolvedValue({ + items: [summary('e1')], + hasNext: false, + nextCursor: null, + total: 1, + }), + listConversations: jest.fn().mockResolvedValue({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }), + }; + const searchIndex = { isSearchReady: jest.fn().mockResolvedValue(ready) }; + const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) }; + const conversations = { + conversation: jest.fn().mockResolvedValue({ + ids: ['m1', 'm2', 'gone'], + hasNext: true, + nextCursor: 'next', + total: 3, + }), + mailboxConversations: jest.fn().mockResolvedValue({ + items: [ + { + key: 'k1', + latestId: 'm2', + mailboxMessageIds: ['m2', 'm1'], + messageCount: 4, + participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + isRead: false, + isFlagged: true, + hasAttachment: false, + }, + { + key: 'k2', + latestId: 'moved', + mailboxMessageIds: ['moved'], + messageCount: 1, + participants: [], + isRead: true, + isFlagged: false, + hasAttachment: false, + }, + ], + hasNext: false, + nextCursor: null, + total: 2, + }), + }; + const service = new MailConversationService( + provider as unknown as MailProvider, + searchIndex as unknown as MailSearchIndexService, + searchSync as unknown as MailSearchSyncService, + conversations as unknown as MailConversationQueryService, + ); + return { service, provider, searchIndex, searchSync, conversations }; +} + +describe('MailConversationService (같은 대화 · 묶은 목록 — 14단계 C3 · C4)', () => { + let warnSpy: jest.SpyInstance; + beforeEach(() => { + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + afterEach(() => warnSpy.mockRestore()); + + it('색인이 준비됐으면 색인 — 바로 전 맞추기 → 범위 → 색인의 쪽(그 메일의 키와 한 문장) → 요약은 엔진에서(없는 메일은 뺀다), basis index', async () => { + const { service, provider, searchSync, conversations } = setup(); + + const result = await service.conversation(user, 'm2', { limit: 100 }); + + expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email); + expect(provider.conversationScope).toHaveBeenCalledWith(user.email); + expect(conversations.conversation).toHaveBeenCalledWith( + user.email, + 'm2', + SCOPE, + { limit: 100, after: null }, + ); + expect(result.items.map((item) => item.id)).toEqual(['m1', 'm2']); + expect(result.pageInfo).toEqual({ + hasNext: true, + nextCursor: 'next', + total: 3, + }); + expect(result.basis).toBe('index'); + expect(provider.getConversation).not.toHaveBeenCalled(); + }); + + it('색인이 준비 중 · 그 메일이 색인에 아직 없으면 엔진의 스레드 — basis engine', async () => { + const notReady = setup(false); + const engine = await notReady.service.conversation(user, 'm2', { + limit: 100, + }); + expect(engine.basis).toBe('engine'); + expect(notReady.provider.getConversation).toHaveBeenCalledWith( + user.email, + 'm2', + { limit: 100, cursor: undefined }, + ); + + const notIndexed = setup(); + notIndexed.conversations.conversation.mockResolvedValue(null); + const fallback = await notIndexed.service.conversation(user, 'new', { + limit: 100, + }); + expect(fallback.basis).toBe('engine'); + expect(notIndexed.provider.getSummaries).not.toHaveBeenCalled(); + }); + + it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 입구에서 읽어 자리로 넘기고, 그 메일이 색인에서 사라졌으면 404 RES_001', async () => { + const at = new Date('2026-11-02T01:00:00.000Z'); + const indexCursor = encodeConversationCursor( + 'thread', + { at, key: 'm1' }, + 'm2', + ); + const notReady = setup(false); + await notReady.service.conversation(user, 'm2', { + limit: 100, + cursor: indexCursor, + }); + expect(notReady.conversations.conversation).toHaveBeenCalledWith( + user.email, + 'm2', + SCOPE, + { limit: 100, after: { at, key: 'm1' } }, + ); + expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled(); + + const ready = setup(); + await ready.service.conversation(user, 'm2', { + limit: 100, + cursor: encodeCursor( + { anchor: 'm1', position: 1 }, + { conditions: ['conversation', 'm2'] }, + ), + }); + expect(ready.provider.getConversation).toHaveBeenCalled(); + expect(ready.conversations.conversation).not.toHaveBeenCalled(); + + const vanished = setup(); + vanished.conversations.conversation.mockResolvedValue(null); + await expect( + vanished.service.conversation(user, 'm2', { + limit: 100, + cursor: indexCursor, + }), + ).rejects.toMatchObject({ code: 'RES_001' }); + }); + + it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음 · 다른 메일의 커서)는 맞추기 · 엔진 · DB 에 묻기 전에 400 VAL_001 — 두 경로 모두 (리뷰 L1)', async () => { + const at = new Date('2026-11-02T01:00:00.000Z'); + const threadCursors = [ + forged({ m: 'ti' }), + forged({ v: 1, m: 'ti', t: 'x', k: 'm1', s: 'zz' }), + encodeConversationCursor('thread', { at, key: 'm1' }, 'other'), + ]; + for (const cursor of threadCursors) { + const { service, provider, searchSync, conversations } = setup(); + await expect( + service.conversation(user, 'm2', { limit: 100, cursor }), + ).rejects.toMatchObject({ code: 'VAL_001' }); + expect(searchSync.syncBeforeRead).not.toHaveBeenCalled(); + expect(provider.conversationScope).not.toHaveBeenCalled(); + expect(conversations.conversation).not.toHaveBeenCalled(); + } + + const listCursors = [ + forged({ m: 'li' }), + forged({ v: 1, m: 'li', t: -5, k: 'k1', s: 'zz' }), + encodeConversationCursor('list', { at, key: 'k1' }, 'sent'), + ]; + for (const cursor of listCursors) { + const { service, provider, searchSync, conversations } = setup(); + await expect( + service.list(user, { mailboxId: 'a', limit: 20, cursor }), + ).rejects.toMatchObject({ code: 'VAL_001' }); + expect(searchSync.syncBeforeRead).not.toHaveBeenCalled(); + expect(provider.conversationScope).not.toHaveBeenCalled(); + expect(conversations.mailboxConversations).not.toHaveBeenCalled(); + } + }); + + it('첫 쪽의 색인 읽기가 DB 장애면 엔진으로 — 코드가 있는 실패(RES_001 · MAIL_001)는 그대로', async () => { + const broken = setup(); + broken.conversations.conversation.mockRejectedValue(new Error('db down')); + const result = await broken.service.conversation(user, 'm2', { + limit: 100, + }); + expect(result.basis).toBe('engine'); + expect(warnSpy).toHaveBeenCalledTimes(1); + const [line] = warnSpy.mock.calls[0] as [string]; + expect(line).toContain('[SYS_001]'); + expect(line).not.toContain(user.email); + + const coded = setup(); + coded.provider.conversationScope.mockRejectedValue( + new BusinessException('MAIL_001'), + ); + await expect( + coded.service.conversation(user, 'm2', { limit: 100 }), + ).rejects.toMatchObject({ code: 'MAIL_001' }); + expect(coded.provider.getConversation).not.toHaveBeenCalled(); + }); + + it('묶은 목록 — 색인이면 그 메일함의 범위(있는지 본다) → 색인의 쪽 → 마지막 메일의 요약과 붙잡힌 메일을 한 번에, 줄의 pendingSendIds · 그 메일함을 떠난 대화는 뺀다 (리뷰 L6)', async () => { + const { service, provider, conversations } = setup(); + + const result = await service.list(user, { mailboxId: 'a', limit: 20 }); + + expect(provider.conversationScope).toHaveBeenCalledWith(user.email, 'a'); + expect(conversations.mailboxConversations).toHaveBeenCalledWith( + user.email, + 'a', + SCOPE, + { limit: 20, after: null }, + ); + expect(provider.getSummariesWithHeld).toHaveBeenCalledTimes(1); + expect(provider.getSummariesWithHeld).toHaveBeenCalledWith(user.email, [ + 'm2', + 'moved', + ]); + expect(provider.getSummaries).not.toHaveBeenCalled(); + expect(result.basis).toBe('index'); + expect(result.items).toEqual([ + { + id: 'k1', + latest: summary('m2'), + participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + messageCount: 4, + messageIds: ['m2', 'm1'], + pendingSendIds: ['m1'], + isRead: false, + isFlagged: true, + hasAttachment: false, + }, + ]); + expect(result.pageInfo.total).toBe(2); + + // 빈 쪽이면 엔진에 요약 · 제출을 묻지 않는다 + const empty = setup(); + empty.conversations.mailboxConversations.mockResolvedValue({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + const none = await empty.service.list(user, { mailboxId: 'a', limit: 20 }); + expect(none.items).toEqual([]); + expect(empty.provider.getSummariesWithHeld).not.toHaveBeenCalled(); + }); + + it('묶은 목록 — 준비 중이면 엔진(collapseThreads), 커서의 방식은 이어 간다', async () => { + const notReady = setup(false); + const engine = await notReady.service.list(user, { + mailboxId: 'a', + limit: 20, + }); + expect(engine.basis).toBe('engine'); + expect(notReady.provider.listConversations).toHaveBeenCalledWith( + user.email, + { mailboxId: 'a', limit: 20, cursor: undefined }, + ); + + const at = new Date('2026-11-02T01:00:00.000Z'); + const indexCursor = encodeConversationCursor( + 'list', + { at, key: 'k9' }, + 'a', + ); + const resumed = setup(false); + const next = await resumed.service.list(user, { + mailboxId: 'a', + limit: 20, + cursor: indexCursor, + }); + expect(next.basis).toBe('index'); + expect(resumed.conversations.mailboxConversations).toHaveBeenCalledWith( + user.email, + 'a', + SCOPE, + { limit: 20, after: { at, key: 'k9' } }, + ); + }); +}); diff --git a/backend/src/modules/mail/conversation.service.ts b/backend/src/modules/mail/conversation.service.ts new file mode 100644 index 0000000..d899a96 --- /dev/null +++ b/backend/src/modules/mail/conversation.service.ts @@ -0,0 +1,249 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + conversationCursorMethod, + decodeConversationCursor, + type ConversationPosition, +} from '@/modules/mail-search/conversation-cursor'; +import { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service'; +import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; +import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; +import type { ConversationBasis } from '@/modules/mail-search/mail-search.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import type { + ConversationListQueryDto, + ConversationQueryDto, +} from './dto/conversation-query.dto'; +import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; +import type { ConversationSummary, MessageSummary } from './mail.types'; + +/** 대화 응답 — 목록 모양에 기준(basis)을 더한다 */ +export type ConversationResult = PaginatedResult & { + readonly basis: ConversationBasis; +}; + +function errorKind(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** + * 대화 (14단계 설계 C3 · C4 — 디자인 README "같은 대화" · "대화로 묶어 보기"). + * - 기준: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 준비가 끝나거나 다시 채우기에 들어가도 처음 방식대로), 없으면 그 직원의 색인이 준비됐으면(ready · 지금 판) + * 우리 색인(대화 키 — 답장 관계 + 말머리를 뗀 제목), 아니면 엔진의 스레드. 응답에 기준(basis)을 싣는다. + * 색인 커서는 입구에서 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 맞추기 · 엔진 · DB 에 묻기 전에 VAL_001 (3차 리뷰 L1) + * - 색인 방식: 바로 전 맞추기(1.5초 안) → 엔진에 범위(휴지통 · 스팸메일함 · 내부 초안 키워드 — 메일함이 있는지) → 색인 → 보일 메일의 요약은 엔진에서(목록과 같은 모양 · 기다리는 발송) + * 엔진에 없는 메일(그사이 지움)과 그 메일함을 떠난 마지막 메일의 대화는 뺀다. 첫 쪽의 색인 읽기가 DB 장애면 엔진 방식으로 답한다 + * - 같은 대화: 그 메일이 색인에 없으면(방금 와 아직 넣지 않음) 엔진 방식 — 엔진에도 없으면 RES_001. 색인 커서로 이어 가는데 없으면 RES_001 + * - 묶은 목록의 붙잡힌 메일(pendingSendIds): 마지막 메일의 요약을 읽는 요청에서 계정의 기다리는 제출을 한 번 읽어 줄의 messageIds 와 맞춘다(getSummariesWithHeld) + */ +@Injectable() +export class MailConversationService { + private readonly logger = new Logger(MailConversationService.name); + + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + private readonly searchIndex: MailSearchIndexService, + private readonly searchSync: MailSearchSyncService, + private readonly conversations: MailConversationQueryService, + ) {} + + /** GET /api/messages/{id}/conversation */ + async conversation( + user: AuthenticatedUser, + emailId: string, + query: ConversationQueryDto, + ): Promise> { + if (query.cursor) { + if (conversationCursorMethod(query.cursor, 'thread') === 'index') { + const after = decodeConversationCursor('thread', query.cursor, emailId); + return this.conversationFromIndex(user, emailId, query.limit, after); + } + return this.conversationFromEngine(user, emailId, query); + } + if (!(await this.searchIndex.isSearchReady(user.email))) { + return this.conversationFromEngine(user, emailId, query); + } + try { + return await this.conversationFromIndex(user, emailId, query.limit, null); + } catch (error) { + if (error instanceof BusinessException) throw error; + this.warnFallback(user, error); + return this.conversationFromEngine(user, emailId, query); + } + } + + /** GET /api/conversations */ + async list( + user: AuthenticatedUser, + query: ConversationListQueryDto, + ): Promise> { + if (query.cursor) { + if (conversationCursorMethod(query.cursor, 'list') === 'index') { + const after = decodeConversationCursor( + 'list', + query.cursor, + query.mailboxId, + ); + return this.listFromIndex(user, query, after); + } + return this.listFromEngine(user, query); + } + if (!(await this.searchIndex.isSearchReady(user.email))) { + return this.listFromEngine(user, query); + } + try { + return await this.listFromIndex(user, query, null); + } catch (error) { + if (error instanceof BusinessException) throw error; + this.warnFallback(user, error); + return this.listFromEngine(user, query); + } + } + + private async conversationFromEngine( + user: AuthenticatedUser, + emailId: string, + query: ConversationQueryDto, + ): Promise> { + const page = await this.provider.getConversation(user.email, emailId, { + limit: query.limit, + cursor: query.cursor, + }); + return { + ...toPaginatedResult(page.items, { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total: page.total, + }), + basis: 'engine', + }; + } + + private async conversationFromIndex( + user: AuthenticatedUser, + emailId: string, + limit: number, + after: ConversationPosition | null, + ): Promise> { + await this.searchSync.syncBeforeRead(user.email); + const scope = await this.provider.conversationScope(user.email); + const page = await this.conversations.conversation( + user.email, + emailId, + scope, + { limit, after }, + ); + if (page === null) { + // 색인에 없다 — 첫 쪽이면 엔진의 스레드로(방금 온 메일), 이어 가는 쪽이면 그 메일이 그사이 사라졌다 + if (after !== null) { + throw new BusinessException('RES_001', { + context: { reason: 'conversation-message-not-indexed' }, + }); + } + return this.conversationFromEngine(user, emailId, { limit }); + } + const summaries = + page.ids.length > 0 + ? await this.provider.getSummaries(user.email, page.ids) + : []; + const byId = new Map(summaries.map((summary) => [summary.id, summary])); + const items = page.ids.flatMap((id) => { + const summary = byId.get(id); + return summary ? [summary] : []; + }); + return { + ...toPaginatedResult(items, { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total: page.total, + }), + basis: 'index', + }; + } + + private async listFromEngine( + user: AuthenticatedUser, + query: ConversationListQueryDto, + ): Promise> { + const page = await this.provider.listConversations(user.email, { + mailboxId: query.mailboxId, + limit: query.limit, + cursor: query.cursor, + }); + return { + ...toPaginatedResult(page.items, { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total: page.total, + }), + basis: 'engine', + }; + } + + private async listFromIndex( + user: AuthenticatedUser, + query: ConversationListQueryDto, + after: ConversationPosition | null, + ): Promise> { + await this.searchSync.syncBeforeRead(user.email); + const scope = await this.provider.conversationScope( + user.email, + query.mailboxId, + ); + const page = await this.conversations.mailboxConversations( + user.email, + query.mailboxId, + scope, + { limit: query.limit, after }, + ); + const latestIds = page.items.map((item) => item.latestId); + // 마지막 메일의 요약과 계정의 붙잡힌 메일 — 기다리는 제출은 이 쪽에서 한 번만 읽는다 (줄이 없으면 묻지 않는다) + const { summaries, heldIds } = + latestIds.length > 0 + ? await this.provider.getSummariesWithHeld(user.email, latestIds) + : { summaries: [], heldIds: new Set() }; + const byId = new Map(summaries.map((summary) => [summary.id, summary])); + const items = page.items.flatMap((item): ConversationSummary[] => { + const latest = byId.get(item.latestId); + // 엔진에 없거나(그사이 지움) 그 메일함을 떠났다 — 맞추기가 곧 고친다 + if (!latest || !latest.mailboxIds.includes(query.mailboxId)) return []; + const messageIds = [...item.mailboxMessageIds]; + return [ + { + id: item.key, + latest, + participants: [...item.participants], + messageCount: item.messageCount, + messageIds, + pendingSendIds: messageIds.filter((id) => heldIds.has(id)), + isRead: item.isRead, + isFlagged: item.isFlagged, + hasAttachment: item.hasAttachment, + }, + ]; + }); + return { + ...toPaginatedResult(items, { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total: page.total, + }), + basis: 'index', + }; + } + + private warnFallback(user: AuthenticatedUser, error: unknown): void { + this.logger.warn( + `[SYS_001]${traceTag()} 검색 색인으로 대화를 읽지 못해 엔진의 스레드로 답합니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`, + ); + } +} diff --git a/backend/src/modules/mail/dto/conversation-query.dto.ts b/backend/src/modules/mail/dto/conversation-query.dto.ts new file mode 100644 index 0000000..2c4fe17 --- /dev/null +++ b/backend/src/modules/mail/dto/conversation-query.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + Matches, + Max, + Min, +} from 'class-validator'; + +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants'; +import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants'; + +/** + * GET /api/messages/{id}/conversation — 같은 대화의 쪽 (14단계). limit 의 처음 값이자 최대가 CONVERSATION_PAGE_LIMIT 다(화면은 한 번에 보인다) + */ +export class ConversationQueryDto { + @ApiPropertyOptional({ + description: + '이전 응답의 nextCursor 값 — 그 메일의 대화에서만 쓴다(다르면 400 VAL_001). 첫 쪽은 생략한다', + example: 'eyJ2IjoxfQ', + }) + @IsOptional() + @IsString({ message: '커서 값의 형식이 올바르지 않습니다.' }) + cursor?: string; + + @ApiPropertyOptional({ + description: `한 번에 가져올 메일 수 (1~${CONVERSATION_PAGE_LIMIT})`, + default: CONVERSATION_PAGE_LIMIT, + example: CONVERSATION_PAGE_LIMIT, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: '조회 개수는 정수로 입력해 주세요.' }) + @Min(1, { message: '조회 개수는 1 이상이어야 합니다.' }) + @Max(CONVERSATION_PAGE_LIMIT, { + message: `조회 개수는 ${CONVERSATION_PAGE_LIMIT} 이하여야 합니다.`, + }) + limit: number = CONVERSATION_PAGE_LIMIT; +} + +/** + * GET /api/conversations — 대화로 묶은 목록 (14단계). 메일함이 꼭 있어야 한다(검색은 묶지 않는다) + */ +export class ConversationListQueryDto extends CursorPaginationQueryDto { + @ApiProperty({ + description: + '메일함 id — 그 메일함의 대화를 묶는다. 없는 메일함은 404 RES_001', + example: 'a', + }) + @Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' }) + mailboxId!: string; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index e2d67c2..b6139bc 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -15,7 +15,18 @@ import { type ContactEmailLabel, } from '@/modules/contacts/contacts.types'; import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants'; +import { + CONVERSATION_MESSAGE_IDS_MAX, + CONVERSATION_PARTICIPANTS_MAX, +} from '@/modules/mail-search/mail-search.constants'; import type { + ConversationBasis, + MessageDetailHighlight, + MessageListHighlight, + SearchRange, +} from '@/modules/mail-search/mail-search.types'; +import type { + ConversationSummary, Draft, MailAddress, MailAttachment, @@ -29,6 +40,7 @@ import type { MessageDeletionResult, MessageDetail, MessageLargeFile, + MessageListItem, MessagesPatchResult, MessageSummary, PendingSend, @@ -311,6 +323,90 @@ export class MessageSummaryDto implements MessageSummary { pendingSend!: PendingSendDto | null; } +/** 맞은 곳 [시작, 끝) — UTF-16, 끝은 들지 않는다 (14단계 검색 강조) */ +const RANGE_LIST_SCHEMA = { + type: 'array', + items: { + type: 'array', + items: { type: 'integer', minimum: 0 }, + minItems: 2, + maxItems: 2, + }, +} as const; + +/** 목록 항목의 미리 보기 강조 */ +export class MessageListPreviewHighlightDto { + @ApiProperty({ + description: + '보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(앞 20자 남짓 · 모두 80자 남짓, 말 경계에서 자르고 잘린 쪽에 "…"), 아니면 엔진의 미리 보기', + example: '…지난주 보내 드린 계약서를 다시 확인해 주세요', + }) + text!: string; + + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: 'text 안의 맞은 곳 [시작, 끝) — UTF-16', + example: [[12, 14]], + }) + ranges!: SearchRange[]; +} + +/** 목록 항목의 검색 강조 (색인으로 찾았을 때만) */ +export class MessageListHighlightDto implements MessageListHighlight { + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + '제목의 맞은 곳 [시작, 끝) — UTF-16. 강조가 있으면 subject 는 NFC 로 맞춘 글이다(위치가 그 글 기준)', + example: [[0, 2]], + }) + subject!: SearchRange[]; + + @ApiProperty({ type: MessageListPreviewHighlightDto }) + preview!: MessageListPreviewHighlightDto; +} + +/** 목록 항목 (GET /api/messages) — 요약 + 검색 강조 */ +export class MessageListItemDto + extends MessageSummaryDto + implements MessageListItem +{ + @ApiProperty({ + description: + '검색 강조 (14단계) — 색인으로 찾은 검색 결과에만, 목록 · 엔진 방식 검색(색인 준비 중)이면 null', + type: MessageListHighlightDto, + nullable: true, + }) + searchHighlight!: MessageListHighlightDto | null; +} + +/** 읽기 화면의 검색 강조 */ +export class MessageDetailHighlightDto implements MessageDetailHighlight { + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + '제목의 맞은 곳 [시작, 끝) — UTF-16 (subject 는 NFC 로 맞춘 글이다)', + example: [[0, 2]], + }) + subject!: SearchRange[]; + + @ApiProperty({ + description: + '첨부 이름의 맞은 곳 — 첨부 id → [시작, 끝) 목록. 맞은 첨부만 든다 (이름은 NFC 로 맞춘 글이다)', + type: 'object', + additionalProperties: RANGE_LIST_SCHEMA, + example: { blob1: [[0, 3]] }, + }) + attachments!: Record; + + @ApiProperty({ + ...RANGE_LIST_SCHEMA, + description: + 'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 가 들어 있다', + example: [[120, 122]], + }) + textBody!: SearchRange[]; +} + export class MessageDetailDto extends MessageSummaryDto implements MessageDetail @@ -406,6 +502,14 @@ export class MessageDetailDto type: () => [MessageLargeFileDto], }) largeFiles!: MessageLargeFileDto[]; + + @ApiProperty({ + description: + '검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 가 있다', + type: MessageDetailHighlightDto, + nullable: true, + }) + searchHighlight!: MessageDetailHighlightDto | null; } const SHARED_FILE_STATUSES: SharedFileStatus[] = [ @@ -475,13 +579,132 @@ export class MessageLargeFileDto implements MessageLargeFile { export class MessagePageDto { @ApiProperty({ - type: [MessageSummaryDto], + type: [MessageListItemDto], description: '받은 시각 내림차순', }) + items!: MessageListItemDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: + 'total — 목록 · 엔진 방식 검색은 늘, 색인 방식 검색은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). 색인 방식은 쪽이 limit 보다 적거나 비어도 hasNext 일 수 있다(이어 찾기)', + }) + pageInfo!: PageInfoDto; +} + +/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목) 또는 엔진의 스레드(색인을 다 만들기 전) — 14단계 */ +const CONVERSATION_BASES: ConversationBasis[] = ['index', 'engine']; + +/** 같은 대화 (GET /api/messages/{id}/conversation — 14단계) */ +export class ConversationPageDto { + @ApiProperty({ + type: [MessageSummaryDto], + description: + '그 메일의 대화 — 오래된 차례(받은 시각). 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나', + }) items!: MessageSummaryDto[]; - @ApiProperty({ type: PageInfoDto, description: 'total 포함' }) + @ApiProperty({ + type: PageInfoDto, + description: + 'total — 대화의 메일 수. 색인 방식은 첫 쪽에서만, 엔진 방식은 스레드를 모두 읽었을 때(상한 이하)만 쪽마다 — 넘으면 null (쪽이 limit 보다 적어도 hasNext 면 이어 읽는다)', + }) pageInfo!: PageInfoDto; + + @ApiProperty({ + description: + '대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전)', + enum: CONVERSATION_BASES, + example: 'index', + }) + basis!: ConversationBasis; +} + +/** 묶은 목록의 대화 하나 (GET /api/conversations — 14단계) */ +export class ConversationSummaryDto implements ConversationSummary { + @ApiProperty({ + description: + '대화 — 불투명 문자열(색인이면 대화 키, 엔진이면 스레드 id). 한 목록 안에서 대화를 가리는 데만 쓴다', + example: 'eaaaaab', + }) + id!: string; + + @ApiProperty({ + type: MessageSummaryDto, + description: '그 메일함 안 마지막 메일 — 줄을 누르면 연다', + }) + latest!: MessageSummaryDto; + + @ApiProperty({ + type: [MailAddressDto], + description: `보낸 사람들 — 대화 전체에서 받은 차례, 주소마다 한 번, ${CONVERSATION_PARTICIPANTS_MAX}명까지 (화면은 내 주소를 "나"로). 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만`, + }) + participants!: MailAddressDto[]; + + @ApiProperty({ + description: + '대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다', + example: 4, + }) + messageCount!: number; + + @ApiProperty({ + type: [String], + description: `그 메일함 안 그 대화의 메일 — 최신부터 ${CONVERSATION_MESSAGE_IDS_MAX}통까지(넘는 오래된 메일은 빠진다). 고르기 · 읽음 · 중요 · 이동 · 삭제는 이 id 들로 여러 통 API(PATCH /api/messages 등)에 — pendingSendIds 는 뺀다`, + example: ['eaaaaab', 'eaaaaaa'], + }) + messageIds!: string[]; + + @ApiProperty({ + type: [String], + description: + 'messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일)의 id — 없으면 빈 배열. 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(여러 통 처리에서 뺀다)', + example: [], + }) + pendingSendIds!: string[]; + + @ApiProperty({ + description: '그 메일함 안 메일을 모두 읽었다', + example: false, + }) + isRead!: boolean; + + @ApiProperty({ + description: '그 메일함 안 메일 가운데 하나라도 중요 표시', + example: false, + }) + isFlagged!: boolean; + + @ApiProperty({ + description: + '그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 hasAttachment 와 같은 판정)', + example: true, + }) + hasAttachment!: boolean; +} + +/** 대화로 묶은 목록 (GET /api/conversations — 14단계) */ +export class ConversationListPageDto { + @ApiProperty({ + type: [ConversationSummaryDto], + description: '그 메일함의 대화 — 그 메일함 안 마지막 받은 시각 최신부터', + }) + items!: ConversationSummaryDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: + 'total — 그 메일함의 대화 수(색인 방식은 첫 쪽에서만 · 엔진 방식은 엔진이 센 스레드 수)', + }) + pageInfo!: PageInfoDto; + + @ApiProperty({ + description: + '묶은 기준 — index 우리 색인 · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전 — 통 수 · 묶음이 엔진 규칙이다)', + enum: CONVERSATION_BASES, + example: 'index', + }) + basis!: ConversationBasis; } const REJECTION_REASONS: RecipientRejectionReason[] = [ diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts index 0ef15c5..287854b 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.spec.ts @@ -25,3 +25,72 @@ describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 image expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']); }); }); + +describe('메일 한 통 읽기 쿼리 — 검색 강조 (14단계 q · from · to · subject · body)', () => { + it('목록의 검색 칸과 같은 검사 — 앞뒤 공백을 지우고, 길이 · 낱말 수를 본다', async () => { + for (const field of ['q', 'from', 'to', 'subject', 'body']) { + expect(await constraintsOf({ [field]: ' 계약 ' })).toEqual([]); + expect(await constraintsOf({ [field]: '가'.repeat(201) })).toEqual([ + 'maxCodeUnits', + ]); + expect( + await constraintsOf({ + [field]: Array.from({ length: 9 }, (_, i) => `w${i}`).join(' '), + }), + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); + expect(await constraintsOf({ [field]: ['a', 'b'] })).toContain( + 'isString', + ); + } + }); + + it('images=show 와 함께 받는다 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다', async () => { + expect(await constraintsOf({ images: 'show', q: '계약' })).toEqual([]); + expect(await constraintsOf({ after: '2026-10-31T15:00:00.000Z' })).toEqual([ + 'whitelistValidation', + ]); + expect(await constraintsOf({ hasAttachment: 'true' })).toEqual([ + 'whitelistValidation', + ]); + }); +}); + +describe('메일 한 통 읽기 쿼리 — 받는 칸은 계약과 같다 (14단계 리뷰 M2 · M3)', () => { + it('q · from · to · subject · body 의 낱말은 합쳐 8개까지 — 목록과 같다', async () => { + const words = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`).join( + ' ', + ); + expect( + await constraintsOf({ q: words('q', 4), subject: words('s', 4) }), + ).toEqual([]); + expect( + await constraintsOf({ q: words('q', 4), body: words('b', 5) }), + ).toEqual(['maxCombinedSearchWords']); + }); + + it('images · q · from · to · subject · body 밖의 이름(cursor · limit · mailboxId · after · before · hasAttachment)은 400', async () => { + expect( + await constraintsOf({ + images: 'show', + q: '계약', + from: '이도윤', + to: 'ara', + subject: '견적', + body: '일정', + }), + ).toEqual([]); + for (const name of [ + 'cursor', + 'limit', + 'mailboxId', + 'after', + 'before', + 'hasAttachment', + ]) { + expect(await constraintsOf({ [name]: '1' })).toEqual([ + 'whitelistValidation', + ]); + } + }); +}); diff --git a/backend/src/modules/mail/dto/message-detail-query.dto.ts b/backend/src/modules/mail/dto/message-detail-query.dto.ts index 6c24fb1..aa00ca3 100644 --- a/backend/src/modules/mail/dto/message-detail-query.dto.ts +++ b/backend/src/modules/mail/dto/message-detail-query.dto.ts @@ -1,12 +1,23 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsOptional } from 'class-validator'; +import { + MAX_SEARCH_LENGTH, + MAX_SEARCH_WORDS, +} from '@/modules/mail/mail.constants'; +import { SearchTextField } from './search-query-fields'; + /** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */ export const SHOW_IMAGES = 'show'; +/** 강조에 쓰는 검색 칸의 설명 꼬리 */ +const HIGHLIGHT_RULE = `검색 결과에서 연 메일을 강조할 때 목록과 같은 값을 보낸다 — 검사는 목록의 검색어와 같다(다섯 칸의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지)`; + /** * GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도). - * 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001 + * 없으면 그 직원의 설정(externalImages)을 따른다. 검색 칸(q · from · to · subject · body — 14단계)을 받으면 맞은 곳을 강조한다 + * (searchHighlight · htmlBody 의 ``). from · to 는 받기만 하고 강조하지 않는다. 모르는 값 · 모르는 쿼리는 400 VAL_001 — + * 목록에만 있는 cursor · limit · mailboxId · after · before · hasAttachment 도 받지 않는다(계약 2절 "검색 — 14단계" — 화면은 다섯 칸만 넘긴다) */ export class MessageDetailQueryDto { @ApiPropertyOptional({ @@ -18,4 +29,39 @@ export class MessageDetailQueryDto { @IsOptional() @IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' }) images?: typeof SHOW_IMAGES; + + @ApiPropertyOptional({ + description: `검색어 — 제목 · 본문 · 첨부 이름의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('검색어', '검색어는') + q?: string; + + @ApiPropertyOptional({ + description: `보낸 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('보낸 사람', '보낸 사람은') + from?: string; + + @ApiPropertyOptional({ + description: `받는 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('받는 사람', '받는 사람은') + to?: string; + + @ApiPropertyOptional({ + description: `제목 조건 — 제목의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('제목', '제목은') + subject?: string; + + @ApiPropertyOptional({ + description: `내용 조건 — 본문의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`, + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('내용', '내용은') + body?: string; } diff --git a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts index 7bd7b15..21c3524 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts @@ -1,6 +1,9 @@ +import { BadRequestException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { startCpuTimer } from '@/../test/cpu-time'; +import { createValidationPipe } from '@/app.setup'; import { MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, @@ -63,10 +66,10 @@ describe('MessageListQueryDto (GET /api/messages)', () => { ); expect( (await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints, - ).toEqual(['maxSearchWords']); + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); expect( (await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints, - ).toEqual(['maxSearchWords']); + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); }); it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => { @@ -75,3 +78,154 @@ describe('MessageListQueryDto (GET /api/messages)', () => { ); }); }); + +describe('MessageListQueryDto — 조건 검색 (14단계)', () => { + const TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const; + + it('글 조건(from · to · subject · body)은 검색어와 같다 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것', async () => { + for (const field of TEXT_FIELDS) { + const trimmed = await check({ [field]: ' 이도윤 계약 ' }); + expect(trimmed.constraints).toEqual([]); + expect(trimmed.dto[field]).toBe('이도윤 계약'); + + const blank = await check({ [field]: ' \u3000 ' }); + expect(blank.constraints).toEqual([]); + expect(blank.dto[field]).toBeUndefined(); + } + }); + + it(`글 조건도 앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지, 글이 아니면 형식 오류`, async () => { + const words = (count: number) => + Array.from({ length: count }, (_, index) => `w${index}`).join(' '); + for (const field of TEXT_FIELDS) { + expect( + (await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH) })).constraints, + ).toEqual([]); + expect( + (await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH + 1) })) + .constraints, + ).toEqual(['maxCodeUnits']); + expect( + (await check({ [field]: words(MAX_SEARCH_WORDS) })).constraints, + ).toEqual([]); + expect( + (await check({ [field]: words(MAX_SEARCH_WORDS + 1) })).constraints, + ).toEqual(['maxSearchWords', 'maxCombinedSearchWords']); + expect((await check({ [field]: ['a', 'b'] })).constraints).toContain( + 'isString', + ); + } + }); + + it('기간(after · before)은 ISO 8601 UTC(Z)만 — 없는 날짜 · 시간대 표기 · 날짜만 · 빈 값은 형식 오류', async () => { + expect( + ( + await check({ + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00Z', + }) + ).constraints, + ).toEqual([]); + for (const value of [ + '', + '2026-11-01', + '2026-11-01T00:00:00+09:00', + '2026-02-30T00:00:00.000Z', + '2026-11-01T00:00:00.0000Z', + 'yesterday', + ]) { + expect((await check({ after: value })).constraints).toEqual([ + 'isUtcDateTime', + ]); + expect((await check({ before: value })).constraints).toEqual([ + 'isUtcDateTime', + ]); + } + }); + + it('첨부 조건은 true 만 — false · 1 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => { + expect((await check({ hasAttachment: 'true' })).constraints).toEqual([]); + for (const hasAttachment of ['false', '1', 'TRUE', '']) { + expect((await check({ hasAttachment })).constraints).toEqual(['isIn']); + } + expect((await check({ cc: 'ara' })).constraints).toEqual([ + 'whitelistValidation', + ]); + }); + + it('전역 검사 그대로(stopAtFirstError) — 긴 글은 길이에서 멈춘다: 낱말을 나누지 않고 싸게 400 (CPU 시간)', async () => { + const pipe = createValidationPipe(); + // 낱말 10만 개 · 30만 자 — 길이(MaxCodeUnits — 길이만 본다)에서 걸려 낱말 수 검사(나누기)까지 가지 않는다 + const huge = 'ab '.repeat(100_000); + for (const field of ['q', ...TEXT_FIELDS]) { + const cpuMs = startCpuTimer(); + const error = await pipe + .transform( + { [field]: huge }, + { type: 'query', metatype: MessageListQueryDto }, + ) + .then( + () => null, + (caught: unknown) => caught, + ); + // 지금 수 ms — 부하를 견디게 10배 넘게 + expect(cpuMs()).toBeLessThan(500); + expect(error).toBeInstanceOf(BadRequestException); + const { message } = (error as BadRequestException).getResponse() as { + message: string[]; + }; + expect(message).toHaveLength(1); + expect(message[0]).toContain(`${MAX_SEARCH_LENGTH}자 이하`); + } + }); +}); + +describe('MessageListQueryDto — 검색어와 조건을 합친 낱말 수 (14단계 리뷰 M2)', () => { + const words = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' '); + + it(`q 와 조건 칸 넷(from · to · subject · body)의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지 — 넘으면 한 번만 알린다`, async () => { + expect( + ( + await check({ + q: words('q', 2), + from: words('f', 2), + to: words('t', 2), + subject: words('s', 1), + body: words('b', 1), + }) + ).constraints, + ).toEqual([]); + expect( + (await check({ q: words('q', 4), to: words('t', 5) })).constraints, + ).toEqual(['maxCombinedSearchWords']); + // 검색어 없이 조건만이어도 같다 + expect( + (await check({ from: words('f', 5), body: words('b', 4) })).constraints, + ).toEqual(['maxCombinedSearchWords']); + }); + + it('기간 · 첨부 · 메일함은 세지 않는다 · 따옴표 안의 낱말은 센다', async () => { + expect( + ( + await check({ + q: words('q', MAX_SEARCH_WORDS), + after: '2026-10-31T15:00:00.000Z', + hasAttachment: 'true', + mailboxId: 'a', + }) + ).constraints, + ).toEqual([]); + expect( + (await check({ q: `"${words('q', 5)}"`, subject: words('s', 4) })) + .constraints, + ).toEqual(['maxCombinedSearchWords']); + }); + + it('칸 하나가 길이를 넘으면 그 칸의 검사만 — 합친 수를 세려고 긴 글을 나누지 않는다', async () => { + // (이 검사는 stopAtFirstError 를 뺀 것이라 그 칸의 규칙이 모두 보인다 — 합친 수는 보지 않았다) + expect( + (await check({ q: '계약', to: 'ab '.repeat(100) })).constraints, + ).toEqual(['maxCodeUnits', 'maxSearchWords']); + }); +}); diff --git a/backend/src/modules/mail/dto/message-list-query.dto.ts b/backend/src/modules/mail/dto/message-list-query.dto.ts index 8fa807a..c8e3b54 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.ts @@ -1,61 +1,29 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform, type TransformFnParams } from 'class-transformer'; -import { - IsOptional, - IsString, - Matches, - ValidateBy, - type ValidationOptions, -} from 'class-validator'; +import { IsIn, IsOptional, Matches } from 'class-validator'; -import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; import { MAIL_ID_PATTERN, MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, - SEARCH_WORD_SEPARATOR, } from '@/modules/mail/mail.constants'; +import { IsUtcDateTime } from './mail-vacation.dto'; +import { SearchTextField } from './search-query-fields'; + +/** 첨부 있음 조건의 값 — 이것만 받는다 (없으면 첨부와 상관없이) */ +export const HAS_ATTACHMENT_ONLY = 'true'; + +/** 글 칸의 설명 꼬리 — 검사는 검색어와 같다 */ +const TEXT_FIELD_RULE = `앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지(검색어와 같은 검사 — q 와 조건 넷의 낱말을 합쳐서도 ${MAX_SEARCH_WORDS}개까지), 공백뿐이면 없는 것으로 본다. 큰따옴표 구절 · 앞뒤 문장부호 떼기 · NUL 을 U+FFFD 로 바꾸기도 검색어와 같다`; /** - * 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색"). - * 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다. - */ -function normalizeSearchQuery({ value }: TransformFnParams): unknown { - const raw: unknown = value; - if (typeof raw !== 'string') return raw; - const trimmed = raw.trim(); - return trimmed === '' ? undefined : trimmed; -} - -/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */ -function countSearchWords(text: string): number { - return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length; -} - -/** 최대 낱말 수 */ -function MaxSearchWords(max: number, options: ValidationOptions) { - return ValidateBy( - { - name: 'maxSearchWords', - constraints: [max], - validator: { - validate: (value: unknown) => - typeof value === 'string' && countSearchWords(value) <= max, - }, - }, - options, - ); -} - -/** - * GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어. - * mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages) + * GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 + 조건(14단계). + * mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (message-list.service.ts) */ export class MessageListQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ description: - '메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)', + '메일함 id — 없으면 검색(q 나 조건)이 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (둘 다 없으면 400 VAL_001)', example: 'a', }) @IsOptional() @@ -64,21 +32,76 @@ export class MessageListQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ description: - `검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` + - '보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' + - '한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' + - '낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다', + `검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지(조건 from · to · subject · body 의 낱말과 합쳐서도 ${MAX_SEARCH_WORDS}개까지). 공백뿐이면 없는 것으로 본다. ` + + '보낸 사람·받는 사람·참조·제목·첨부 이름·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' + + '색인이 준비됐으면(GET /api/search-index 의 ready) 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 없이), ' + + '준비 중이면 낱말이 통째로 같을 때만 맞고 한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다. ' + + '낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·조건·범위로만 쓴다', example: '서버 이전', maxLength: MAX_SEARCH_LENGTH, }) - @Transform(normalizeSearchQuery) - @IsOptional() - @IsString({ message: '검색어 형식이 올바르지 않습니다.' }) - @MaxCodeUnits(MAX_SEARCH_LENGTH, { - message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`, - }) - @MaxSearchWords(MAX_SEARCH_WORDS, { - message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, - }) + @SearchTextField('검색어', '검색어는') q?: string; + + @ApiPropertyOptional({ + description: `보낸 사람 조건 — 보낸 사람의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`, + example: '이도윤', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('보낸 사람', '보낸 사람은') + from?: string; + + @ApiPropertyOptional({ + description: `받는 사람 조건 — 받는 사람과 참조의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`, + example: 'ara.kim', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('받는 사람', '받는 사람은') + to?: string; + + @ApiPropertyOptional({ + description: `제목 조건. ${TEXT_FIELD_RULE}`, + example: '견적', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('제목', '제목은') + subject?: string; + + @ApiPropertyOptional({ + description: `내용 조건 — 본문에서 찾는다(첨부 파일 안의 글은 찾지 않는다). ${TEXT_FIELD_RULE}`, + example: '계약', + maxLength: MAX_SEARCH_LENGTH, + }) + @SearchTextField('내용', '내용은') + body?: string; + + @ApiPropertyOptional({ + description: + '받은 시각이 이 시각 이후(이상)인 메일만 — ISO 8601 UTC(Z). 화면은 브라우저 시간대의 시작 날짜 0시를 UTC 로 바꿔 보낸다', + example: '2026-10-31T15:00:00.000Z', + }) + @IsOptional() + @IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' }) + after?: string; + + @ApiPropertyOptional({ + description: + '받은 시각이 이 시각 앞(미만)인 메일만 — ISO 8601 UTC(Z). 화면은 끝 날짜의 다음 날 0시를 보낸다', + example: '2026-11-30T15:00:00.000Z', + }) + @IsOptional() + @IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' }) + before?: string; + + @ApiPropertyOptional({ + description: + 'true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정 — 본문 그림만 있는 메일은 빠진다). 다른 값은 400 VAL_001', + enum: [HAS_ATTACHMENT_ONLY], + example: HAS_ATTACHMENT_ONLY, + }) + @IsOptional() + @IsIn([HAS_ATTACHMENT_ONLY], { + message: '첨부 조건이 올바르지 않습니다.', + }) + hasAttachment?: typeof HAS_ATTACHMENT_ONLY; } diff --git a/backend/src/modules/mail/dto/search-query-fields.ts b/backend/src/modules/mail/dto/search-query-fields.ts new file mode 100644 index 0000000..2ad2f53 --- /dev/null +++ b/backend/src/modules/mail/dto/search-query-fields.ts @@ -0,0 +1,121 @@ +import { applyDecorators } from '@nestjs/common'; +import { Transform, type TransformFnParams } from 'class-transformer'; +import { + IsOptional, + IsString, + ValidateBy, + type ValidationArguments, + type ValidationOptions, +} from 'class-validator'; + +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + MAX_SEARCH_LENGTH, + MAX_SEARCH_WORDS, + SEARCH_WORD_SEPARATOR, +} from '@/modules/mail/mail.constants'; + +/** + * 검색 글 칸의 검사 (계약 2절 "검색" · "검색 — 14단계" — 검색어 q 와 조건 검색의 from · to · subject · body 가 같이 쓴다). + * 앞뒤 공백을 지우고 공백뿐이면 없는 것으로 본다. 앞뒤 공백을 뺀 1~MAX_SEARCH_LENGTH 자(JS 문자열 길이) · 낱말 MAX_SEARCH_WORDS 개까지, + * 그리고 **다섯 칸을 합친 낱말도 MAX_SEARCH_WORDS 개까지** — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻는다(3단계에서 잰 비용 안에 두려고). + * 검사는 적은 차례대로 돈다(applyDecorators — 8단계 DTO 와 같다): 형식 → 길이 → 낱말 수 → 합친 낱말 수. 길이에서 걸리면 낱말을 나누지 않는다 + */ + +/** 낱말 수를 합쳐 세는 검색 글 칸 — 목록(GET /api/messages)과 읽기(GET /api/messages/{id})의 같은 이름 */ +export const SEARCH_TEXT_FIELDS = [ + 'q', + 'from', + 'to', + 'subject', + 'body', +] as const; + +/** 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 — 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다 */ +function normalizeSearchText({ value }: TransformFnParams): unknown { + const raw: unknown = value; + if (typeof raw !== 'string') return raw; + const trimmed = raw.trim(); + return trimmed === '' ? undefined : trimmed; +} + +/** 검색 글의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */ +export function countSearchWords(text: string): number { + return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length; +} + +/** 최대 낱말 수 */ +function MaxSearchWords(max: number, options: ValidationOptions) { + return ValidateBy( + { + name: 'maxSearchWords', + constraints: [max], + validator: { + validate: (value: unknown) => + typeof value === 'string' && countSearchWords(value) <= max, + }, + }, + options, + ); +} + +/** + * 다섯 칸을 합친 낱말 수 — 값이 있는 첫 칸에서만 센다(한 번만 알린다). 다른 칸이 글이 아니거나 길이를 넘으면 세지 않는다 — + * 그 칸의 검사가 막는다(긴 글을 나누지 않게) + */ +export function combinedSearchWords( + object: Readonly>, +): number | null { + let total = 0; + for (const field of SEARCH_TEXT_FIELDS) { + const value = object[field]; + if (value === undefined) continue; + if (typeof value !== 'string' || value.length > MAX_SEARCH_LENGTH) { + return null; + } + total += countSearchWords(value); + } + return total; +} + +function MaxCombinedSearchWords(max: number, options: ValidationOptions) { + return ValidateBy( + { + name: 'maxCombinedSearchWords', + constraints: [max], + validator: { + validate: (_value: unknown, args?: ValidationArguments) => { + const object = (args?.object ?? {}) as Record; + const first = SEARCH_TEXT_FIELDS.find( + (field) => object[field] !== undefined, + ); + if (first !== args?.property) return true; + const total = combinedSearchWords(object); + return total === null || total <= max; + }, + }, + }, + options, + ); +} + +/** + * 검색 글 칸 하나 — `label` 은 검사 글의 이름(검색어 · 보낸 사람 …), `topic` 은 조사를 붙인 꼴(검색어는 · 보낸 사람은 …). + * 검사 글은 로그 · 응답에 나가지 않는다(응답은 늘 VAL_001 의 사전 문구 — 계약 머리말) + */ +export function SearchTextField(label: string, topic: string) { + return applyDecorators( + Transform(normalizeSearchText), + IsOptional(), + IsString({ message: `${label} 형식이 올바르지 않습니다.` }), + MaxCodeUnits(MAX_SEARCH_LENGTH, { + message: `${topic} ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`, + }), + MaxSearchWords(MAX_SEARCH_WORDS, { + message: `${topic} 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, + }), + MaxCombinedSearchWords(MAX_SEARCH_WORDS, { + message: `검색어와 조건은 낱말을 모두 합쳐 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`, + }), + ); +} diff --git a/backend/src/modules/mail/html/html-mark.spec.ts b/backend/src/modules/mail/html/html-mark.spec.ts new file mode 100644 index 0000000..4e22f13 --- /dev/null +++ b/backend/src/modules/mail/html/html-mark.spec.ts @@ -0,0 +1,176 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { MAX_HTML_MARKS, markHtmlText } from './html-mark'; +import { sanitizeMailHtml } from './sanitize-mail-html'; + +const marked = (html: string, ...terms: string[]) => + markHtmlText(html, terms).html; + +const countMarks = (html: string) => html.split('').length - 1; + +describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => { + it('글 조각의 맞은 곳에 — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => { + expect(marked('

계약 갱신 안내

', '계약')).toBe( + '

계약 갱신 안내

', + ); + expect(marked('

ERP 와 Erp 와 ERP

', 'erp')).toBe( + '

ERP 와 Erp 와 ERP

', + ); + expect(markHtmlText('

계약 계약

', ['계약']).marks).toBe(2); + }); + + it('겹치거나 맞닿은 맞은 곳은 하나로 — 여러 말이 한 자리에 맞아도 는 겹치지 않는다', () => { + expect(marked('

계약서 갱신

', '계약', '약서')).toBe( + '

계약서 갱신

', + ); + }); + + it('태그 안(이름 · 속성 · style · 주소)은 건드리지 않는다', () => { + const html = + '계약계약'; + expect(marked(html, '계약')).toBe( + '계약계약', + ); + }); + + it('주석 · 글을 그대로 두는 요소(style · script · textarea · title)의 안은 그대로', () => { + expect( + marked( + '계약

계약

계약

계약

`)과 글을 그대로 두어야 하는 요소 + * (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로 + * - 글 조각마다 문자 참조를 풀어(& · < · > · " · ' ·   · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다 + * (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(& · < · > 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다 + * - 태그를 건너는 말(`계약`)은 강조하지 않는다 — 글 조각 안에서만 + * - 넣는 `` 는 `limit`(MAX_HTML_MARKS)개까지 + * 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다) + */ + +/** 한 본문에 넣는 `` 의 수 */ +export const MAX_HTML_MARKS = 1_000; + +/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */ +const MAX_REFERENCE_LENGTH = 32; + +/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 & · < · > 만 다시 적는다)과 흔한 것 */ +const NAMED_REFERENCES: Readonly> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', +}; + +/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */ +const RAW_TEXT_TAGS: ReadonlySet = new Set([ + 'script', + 'style', + 'textarea', + 'title', + 'xmp', +]); + +const COMMENT_OPEN = ''; +const MAX_CODE_POINT = 0x10ffff; +const REPLACEMENT_CHARACTER = '\ufffd'; + +/** 태그 이름 (`', + }, + }, + } + : { + id, + textBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { '1': { value: '글 본문' } }, + }, + ), + }, + callId, + ]; + } + return [ + method, + { list: emails.filter((email) => ids.includes(email.id)) }, + callId, + ]; + }); + + const documents = await source.documents( + ARA, + ['e1', 'e2', 'gone'], + options(), + ); + + expect(documents).toHaveLength(2); + expect(documents[0]).toEqual({ + id: 'e1', + threadId: 't1', + mailboxIds: ['inbox'], + keywords: ['$seen'], + receivedAt: '2026-11-05T01:00:00Z', + size: 900, + messageIds: ['m1@x'], + inReplyTo: ['p@x'], + references: ['r@x', 'p@x'], + subject: '계약서', + from: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + to: [{ name: null, email: ARA }], + cc: [], + attachmentNames: ['계약서.pdf', '메모.txt'], + hasAttachment: true, + preview: '미리 보기', + bodyText: '계약서를보내 드립니다\n', + }); + expect(documents[1]).toMatchObject({ + id: 'e2', + subject: '', + threadId: null, + bodyText: '글 본문', + hasAttachment: false, + attachmentNames: [], + }); + }); + }); + + it('indexBodyBatches — 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘으면 나누고, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자', () => { + const part = { partId: '1', type: 'text/plain', size: 1_000 }; + const third = Math.floor(BODY_READ_BATCH_MESSAGE_BYTES / 3); + const batches = indexBodyBatches([ + // 큰 첨부가 든 메일 넷 — 크기 합이 상한을 넘기 전에 나눈다 + { id: 'a', parts: [part], size: third }, + { id: 'b', parts: [part], size: third }, + { id: 'c', parts: [part], size: third }, + { id: 'd', parts: [part], size: third }, + // 혼자 읽는 메일 — 그 앞뒤 묶음과 섞지 않는다 + { id: 'huge', parts: [part], size: BODY_READ_SOLO_MESSAGE_BYTES + 1 }, + { id: 'small', parts: [part], size: 10 }, + ]); + expect(batches.map((batch) => batch.ids)).toEqual([ + ['a', 'b', 'c'], + ['huge'], + ['d', 'small'], + ]); + }); + + it('indexBodyBatches — 몫이 같은 메일끼리, 어림 크기가 상한을 넘으면 나눈다 · 글자 부분이 없으면 읽지 않는다', () => { + const big = { partId: '1', type: 'text/plain', size: 1_000_000 }; + const batches = indexBodyBatches([ + { id: 'a', parts: [big], size: 1_000_000 }, + { id: 'b', parts: [big], size: 1_000_000 }, + { id: 'c', parts: [big, { ...big, partId: '2' }], size: 2_000_000 }, + { id: 'none', parts: [], size: 0 }, + ]); + // 한 부분 몫 256KiB — 어림 256KiB 씩이라 a · b 는 한 묶음, c 는 부분이 둘이라 몫이 같은(256KiB) 묶음에 + expect(batches.flatMap((batch) => batch.ids).sort()).toEqual([ + 'a', + 'b', + 'c', + ]); + expect(batches.every((batch) => batch.quota === 262_144)).toBe(true); + }); + + it('indexAttachmentNames — 본문 그림을 빼고 이름이 있는 것만, 경로 · 제어 문자는 다듬는다', () => { + expect( + indexAttachmentNames([ + { name: '../보고서.hwp', type: 'application/octet-stream' }, + { name: 'logo.png', type: 'image/png', cid: 'c1', size: 10 }, + { name: '', type: 'application/pdf' }, + ]), + ).toEqual(['보고서.hwp']); + }); + + describe('pageIds · allIds · snapshot', () => { + it('anchor 다음부터 최신 차례 — anchor 가 지워졌으면 위치로', async () => { + const seen: Record[] = []; + engine.on('Email/query', (method, args, callId) => { + seen.push(args); + if (args.anchor === 'gone') { + return ['error', { type: 'anchorNotFound' }, callId]; + } + return [method, { ids: ['x', 'y'], position: 50, total: 120 }, callId]; + }); + expect( + await source.pageIds( + ARA, + { anchor: 'gone', position: 50 }, + 2, + options(), + ), + ).toEqual({ ids: ['x', 'y'], position: 50, total: 120 }); + expect(seen[0]).toMatchObject({ + anchor: 'gone', + anchorOffset: 1, + sort: [{ property: 'receivedAt', isAscending: false }], + calculateTotal: true, + }); + expect(seen[1]).toMatchObject({ position: 50, limit: 2 }); + expect(seen[1]).not.toHaveProperty('anchor'); + }); + + it('전체 대조는 오래된 차례로 anchor 로 이어 묻고 빈 쪽에서 끝난다 — anchor 가 지워지면 처음부터', async () => { + const pages = [ + { anchor: undefined, ids: ['a', 'b'] }, + { anchor: 'b', error: 'anchorNotFound' }, + { anchor: undefined, ids: ['a', 'c'] }, + { anchor: 'c', ids: ['d'] }, + { anchor: 'd', ids: [] }, + ]; + engine.on('Email/get', (method, _args, callId) => [ + method, + { state: 'st', list: [] }, + callId, + ]); + engine.on('Email/query', (method, args, callId) => { + if (args.limit === 1) return [method, { ids: [], total: 3 }, callId]; + const page = pages.shift()!; + expect(args.anchor).toBe(page.anchor); + expect(args.sort).toEqual([ + { property: 'receivedAt', isAscending: true }, + ]); + if (page.error) return ['error', { type: page.error }, callId]; + return [method, { ids: page.ids, total: 3 }, callId]; + }); + expect(await source.allIds(ARA, options())).toEqual({ + state: 'st', + ids: ['a', 'c', 'd'], + }); + expect(pages).toEqual([]); + }); + + it('snapshot — 빈 ids 의 Email/get 으로 state, 조회의 total 로 메일 수', async () => { + engine.on('Email/get', (method, args, callId) => { + expect(args.ids).toEqual([]); + return [method, { state: 'st9', list: [] }, callId]; + }); + engine.on('Email/query', (method, _args, callId) => [ + method, + { ids: ['z'], total: 42 }, + callId, + ]); + expect(await source.snapshot(ARA, options())).toEqual({ + state: 'st9', + total: 42, + }); + }); + + it('state 가 없으면 MAIL_001', async () => { + engine.on('Email/get', (method, _args, callId) => [ + method, + { list: [] }, + callId, + ]); + engine.on('Email/query', (method, _args, callId) => [ + method, + { ids: [] }, + callId, + ]); + await expect(source.snapshot(ARA, options())).rejects.toMatchObject({ + code: 'MAIL_001', + }); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-index-source.ts b/backend/src/modules/mail/jmap/jmap-index-source.ts new file mode 100644 index 0000000..fcdb222 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-index-source.ts @@ -0,0 +1,622 @@ +import { Injectable } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { sanitizeFileName } from '@/modules/mail/attachment.policy'; +import { htmlToText } from '@/modules/mail/html/html-text'; +import { + hasRealAttachment, + isBodyImagePart, +} from '@/modules/mail/html/inline-images'; +import { + IndexDeadlineError, + type IndexAddress, + type IndexAllIds, + type IndexChanges, + type IndexDocument, + type IndexIdPage, + type IndexMutable, + type IndexReadOptions, + type MailIndexSource, +} from '@/modules/mail-search/mail-index-source'; +import { + ALL_IDS_PAGE, + BODY_READ_BATCH_MESSAGE_BYTES, + BODY_READ_SOLO_MESSAGE_BYTES, + INDEX_CALL_MIN_MS, + INDEX_CALL_TIMEOUT_MS, +} from '@/modules/mail-search/mail-search.constants'; +import { BODY_FETCH_BUDGET_BYTES, bodyValueQuota } from './jmap-filter-run'; +import { JmapClient, type JmapCallOptions } from './jmap.client'; +import { jmapFailure } from './jmap.errors'; +import { expectResult, findResult, isState } from './jmap-results'; +import type { + JmapBodyPart, + JmapChangesResult, + JmapEmail, + JmapEmailAddress, + JmapGetResult, + JmapInvocation, + JmapQueryResult, +} from './jmap.types'; + +/** + * 메일 검색 색인이 엔진에서 읽는 것 — Stalwart(JMAP) 어댑터 (14단계 설계 B2, 포트는 modules/mail-search/mail-index-source.ts). + * 직원 자격(게이트웨이 대리 접근 — JmapClient.call)으로 읽기만 한다 — 이 파일에는 Email/set 이 없다. + * + * - 글: 머리글 · 받는 사람 · 첨부 부분은 한 번에(MAX_IDS_PER_GET 씩), 본문 값은 textBody 의 글자 부분만 따로 읽는다(fetchTextBodyValues). + * textBody 는 엔진이 고른 글자 쪽 표현(text/plain, 없으면 text/html)이라 같은 글을 두 번 담지 않는다. 첨부 파일 안의 글은 읽지 않는다(설계 — 첨부 이름만 찾는다). + * 본문 값은 부분마다 256KiB · 한 통 1MiB 까지(이미 받은 메일에 필터 적용과 같은 몫 — jmap-filter-run.ts 의 bodyValueQuota), + * 몫이 같은 메일끼리 어림 크기 BODY_FETCH_BUDGET_BYTES 로 나눠 읽는다 — 바깥 메일 한 통으로 서버 · 엔진 메모리를 크게 쓰지 않게. + * 묶음은 메일 **전체** 크기(size)의 합도 BODY_READ_BATCH_MESSAGE_BYTES 까지, BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다 — + * 엔진은 값을 만들며 원문을 읽으므로 큰 첨부 메일 여럿이 한 요청에 들면 엔진(1g) 메모리가 한꺼번에 튄다 + * - 시간: 엔진 읽기마다 차례의 끝 시각까지 남은 시간을 본다 — INDEX_CALL_MIN_MS 보다 적으면 시작하지 않고, 남은 시간으로 줄인 기다림이 + * 끝나 끊긴 것도 IndexDeadlineError(실패가 아니라 이어 하기)로 알린다. 한 번은 INDEX_CALL_TIMEOUT_MS 까지만 기다린다 + * - 지난 메일 쪽: 받은 시각 최신부터 anchor 다음 — anchor 가 지워졌으면(anchorNotFound) 위치로 + * - 전체 대조: 받은 시각 오래된 것부터 anchor 로 이어 묻는다(위치로 넘기면 그사이 지운 메일만큼 건너뛴다) — anchor 가 지워지면 처음부터 다시 + * 엔진 오류의 설명 문장은 남기지 않는다(jmapFailure — type · 속성 이름만) + */ + +/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */ +const MAX_IDS_PER_GET = 500; + +/** 전체 대조에서 anchor 가 지워져 처음부터 다시 묻는 횟수 */ +const ALL_IDS_RESTARTS = 3; + +/** 머리글을 읽을 때의 속성 · 부분 속성 (본문 값은 따로) */ +const DOCUMENT_PROPERTIES = [ + 'id', + 'threadId', + 'mailboxIds', + 'keywords', + 'receivedAt', + 'size', + 'messageId', + 'inReplyTo', + 'references', + 'subject', + 'from', + 'to', + 'cc', + 'preview', + 'attachments', + 'textBody', +]; +const DOCUMENT_PART_PROPERTIES = [ + 'partId', + 'type', + 'size', + 'name', + 'disposition', + 'cid', +]; + +const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }]; +const OLDEST_FIRST = [{ property: 'receivedAt', isAscending: true }]; + +/** JMAP 메서드 오류 — 그 state 부터의 변화를 셀 수 없다 · 인자가 맞지 않는다(모르는 state 모양) · anchor 가 결과에 없다 */ +const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges'; +const INVALID_ARGUMENTS = 'invalidArguments'; +const ANCHOR_NOT_FOUND = 'anchorNotFound'; + +const HTML_TYPE = 'text/html'; + +function chunks(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let index = 0; index < items.length; index += size) { + out.push(items.slice(index, index + size)); + } + return out; +} + +function trueKeys(record: Record | undefined): string[] { + return Object.entries(record ?? {}) + .filter(([, value]) => value === true) + .map(([key]) => key); +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function toAddresses( + addresses: JmapEmailAddress[] | null | undefined, +): IndexAddress[] { + return (addresses ?? []).map((address) => ({ + name: typeof address.name === 'string' ? address.name : null, + email: typeof address.email === 'string' ? address.email : null, + })); +} + +/** textBody 의 글자 부분 (text/* — 같은 partId 는 한 번) */ +export function indexTextParts(email: JmapEmail): JmapBodyPart[] { + const seen = new Set(); + return (email.textBody ?? []).filter((part) => { + const { partId } = part; + if (!partId || seen.has(partId)) return false; + seen.add(partId); + return (part.type ?? '').toLowerCase().startsWith('text/'); + }); +} + +/** 본문 글 — 글자 부분마다(HTML 은 글자만), 줄바꿈으로 잇는다. 값을 받지 못한 부분은 뺀다 */ +export function indexBodyText( + parts: readonly JmapBodyPart[], + values: JmapEmail['bodyValues'], +): string { + return parts + .flatMap((part) => { + const value = values?.[part.partId ?? '']?.value; + if (typeof value !== 'string') return []; + return [ + (part.type ?? '').toLowerCase() === HTML_TYPE + ? htmlToText(value) + : value, + ]; + }) + .join('\n'); +} + +/** 진짜 첨부(본문 그림이 아닌 것)의 이름 — 이름이 없는 첨부는 뺀다 (목록 · 읽기 화면과 같은 이름 다듬기) */ +export function indexAttachmentNames( + attachments: readonly JmapBodyPart[], +): string[] { + return attachments + .filter((part) => !isBodyImagePart(part)) + .flatMap((part) => { + const name = part.name?.trim(); + return name ? [sanitizeFileName(name)] : []; + }); +} + +/** 본문 값을 받을 때 응답 크기의 어림 (바이트) — 부분마다 몫까지, UTF-8 로 바꾸며 늘 수 있어 두 배 (크기를 모르면 몫) */ +function estimatedBytes(parts: readonly JmapBodyPart[], quota: number): number { + return parts.reduce( + (sum, part) => + sum + + (typeof part.size === 'number' && part.size >= 0 + ? Math.min(quota, part.size * 2) + : quota), + 0, + ); +} + +/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */ +interface BodyBatch { + readonly quota: number; + readonly ids: string[]; +} + +/** 본문 값을 읽을 메일 한 통 — 글자 부분 · 메일 전체 크기(엔진의 size, 모르면 0) */ +export interface BodyReadCandidate { + readonly id: string; + readonly parts: readonly JmapBodyPart[]; + readonly size: number; +} + +/** + * 본문 값을 읽을 묶음 — 몫(bodyValueQuota)이 같은 메일끼리. 이럴 때 나눈다: 값의 어림 크기가 BODY_FETCH_BUDGET_BYTES 를 넘는다 · + * 한 번 읽기 한도(MAX_IDS_PER_GET)에 닿는다 · 메일 전체 크기의 합이 BODY_READ_BATCH_MESSAGE_BYTES 를 넘는다. + * 전체 크기가 BODY_READ_SOLO_MESSAGE_BYTES 를 넘는 메일은 혼자 읽는다. 글자 부분이 없는 메일은 읽지 않는다 + */ +export function indexBodyBatches( + emails: readonly BodyReadCandidate[], +): BodyBatch[] { + const groups = new Map(); + for (const email of emails) { + if (email.parts.length === 0) continue; + const quota = bodyValueQuota(email.parts.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 valueBytes = 0; + let messageBytes = 0; + for (const email of group) { + const size = Number.isFinite(email.size) ? Math.max(0, email.size) : 0; + if (size > BODY_READ_SOLO_MESSAGE_BYTES) { + batches.push({ quota, ids: [email.id] }); + continue; + } + const estimate = estimatedBytes(email.parts, quota); + if ( + ids.length > 0 && + (valueBytes + estimate > BODY_FETCH_BUDGET_BYTES || + messageBytes + size > BODY_READ_BATCH_MESSAGE_BYTES || + ids.length >= MAX_IDS_PER_GET) + ) { + batches.push({ quota, ids }); + ids = []; + valueBytes = 0; + messageBytes = 0; + } + ids.push(email.id); + valueBytes += estimate; + messageBytes += size; + } + if (ids.length > 0) batches.push({ quota, ids }); + } + return batches; +} + +/** 엔진 호출이 기다림 끝(시간 초과)으로 끊겼나 — JmapClient 가 붙이는 사유 */ +function isTimeout(error: unknown): boolean { + return ( + error instanceof BusinessException && + error.code === 'MAIL_001' && + error.context?.reason === 'timeout' + ); +} + +@Injectable() +export class JmapIndexSource implements MailIndexSource { + constructor(private readonly jmap: JmapClient) {} + + async snapshot( + user: string, + options: IndexReadOptions, + ): Promise<{ state: string; total: number | null }> { + const responses = await this.call( + user, + (accountId) => [ + ['Email/get', { accountId, ids: [], properties: ['id'] }, 's'], + [ + 'Email/query', + { accountId, position: 0, limit: 1, calculateTotal: true }, + 'q', + ], + ], + options, + ); + const { state } = expectResult>( + responses, + 's', + 'Email/get', + ); + const query = expectResult(responses, 'q', 'Email/query'); + return { + state: this.requireState(state), + total: typeof query.total === 'number' ? query.total : null, + }; + } + + async changes( + user: string, + sinceState: string, + maxChanges: number, + options: IndexReadOptions, + ): Promise { + let responses: JmapInvocation[]; + try { + responses = await this.call( + user, + (accountId) => [ + ['Email/changes', { accountId, sinceState, maxChanges }, 'c'], + ], + options, + // 엔진이 state 모양을 받지 않아 요청 전체를 거절해도 셀 수 없는 것으로 본다 (아래 catch) + { invalidRequestCode: 'VAL_001' }, + ); + } catch (error) { + if ( + error instanceof BusinessException && + error.code === 'VAL_001' && + error.context?.reason === 'request-rejected' + ) { + return { kind: 'cannotCalculate' }; + } + throw error; + } + const result = findResult(responses, 'c', 'Email/changes'); + if (!result.ok) { + const type = result.error.type; + if (type === CANNOT_CALCULATE_CHANGES || type === INVALID_ARGUMENTS) { + return { kind: 'cannotCalculate' }; + } + throw jmapFailure(result.error, { method: 'Email/changes' }); + } + const changes = result.args as JmapChangesResult; + return { + kind: 'changes', + created: strings(changes.created), + updated: strings(changes.updated), + destroyed: strings(changes.destroyed), + newState: this.requireState(changes.newState), + hasMore: changes.hasMoreChanges === true, + }; + } + + async documents( + user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise { + const documents: IndexDocument[] = []; + for (const chunk of chunks(ids, MAX_IDS_PER_GET)) { + const emails = await this.readEmails( + user, + chunk, + DOCUMENT_PROPERTIES, + DOCUMENT_PART_PROPERTIES, + options, + ); + const withParts = emails.map((email) => ({ + id: email.id, + parts: indexTextParts(email), + size: typeof email.size === 'number' ? email.size : 0, + })); + const bodies = new Map(); + for (const batch of indexBodyBatches(withParts)) { + for (const email of await this.readBodies(user, batch, options)) { + bodies.set( + email.id, + indexBodyText(indexTextParts(email), email.bodyValues), + ); + } + } + for (const email of emails) { + documents.push(this.toDocument(email, bodies.get(email.id) ?? '')); + } + } + return documents; + } + + async mutable( + user: string, + ids: readonly string[], + options: IndexReadOptions, + ): Promise { + const found: IndexMutable[] = []; + for (const chunk of chunks(ids, MAX_IDS_PER_GET)) { + const emails = await this.readEmails( + user, + chunk, + ['id', 'mailboxIds', 'keywords'], + null, + options, + ); + for (const email of emails) { + found.push({ + id: email.id, + mailboxIds: trueKeys(email.mailboxIds), + keywords: trueKeys(email.keywords), + }); + } + } + return found; + } + + async pageIds( + user: string, + from: { anchor: string | null; position: number }, + limit: number, + options: IndexReadOptions, + ): Promise { + if (from.anchor !== null) { + const anchored = await this.query( + user, + NEWEST_FIRST, + { anchor: from.anchor }, + limit, + options, + ); + if (anchored !== null) return anchored; + } + // anchor 가 없거나 그사이 지워졌다 — 위치로 + const page = await this.query( + user, + NEWEST_FIRST, + { position: Math.max(0, from.position) }, + limit, + options, + ); + return page ?? { ids: [], position: from.position, total: null }; + } + + async allIds(user: string, options: IndexReadOptions): Promise { + for (let attempt = 0; attempt <= ALL_IDS_RESTARTS; attempt += 1) { + const { state } = await this.snapshot(user, options); + const ids: string[] = []; + let anchor: string | null = null; + let restarted = false; + for (;;) { + const page = await this.query( + user, + OLDEST_FIRST, + anchor === null ? { position: 0 } : { anchor }, + ALL_IDS_PAGE, + options, + ); + if (page === null) { + restarted = true; + break; + } + if (page.ids.length === 0) break; + ids.push(...page.ids); + anchor = page.ids[page.ids.length - 1]; + } + if (!restarted) return { state, ids }; + } + throw new BusinessException('MAIL_001', { + context: { reason: 'index-all-ids-anchor-lost' }, + }); + } + + /** + * 받은 시각 차례로 한 쪽 — anchor 가 결과에 없으면(anchorNotFound) null (부른 쪽이 위치로 · 처음부터). + * 위치 · anchor 둘 다 total 을 센다 + */ + private async query( + user: string, + sort: readonly { property: string; isAscending: boolean }[], + start: { anchor: string } | { position: number }, + limit: number, + options: IndexReadOptions, + ): Promise { + const responses = await this.call( + user, + (accountId) => [ + [ + 'Email/query', + { + accountId, + sort, + ...('anchor' in start + ? { anchor: start.anchor, anchorOffset: 1 } + : { position: start.position }), + limit, + calculateTotal: true, + }, + 'q', + ], + ], + options, + ); + const result = findResult(responses, 'q', 'Email/query'); + if (!result.ok) { + if (result.error.type === ANCHOR_NOT_FOUND && 'anchor' in start) { + return null; + } + throw jmapFailure(result.error, { method: 'Email/query' }); + } + const query = result.args as JmapQueryResult; + return { + ids: strings(query.ids), + position: + typeof query.position === 'number' + ? query.position + : 'position' in start + ? start.position + : 0, + total: typeof query.total === 'number' ? query.total : null, + }; + } + + private async readEmails( + user: string, + ids: readonly string[], + properties: readonly string[], + bodyProperties: readonly string[] | null, + options: IndexReadOptions, + ): Promise { + if (ids.length === 0) return []; + const responses = await this.call( + user, + (accountId) => [ + [ + 'Email/get', + { + accountId, + ids: [...ids], + properties: [...properties], + ...(bodyProperties ? { bodyProperties: [...bodyProperties] } : {}), + }, + 'g', + ], + ], + options, + ); + const { list = [] } = expectResult>( + responses, + 'g', + 'Email/get', + ); + return list.filter( + (email): email is JmapEmail => + typeof email?.id === 'string' && email.id !== '', + ); + } + + /** 본문 값 — textBody 의 글자 부분, 부분마다 그 몫까지 */ + private async readBodies( + user: string, + batch: BodyBatch, + options: IndexReadOptions, + ): Promise { + const responses = await this.call( + user, + (accountId) => [ + [ + 'Email/get', + { + accountId, + ids: batch.ids, + properties: ['id', 'textBody', 'bodyValues'], + bodyProperties: ['partId', 'type'], + fetchTextBodyValues: true, + maxBodyValueBytes: batch.quota, + }, + 'b', + ], + ], + options, + ); + const { list = [] } = expectResult>( + responses, + 'b', + 'Email/get', + ); + return list.filter((email) => typeof email?.id === 'string'); + } + + private toDocument(email: JmapEmail, bodyText: string): IndexDocument { + const attachments = email.attachments ?? []; + return { + id: email.id, + threadId: typeof email.threadId === 'string' ? email.threadId : null, + mailboxIds: trueKeys(email.mailboxIds), + keywords: trueKeys(email.keywords), + receivedAt: typeof email.receivedAt === 'string' ? email.receivedAt : '', + size: typeof email.size === 'number' ? email.size : 0, + messageIds: strings(email.messageId), + inReplyTo: strings(email.inReplyTo), + references: strings(email.references), + subject: typeof email.subject === 'string' ? email.subject : '', + from: toAddresses(email.from), + to: toAddresses(email.to), + cc: toAddresses(email.cc), + attachmentNames: indexAttachmentNames(attachments), + hasAttachment: hasRealAttachment(attachments), + preview: typeof email.preview === 'string' ? email.preview : '', + bodyText, + }; + } + + /** + * 엔진 읽기 한 번 — 차례의 끝 시각까지 남은 시간이 INDEX_CALL_MIN_MS 보다 적으면 시작하지 않는다(IndexDeadlineError). + * 기다림은 남은 시간 · INDEX_CALL_TIMEOUT_MS 가운데 짧은 쪽이다 — 남은 시간으로 줄인 기다림이 끝나 끊겼으면 그것도 IndexDeadlineError + * (엔진이 느린 것이 아니라 차례가 끝난 것이다). 그 밖의 실패는 그대로 던진다 + */ + private async call( + user: string, + build: (accountId: string) => JmapInvocation[], + options: IndexReadOptions, + extra: Omit = {}, + ): Promise { + const remaining = options.deadline - Date.now(); + if (remaining < INDEX_CALL_MIN_MS) throw new IndexDeadlineError(); + const cappedByDeadline = remaining < INDEX_CALL_TIMEOUT_MS; + try { + return await this.jmap.call(user, build, { + ...extra, + timeoutMs: Math.min(remaining, INDEX_CALL_TIMEOUT_MS), + }); + } catch (error) { + if (cappedByDeadline && isTimeout(error)) throw new IndexDeadlineError(); + throw error; + } + } + + private requireState(state: unknown): string { + if (!isState(state)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'index-state-missing' }, + }); + } + return state; + } +} 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 cf213ac..6578061 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -9,6 +9,7 @@ import { BusinessException } from '@/common/errors/business.exception'; import { MAX_BODY_VALUE_BYTES, MAX_ENVELOPE_RESUBMITS, + MAX_SEARCH_WORDS, } from '@/modules/mail/mail.constants'; import { SendNotStartedException } from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; @@ -29,6 +30,7 @@ import { } from './jmap.client'; import type { JmapClient, JmapSession } from './jmap.client'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { JmapConversations } from './jmap-conversations'; import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch'; @@ -38,7 +40,8 @@ import { parseLargeFileIds, } from './jmap-mail.provider'; import { MAILBOX_PROPERTIES } from './jmap.mapper'; -import type { JmapInvocation } from './jmap.types'; +import type { JmapEmailFilter, JmapInvocation } from './jmap.types'; +import { particleForms } from './search-terms'; const ARA = 'ara.kim@arasystem.test'; const DOYUN = 'doyun.lee@arasystem.test'; @@ -194,6 +197,7 @@ function fakeClient( overrides.settings ?? settings, new JmapFilterScriptStore(client as unknown as JmapClient), new JmapFilterRunner(client as unknown as JmapClient), + new JmapConversations(client as unknown as JmapClient), ), }; } @@ -871,6 +875,298 @@ describe('JmapMailProvider', () => { }); }); + describe('조건 검색 · 색인 검색의 엔진 쪽 (14단계)', () => { + const searchResponses = ( + ids: string[], + total: number, + ): JmapInvocation[] => [ + ['Email/query', { ids, position: 0, total, queryState: 's' }, 'q'], + [ + 'Email/get', + { + list: ids.map((id) => summaryEmail(id, '2026-10-26T09:00:00Z')), + notFound: [], + }, + 'g', + ], + ]; + const mailboxRoles: JmapInvocation[] = [ + [ + 'Mailbox/get', + { list: stalwartMailboxes.map(({ id, role }) => ({ id, role })) }, + 'm', + ], + ]; + + it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => { + const { provider, recorded } = fakeClient([ + mailboxRoles, + searchResponses(['m1'], 1), + ]); + + const page = await provider.listMessages(ARA, { + limit: 20, + conditions: { + from: '이도윤', + to: 'ara', + subject: '"계약 갱신"', + body: '견적', + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }, + }); + + const { filter } = argsOf(recorded[1]) as { + filter: { operator: string; conditions: unknown[] }; + }; + expect(filter.operator).toBe('AND'); + expect(filter.conditions[0]).toEqual({ + inMailboxOtherThan: ['b', 'c'], + notKeyword: '$ara-sending', + }); + expect(filter.conditions.slice(1)).toEqual([ + { + operator: 'OR', + conditions: particleForms('이도윤').map((from) => ({ from })), + }, + { + operator: 'OR', + conditions: particleForms('ara').flatMap((form) => [ + { to: form }, + { cc: form }, + ]), + }, + { subject: '"계약 갱신"' }, + { + operator: 'OR', + conditions: particleForms('견적').map((body) => ({ body })), + }, + { after: '2026-10-31T15:00:00Z' }, + { before: '2026-11-30T15:00:00Z' }, + { hasAttachment: true }, + ]); + expect(page.items.map((item) => item.id)).toEqual(['m1']); + }); + + it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => { + const conditions = { from: '이도윤', hasAttachment: true as const }; + const first = fakeClient([ + mailboxRoles, + searchResponses(['m1', 'm2'], 3), + ]); + const page = await first.provider.listMessages(ARA, { + limit: 2, + text: '회의', + conditions, + }); + const { filter } = argsOf(first.recorded[1]) as { + filter: { conditions: unknown[] }; + }; + // 범위 · 검색어 · 보낸 사람 · 첨부 + expect(filter.conditions).toHaveLength(4); + const cursor = page.nextCursor as string; + expect(cursor).not.toBeNull(); + + for (const query of [ + { limit: 2, text: '회의', cursor }, + { limit: 2, text: '회의', cursor, conditions: { from: '이도윤' } }, + { + limit: 2, + text: '회의', + cursor, + conditions: { ...conditions, before: '2026-11-30T15:00:00.000Z' }, + }, + ]) { + const other = fakeClient([]); + expect(await codeOf(other.provider.listMessages(ARA, query))).toBe( + 'VAL_001', + ); + expect(other.client.call).not.toHaveBeenCalled(); + } + const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]); + await next.provider.listMessages(ARA, { + limit: 2, + text: '회의', + cursor, + conditions, + }); + expect(argsOf(next.recorded[1])).toMatchObject({ anchor: 'm2' }); + }); + + it('조건만 있는 검색(검색어 없음)도 모든 메일함에서 · 조건 없는 검색의 커서 지문은 예전과 같다', async () => { + const { provider, recorded } = fakeClient([ + mailboxRoles, + searchResponses([], 0), + ]); + await provider.listMessages(ARA, { + limit: 20, + conditions: { hasAttachment: true }, + }); + const { filter } = argsOf(recorded[1]) as { + filter: { conditions: unknown[] }; + }; + expect(filter.conditions).toEqual([ + { inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' }, + { hasAttachment: true }, + ]); + + const plainCursor = encodeCursor( + { anchor: 'm2', position: 2 }, + { text: '회의' }, + ); + const plain = fakeClient([mailboxRoles, searchResponses([], 0)]); + await expect( + plain.provider.listMessages(ARA, { + limit: 2, + text: '회의', + cursor: plainCursor, + conditions: {}, + }), + ).resolves.toMatchObject({ items: [] }); + }); + + it(`엔진 방식의 가장 큰 필터를 고정한다 — 합친 낱말 ${MAX_SEARCH_WORDS}개가 모두 받는 사람(to · cc 둘로 묻는다)이고 영문 · 숫자로 끝나면(형태가 가장 많다) 잎 948개 (리뷰 M2)`, async () => { + /** 필터의 잎(연산자가 아닌 조건) 수 */ + const leaves = (filter: JmapEmailFilter): number => + 'operator' in filter + ? filter.conditions.reduce((sum, child) => sum + leaves(child), 0) + : 1; + const words = Array.from( + { length: MAX_SEARCH_WORDS }, + (_, index) => `word${index}`, + ).join(' '); + const filterOf = async (query: { + text?: string; + conditions?: Record; + }) => { + const { provider, recorded } = fakeClient([ + mailboxRoles, + searchResponses([], 0), + ]); + await provider.listMessages(ARA, { limit: 20, ...query }); + return (argsOf(recorded[1]) as { filter: JmapEmailFilter }).filter; + }; + const forms = particleForms('word0').length; + expect(forms).toBe(59); + + const worst = await filterOf({ + conditions: { + to: words, + after: '2026-10-31T15:00:00.000Z', + before: '2026-11-30T15:00:00.000Z', + hasAttachment: true, + }, + }); + // 낱말 8 × 형태 59 × 칸 2(to · cc) + 범위 1 + 기간 2 + 첨부 1 + expect(leaves(worst)).toBe(MAX_SEARCH_WORDS * forms * 2 + 4); + expect(leaves(worst)).toBe(948); + // 같은 낱말을 검색어로 두면 칸이 하나 — 3단계에서 잰 472 + 범위 + expect(leaves(await filterOf({ text: words }))).toBe( + MAX_SEARCH_WORDS * forms + 1, + ); + }); + + it('글 조건에 찾을 낱말이 하나도 없으면(문장부호뿐) 엔진에 묻지 않고 빈 결과', async () => { + for (const conditions of [{ from: '…' }, { body: '""' }, { to: '-' }]) { + const { provider, client } = fakeClient([]); + await expect( + provider.listMessages(ARA, { limit: 20, conditions }), + ).resolves.toEqual({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + expect(client.call).not.toHaveBeenCalled(); + } + }); + + it('색인 검색의 범위 (searchScope) — 모든 메일함이면 휴지통 · 스팸메일함을 빼는 메일함으로, 내부 초안 키워드는 늘 뺀다', async () => { + const { provider, recorded } = fakeClient([mailboxRoles]); + + await expect(provider.searchScope(ARA)).resolves.toEqual({ + mailboxId: null, + excludedMailboxIds: ['b', 'c'], + excludedKeywords: ['$ara-sending'], + }); + expect(argsOf(recorded[0])).toEqual({ + accountId: 'd', + ids: null, + properties: ['id', 'role'], + }); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'MAIL_001' }); + }); + + it('색인 검색의 범위 — 메일함 범위면 그 메일함이 있는지만 본다(없으면 404 RES_001 · 엔진이 해석하지 못하는 id 도 RES_001)', async () => { + const found = fakeClient([ + [['Mailbox/get', { list: [{ id: 'a', role: 'inbox' }] }, 'm']], + ]); + await expect(found.provider.searchScope(ARA, 'a')).resolves.toEqual({ + mailboxId: 'a', + excludedMailboxIds: [], + excludedKeywords: ['$ara-sending'], + }); + expect(argsOf(found.recorded[0])).toMatchObject({ ids: ['a'] }); + expect(found.recorded[0].options).toEqual({ + invalidRequestCode: 'RES_001', + }); + + const missing = fakeClient([ + [['Mailbox/get', { list: [], notFound: ['zz'] }, 'm']], + ]); + expect(await codeOf(missing.provider.searchScope(ARA, 'zz'))).toBe( + 'RES_001', + ); + }); + + it('찾은 쪽의 요약 (getSummaries) — 목록과 같은 속성 · 기다리는 발송을 한 요청에, 준 차례대로 · 없는 메일 · 형식이 틀린 id 는 빠진다', async () => { + const { provider, recorded } = fakeClient([ + [ + [ + 'Email/get', + { + list: [ + summaryEmail('m2', '2026-10-26T08:00:00Z'), + summaryEmail('m1', '2026-10-26T09:00:00Z'), + ], + notFound: ['m3'], + }, + 'g', + ], + ...heldGuard(), + ], + ]); + + const summaries = await provider.getSummaries(ARA, [ + 'm1', + 'm3', + '../x', + 'm2', + 'm1', + ]); + + expect(summaries.map((summary) => summary.id)).toEqual(['m1', 'm2']); + expect(summaries[0]).toMatchObject({ + subject: '제목 m1', + pendingSend: null, + }); + expect(recorded[0].calls.map(([method]) => method)).toEqual([ + 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[0])).toMatchObject({ ids: ['m1', 'm3', 'm2'] }); + }); + + it('요약할 id 가 없으면 엔진에 묻지 않는다', async () => { + const { provider, client } = fakeClient([]); + await expect(provider.getSummaries(ARA, [])).resolves.toEqual([]); + await expect(provider.getSummaries(ARA, ['../x'])).resolves.toEqual([]); + expect(client.call).not.toHaveBeenCalled(); + }); + }); + describe('메일 상세 (getMessage)', () => { const detailEmail = { ...summaryEmail('m1', '2026-10-26T05:12:00Z', { hasAttachment: true }), diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index bbfbfac..1079181 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -16,9 +16,15 @@ import { type MailProvider, } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import type { + SearchConditions, + SearchScope, +} from '@/modules/mail-search/mail-search.types'; import type { AttachmentContent, BlockedSender, + ConversationListQuery, + ConversationSummaryPage, Correspondence, CredentialCheck, DraftContent, @@ -62,6 +68,7 @@ import type { SendHold, SentMessage, SentMessageRecord, + SummariesWithHeld, TwoFactorLoginResult, UploadedFile, } from '@/modules/mail/mail.types'; @@ -91,6 +98,7 @@ import { submissionEnvelope, } from './envelope'; import { decodeCursor, encodeCursor } from './jmap-cursor'; +import { JmapConversations } from './jmap-conversations'; import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { @@ -126,6 +134,7 @@ import { selectAccountQuota, toAddresses, toDetailSource, + toJmapUtcDate, toMailbox, toSummary, toUsage, @@ -148,14 +157,13 @@ import type { JmapSetResult, } from './jmap.types'; import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail'; +import { INTERNAL_SEND_KEYWORD, excludedMailboxIdsOf } from './mail-scope'; import { CANCELED_SEND_KEYWORD, - HELD_SUBMISSIONS_MAX, SCHEDULED_SEND_KEYWORD, heldAmong, heldEmailsFilter, heldOverLimit, - heldQueryFull, heldSendTimes, heldSubmissionFilter, holdParameters, @@ -164,11 +172,17 @@ import { readHeldEmails, readHeldSendTimes, submissionCalls, + warnIfHeldQueryFull, submittedPendingSend, type HeldEmails, type HeldLookup, } from './pending-send'; -import { searchTextConditions, withSearchConditions } from './search-terms'; +import { + searchFieldConditions, + searchTextConditions, + withSearchConditions, + type SearchFieldProperty, +} from './search-terms'; const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota'; /** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */ @@ -182,15 +196,6 @@ const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob'; const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash'; const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`; -/** - * 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면 - * 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 — - * 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). - * 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다 - * (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft). - */ -const INTERNAL_SEND_KEYWORD = '$ara-sending'; - /** * 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장"). * 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다. @@ -273,8 +278,60 @@ const BULK_BATCH_SIZE = 500; const SOURCE_TYPE = 'message/rfc822'; const SOURCE_DOWNLOAD_NAME = 'message.eml'; -/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */ -const SEARCH_EXCLUDED_ROLES: ReadonlySet = new Set(['trash', 'junk']); +/** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */ +const CONDITION_PROPERTIES: readonly (readonly [ + keyof Pick, + readonly SearchFieldProperty[], +])[] = [ + ['from', ['from']], + ['to', ['to', 'cc']], + ['subject', ['subject']], + ['body', ['body']], +]; + +/** 조건 → 커서 지문에 넣는 값들 (정해진 차례 — jmap-cursor.ts 가 JSON 으로 잇는다) — 조건이 없으면 undefined (지문이 예전과 같다) */ +function conditionsKey( + conditions: SearchConditions | undefined, +): (string | boolean)[] | undefined { + if (!conditions) return undefined; + const parts = [ + conditions.from ?? '', + conditions.to ?? '', + conditions.subject ?? '', + conditions.body ?? '', + conditions.after ?? '', + conditions.before ?? '', + conditions.hasAttachment === true, + ]; + return parts.some((part) => part !== '' && part !== false) + ? parts + : undefined; +} + +/** + * 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정. + * 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과) + */ +function conditionFilters( + conditions: SearchConditions | undefined, +): JmapEmailFilter[] | null { + if (!conditions) return []; + const filters: JmapEmailFilter[] = []; + for (const [name, properties] of CONDITION_PROPERTIES) { + const value = conditions[name]; + if (!value) continue; + const found = searchFieldConditions(value, properties); + if (found.length === 0) return null; + filters.push(...found); + } + if (conditions.after) + filters.push({ after: toJmapUtcDate(conditions.after) }); + if (conditions.before) { + filters.push({ before: toJmapUtcDate(conditions.before) }); + } + if (conditions.hasAttachment === true) filters.push({ hasAttachment: true }); + return filters; +} /** * 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 @@ -807,6 +864,8 @@ export class JmapMailProvider implements MailProvider { private readonly filterScripts: JmapFilterScriptStore, /** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */ private readonly filterRuns: JmapFilterRunner, + /** 엔진의 스레드로 본 대화 (jmap-conversations.ts — 14단계, 자체 색인을 다 만들기 전) */ + private readonly conversations: JmapConversations, ) {} verifyCredentials(email: string, password: string): Promise { @@ -1419,15 +1478,23 @@ export class JmapMailProvider implements MailProvider { user: string, query: MessageListQuery, ): Promise { - const scope = { mailboxId: query.mailboxId, text: query.text }; - // 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001 + const scope = { + mailboxId: query.mailboxId, + text: query.text, + conditions: conditionsKey(query.conditions), + }; + // 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어·조건의 커서면 VAL_001 const resume = query.cursor ? decodeCursor(query.cursor, scope) : null; - // 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다. + // 검색어 · 조건의 글에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다. // 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색") const textConditions = query.text !== undefined ? searchTextConditions(query.text) : []; - if (query.text !== undefined && textConditions.length === 0) { + const fieldConditions = conditionFilters(query.conditions); + if ( + (query.text !== undefined && textConditions.length === 0) || + fieldConditions === null + ) { return { items: [], hasNext: false, nextCursor: null, total: 0 }; } @@ -1439,7 +1506,7 @@ export class JmapMailProvider implements MailProvider { : await this.searchableMailboxes(user); const filter = withSearchConditions( { ...range, notKeyword: INTERNAL_SEND_KEYWORD }, - textConditions, + [...textConditions, ...fieldConditions], ); // 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts) @@ -1489,7 +1556,7 @@ export class JmapMailProvider implements MailProvider { const start = result.position ?? 0; const total = typeof result.total === 'number' ? result.total : null; const held = readHeldSendTimes(responses, HELD_CALLS, now); - this.warnIfHeldQueryFull(responses, user); + warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user); // Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다 const byId = new Map(list.map((email) => [email.id, email])); @@ -1516,6 +1583,160 @@ export class JmapMailProvider implements MailProvider { }; } + /** + * 자체 색인으로 찾을 범위 (14단계) — 메일함 범위면 그 메일함이 있는지(없으면 RES_001 — 엔진이 해석하지 못하는 id 도), 모든 메일함이면 빼는 메일함(휴지통 · 스팸메일함). + * 발송 중인 내부 초안의 키워드는 늘 뺀다 (목록과 같다). 엔진 요청 한 번 + */ + async searchScope(user: string, mailboxId?: string): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { + accountId, + ids: mailboxId === undefined ? null : [mailboxId], + properties: ['id', 'role'], + }, + 'm', + ], + ], + { invalidRequestCode: mailboxId === undefined ? 'MAIL_001' : 'RES_001' }, + ); + const { list: mailboxes = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + if (mailboxId !== undefined) { + if (!mailboxes.some((mailbox) => mailbox.id === mailboxId)) { + throw new BusinessException('RES_001', { + context: { reason: 'mailbox-not-found' }, + }); + } + return { + mailboxId, + excludedMailboxIds: [], + excludedKeywords: [INTERNAL_SEND_KEYWORD], + }; + } + return { + mailboxId: null, + excludedMailboxIds: excludedMailboxIdsOf(mailboxes), + excludedKeywords: [INTERNAL_SEND_KEYWORD], + }; + } + + /** + * 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다(대화의 통 수 · 같은 대화). + * `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001 — 엔진이 해석하지 못하는 id 도). 엔진 요청 한 번 + */ + async conversationScope( + user: string, + mailboxId?: string, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ], + { invalidRequestCode: 'MAIL_001' }, + ); + const { list: mailboxes = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + if ( + mailboxId !== undefined && + !mailboxes.some((mailbox) => mailbox.id === mailboxId) + ) { + throw new BusinessException('RES_001', { + context: { reason: 'mailbox-not-found' }, + }); + } + return { + mailboxId: mailboxId ?? null, + excludedMailboxIds: excludedMailboxIdsOf(mailboxes), + excludedKeywords: [INTERNAL_SEND_KEYWORD], + }; + } + + getConversation( + user: string, + emailId: string, + page: { readonly limit: number; readonly cursor?: string }, + ): Promise { + return this.conversations.conversation(user, emailId, page); + } + + listConversations( + user: string, + query: ConversationListQuery, + ): Promise { + return this.conversations.list(user, query); + } + + /** + * 요약 여러 통 (14단계 — 색인으로 찾은 쪽) — 목록과 같은 속성 · 기다리는 발송. `ids` 차례대로, 없는 메일은 빠진다. + * 엔진이 해석하지 못하는 id 는 받지 않는다(색인의 id 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다) + */ + async getSummaries( + user: string, + ids: readonly string[], + ): Promise { + if (!ids.some((id) => MAIL_ID_PATTERN.test(id))) return []; + return (await this.getSummariesWithHeld(user, ids)).summaries; + } + + /** + * 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식) — 같은 요청에서 계정의 기다리는 제출을 한 번 읽는다(보이기 경로 — + * readHeldSendTimes, 읽지 못하면 빈 표). 요약이 없어도(ids 가 비거나 모두 형식이 틀림) 기다리는 제출은 읽는다 + */ + async getSummariesWithHeld( + user: string, + ids: readonly string[], + ): Promise { + const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id)); + const now = Date.now(); + const responses = await this.jmap.call(user, (accountId) => [ + ...(valid.length > 0 + ? ([ + [ + 'Email/get', + { + accountId, + ids: valid, + properties: SUMMARY_PROPERTIES, + bodyProperties: SUMMARY_BODY_PART_PROPERTIES, + }, + 'g', + ], + ] satisfies JmapInvocation[]) + : []), + ...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS), + ]); + const list = + valid.length > 0 + ? (expectResult>(responses, 'g', 'Email/get') + .list ?? []) + : []; + const held = readHeldSendTimes(responses, HELD_CALLS, now); + warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user); + const byId = new Map(list.map((email) => [email.id, email])); + return { + summaries: valid.flatMap((id) => { + const email = byId.get(id); + return email ? [toSummary(email, pendingSendOf(email, held))] : []; + }), + heldIds: new Set(held.keys()), + }; + } + /** * 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다. * 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다 @@ -2605,7 +2826,7 @@ export class JmapMailProvider implements MailProvider { 'g', 'Email/get', ); - this.warnIfHeldQueryFull(responses, user); + warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user); const times = heldSendTimes(submissions, now); return emails .flatMap((email) => { @@ -3615,11 +3836,7 @@ export class JmapMailProvider implements MailProvider { 'm', 'Mailbox/get', ); - const excluded = mailboxes - .filter((mailbox) => - SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''), - ) - .map((mailbox) => mailbox.id); + const excluded = excludedMailboxIdsOf(mailboxes); return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {}; } @@ -3878,17 +4095,6 @@ export class JmapMailProvider implements MailProvider { ); } - /** - * 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull) - * — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX) - */ - private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void { - if (!heldQueryFull(responses, HELD_CALLS)) return; - this.logger.warn( - `[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`, - ); - } - /** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */ private async submissionsOf( user: string, diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index c848242..ebfeb40 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -221,8 +221,18 @@ export interface JmapEmailFilterCondition { notKeyword?: string; /** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */ before?: string; + /** 받은 시각이 이 이후(이상)인 메일 — UTCDate (14단계 조건 검색의 기간) */ + after?: string; /** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */ text?: string; + /** 칸마다의 전문 검색 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — 낱말 통째 일치는 text 와 같다 */ + from?: string; + to?: string; + cc?: string; + subject?: string; + body?: string; + /** 엔진의 첨부 판정 (본문 그림만 있어도 true 일 수 있다 — 색인 방식과 조금 다르다) */ + hasAttachment?: boolean; } /** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */ diff --git a/backend/src/modules/mail/jmap/mail-scope.ts b/backend/src/modules/mail/jmap/mail-scope.ts new file mode 100644 index 0000000..cdf9662 --- /dev/null +++ b/backend/src/modules/mail/jmap/mail-scope.ts @@ -0,0 +1,27 @@ +import type { JmapMailbox } from './jmap.types'; + +/** + * 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면 + * 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록 · 검색 · 대화는 이 키워드가 있는 메일을 보이지 않는다 — + * 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). + * 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다 + * (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft). + */ +export const INTERNAL_SEND_KEYWORD = '$ara-sending'; + +/** 메일함을 정하지 않은 검색 · 같은 대화 · 대화의 통 수에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */ +export const SEARCH_EXCLUDED_ROLES: ReadonlySet = new Set([ + 'trash', + 'junk', +]); + +/** 빼는 역할(휴지통 · 스팸메일함)의 메일함 id */ +export function excludedMailboxIdsOf( + mailboxes: readonly Pick[], +): string[] { + return mailboxes + .filter((mailbox) => + SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''), + ) + .map((mailbox) => mailbox.id); +} diff --git a/backend/src/modules/mail/jmap/pending-send.ts b/backend/src/modules/mail/jmap/pending-send.ts index 5024eff..33e1d01 100644 --- a/backend/src/modules/mail/jmap/pending-send.ts +++ b/backend/src/modules/mail/jmap/pending-send.ts @@ -1,5 +1,9 @@ +import type { Logger } from '@nestjs/common'; + import { BusinessException } from '@/common/errors/business.exception'; import type { PendingSend, SendHold } from '@/modules/mail/mail.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; import { toJmapUtcDate, toUtcIso } from './jmap.mapper'; import { expectResult, findResult } from './jmap-results'; import type { @@ -212,6 +216,22 @@ export function heldQueryFull( } } +/** + * 보이기 경로(목록 · 예약 목록 · 대화)의 계정 전체 제출 조회가 다 주지 못했으면(heldQueryFull) 경고만 남긴다 — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다. + * 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX). 로그는 코드 · 추적 ID · 가린 주소만 + */ +export function warnIfHeldQueryFull( + logger: Logger, + responses: JmapInvocation[], + prefix: string, + user: string, +): void { + if (!heldQueryFull(responses, prefix)) return; + logger.warn( + `[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`, + ); +} + /** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */ export interface HeldEmails { /** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */ diff --git a/backend/src/modules/mail/jmap/search-terms.spec.ts b/backend/src/modules/mail/jmap/search-terms.spec.ts index af40546..9251f60 100644 --- a/backend/src/modules/mail/jmap/search-terms.spec.ts +++ b/backend/src/modules/mail/jmap/search-terms.spec.ts @@ -2,6 +2,7 @@ import { SEARCH_PARTICLES, parseSearchTerms, particleForms, + searchFieldConditions, searchTextConditions, withSearchConditions, } from './search-terms'; @@ -365,4 +366,57 @@ describe('검색어 → 엔진 조건 (search-terms)', () => { ); }); }); + + describe('칸 조건 (searchFieldConditions — 14단계 조건 검색의 엔진 방식)', () => { + it('칸 하나 — 낱말은 그 칸의 형태 OR, 구절은 그 칸의 따옴표 구절', () => { + const [word, phrase] = searchFieldConditions('계약 "갱신 일정"', [ + 'subject', + ]); + expect(word).toEqual({ + operator: 'OR', + conditions: particleForms('계약').map((subject) => ({ subject })), + }); + expect(phrase).toEqual({ subject: '"갱신 일정"' }); + }); + + it('칸이 여럿(받는 사람 = to · cc)이면 형태마다 두 칸의 OR — 낱말끼리는 AND(목록의 차례)', () => { + const conditions = searchFieldConditions('ara 東京', ['to', 'cc']); + expect(conditions).toHaveLength(2); + expect(conditions[0]).toEqual({ + operator: 'OR', + conditions: particleForms('ara').flatMap((form) => [ + { to: form }, + { cc: form }, + ]), + }); + // 형태가 하나뿐이어도 칸이 둘이면 OR + expect(conditions[1]).toEqual({ + operator: 'OR', + conditions: [{ to: '東京' }, { cc: '東京' }], + }); + }); + + it('검색어 조건(searchTextConditions)은 text 칸 하나의 칸 조건과 같다 · 찾을 낱말이 없으면 빈 목록', () => { + expect(searchTextConditions('서버 "이전 계획"')).toEqual( + searchFieldConditions('서버 "이전 계획"', ['text']), + ); + expect(searchFieldConditions('… -', ['body'])).toEqual([]); + }); + }); + + it('검색 글의 NUL 은 U+FFFD 로 1:1 바꾼다 — 색인의 저장과 같고 엔진 방식도 같은 낱말로 묻는다 (리뷰 L2)', () => { + const NUL = String.fromCharCode(0); + const FFFD = String.fromCharCode(0xfffd); + expect(parseSearchTerms(`계${NUL}약 "a${NUL}b"`)).toEqual([ + { kind: 'word', text: `계${FFFD}약` }, + { kind: 'phrase', text: `a${FFFD}b` }, + ]); + // 엔진에 보내는 조건(JSON)에 NUL 이 없다 — 이스케이프한 꼴도 없다 + const sent = JSON.stringify(searchTextConditions(`계${NUL}약`)); + expect(sent.includes(NUL)).toBe(false); + expect(sent).not.toContain(`${String.fromCharCode(92)}u0000`); + expect(sent).toContain(`계${FFFD}약`); + // NUL 만이면 찾을 글자가 없다 — 빈 목록 + expect(parseSearchTerms(NUL.repeat(3))).toEqual([]); + }); }); diff --git a/backend/src/modules/mail/jmap/search-terms.ts b/backend/src/modules/mail/jmap/search-terms.ts index 258f7fa..fa5b8d3 100644 --- a/backend/src/modules/mail/jmap/search-terms.ts +++ b/backend/src/modules/mail/jmap/search-terms.ts @@ -1,4 +1,4 @@ -import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants'; +import { parseSearchTerms } from '@/shared/utils/search-query'; import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types'; /** @@ -14,6 +14,9 @@ import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types'; * 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다. */ +/** 검색어 나누기는 엔진과 상관없어 shared/utils/search-query.ts 로 옮겼다(14단계 — 색인 찾기와 함께 쓴다) */ +export { parseSearchTerms, type SearchTerm } from '@/shared/utils/search-query'; + /** * 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절). * 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다 @@ -89,6 +92,9 @@ export const SEARCH_PARTICLES = { ], } as const; +/** 큰따옴표 — 구절 조건(`{ text: '"…"' }`)을 감싼다 */ +const QUOTE = '"'; + /** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */ const HANGUL_SYLLABLE_FIRST = 0xac00; const HANGUL_SYLLABLE_LAST = 0xd7a3; @@ -102,29 +108,6 @@ const EURO = '으로'; /** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */ const ASCII_ALNUM_END = /[A-Za-z0-9]$/; -/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */ -const QUOTE = '"'; - -/** - * 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}). - * 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다 - */ -const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u; - -/** - * 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다 - * ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다. - * 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고, - * 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다. - */ -const WORD_EDGE_PUNCTUATION = - /^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu; - -/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */ -function coreWord(word: string): string { - return word.replace(WORD_EDGE_PUNCTUATION, ''); -} - /** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */ type WordEnding = /** 받침 없는 한글 */ @@ -186,61 +169,46 @@ export function particleForms(word: string): string[] { ]); } -/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */ -export interface SearchTerm { - readonly kind: 'word' | 'phrase'; - readonly text: string; -} - -function splitWords(text: string): string[] { - return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== ''); -} - -/** - * 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만. - * - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로) - * - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다 - * - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR) - * - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다 - * - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) — - * 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다) - * - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다 - */ -export function parseSearchTerms(query: string): SearchTerm[] { - const segments = query.normalize('NFC').split(QUOTE); - const terms: SearchTerm[] = []; - const seen = new Set(); - const add = (kind: SearchTerm['kind'], text: string) => { - const key = `${kind}\n${text}`; - if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return; - seen.add(key); - terms.push({ kind, text }); - }; - - segments.forEach((segment, index) => { - // 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다) - const isPhrase = index % 2 === 1 && index < segments.length - 1; - if (isPhrase) { - add('phrase', splitWords(segment).join(' ')); - return; - } - for (const word of splitWords(segment)) add('word', coreWord(word)); - }); - return terms; -} - /** * 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다). * 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나). * 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다. */ export function searchTextConditions(query: string): JmapEmailFilter[] { + return searchFieldConditions(query, ['text']); +} + +/** 글로 찾는 엔진 필터 칸 — text(모든 칸) · 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 */ +export type SearchFieldProperty = + | 'text' + | 'from' + | 'to' + | 'cc' + | 'subject' + | 'body'; + +/** + * 칸 조건 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — searchTextConditions 와 같은 낱말 · 구절 · 조사 형태를 그 칸에서 찾는다. + * 칸이 여럿이면(받는 사람 = to · cc) 그 가운데 하나에 있으면 된다 — 낱말마다 (형태 × 칸)의 OR, 낱말끼리는 AND. + * 찾을 낱말이 하나도 없으면 빈 목록 + */ +export function searchFieldConditions( + query: string, + properties: readonly SearchFieldProperty[], +): JmapEmailFilter[] { return parseSearchTerms(query).map((term): JmapEmailFilter => { - if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` }; - const forms = particleForms(term.text); - return forms.length === 1 - ? { text: forms[0] } - : { operator: 'OR', conditions: forms.map((text) => ({ text })) }; + const forms = + term.kind === 'phrase' + ? [`${QUOTE}${term.text}${QUOTE}`] + : particleForms(term.text); + const conditions = forms.flatMap((form) => + properties.map( + (property): JmapEmailFilterCondition => ({ [property]: form }), + ), + ); + return conditions.length === 1 + ? conditions[0] + : { operator: 'OR', conditions }; }); } diff --git a/backend/src/modules/mail/mail-events.service.spec.ts b/backend/src/modules/mail/mail-events.service.spec.ts index 72fcc91..b306bec 100644 --- a/backend/src/modules/mail/mail-events.service.spec.ts +++ b/backend/src/modules/mail/mail-events.service.spec.ts @@ -15,6 +15,7 @@ import { type MailEventSink, } from './mail-events.service'; import type { RevokedSessions } from '@/modules/auth/revoked-sessions'; +import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; import type { MailProvider } from './mail.provider'; import type { MailLiveState, @@ -132,6 +133,8 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { let service: MailEventsService; /** 끊은 세션 기억 대역 — 테스트가 넣는다 */ let revokedIds: Set; + /** 검색 색인 맞추기 대역 (14단계) — 맞춰 달라는 직원을 모은다 */ + let searchSync: { requestSync: jest.Mock }; const connect = ( user: string, @@ -171,9 +174,11 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { const revoked = { isRevoked: (sessionId: string) => revokedIds.has(sessionId), }; + searchSync = { requestSync: jest.fn() }; service = new MailEventsService( fake.provider, revoked as unknown as RevokedSessions, + searchSync as unknown as MailSearchSyncService, ); }); @@ -207,6 +212,9 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { } expect(other.events).toHaveLength(1); + // 메일이 바뀐 소식 — 그 직원의 검색 색인을 맞춰 달라고 한다 (14단계 — 직원마다 한 번, 모으기는 맞추기 쪽이 한다) + expect(searchSync.requestSync.mock.calls).toEqual([[ARA]]); + // 나중에 들어온 연결은 마지막으로 알게 된 상태로 시작한다 (엔진에 다시 묻지 않는다) const late = await connected(ARA); expect(late.events).toEqual([ diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts index 484be6c..8a14ae0 100644 --- a/backend/src/modules/mail/mail-events.service.ts +++ b/backend/src/modules/mail/mail-events.service.ts @@ -8,6 +8,7 @@ import { import { BusinessException } from '@/common/errors/business.exception'; import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants'; import { RevokedSessions } from '@/modules/auth/revoked-sessions'; +import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; import { runOutsideRequestContext, traceTag, @@ -149,6 +150,8 @@ export class MailEventsService implements BeforeApplicationShutdown { constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, private readonly revokedSessions: RevokedSessions, + /** 14단계 — 엔진의 state 소식이 오면 그 직원의 검색 색인을 맞춘다 (300ms 모아서 — 던지지 않는다) */ + private readonly searchSync: MailSearchSyncService, ) {} /** @@ -312,6 +315,8 @@ export class MailEventsService implements BeforeApplicationShutdown { state: (live) => { hub.live = live; this.broadcast(hub, { type: 'state', data: live }); + // 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있는 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다) + this.searchSync.requestSync(hub.user); }, newMail: (notice) => this.broadcast(hub, { type: 'new-mail', data: notice }), diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index b355b15..a412474 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -56,8 +56,8 @@ export const MAX_SEARCH_LENGTH = 200; /** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */ export const MAX_SEARCH_WORDS = 8; -/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */ -export const SEARCH_WORD_SEPARATOR = /\s+/u; +/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 (shared/utils/search-query.ts) */ +export { SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-query'; /** * 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 — diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index 46f83be..d97c3ae 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -10,6 +10,7 @@ import { AuthSessionModule } from '@/modules/auth/auth-session.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; +import { MailSearchModule } from '@/modules/mail-search/mail-search.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; @@ -27,6 +28,7 @@ import { DnsMailImageResolver, NodeMailImageTransport, } from './images/node-image-transport'; +import { JmapConversations } from './jmap/jmap-conversations'; import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; @@ -55,6 +57,9 @@ import { MailVacationController, } from './mailbox.controller'; import { MessageDeletionController } from './message-deletion.controller'; +import { MessageListService } from './message-list.service'; +import { ConversationController } from './conversation.controller'; +import { MailConversationService } from './conversation.service'; import { MessageController } from './message.controller'; import { RecipientSuggestionController } from './recipient-suggestion.controller'; import { RecipientSuggestionService } from './recipient-suggestions.service'; @@ -127,6 +132,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { AuthSessionModule, // 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다 SystemMailModule, + // 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만) + MailSearchModule, ], controllers: [ MailboxController, @@ -138,6 +145,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailFilterRunController, MailBlockController, MessageController, + // 같은 대화 · 대화로 묶은 목록 (14단계) + ConversationController, MessageDeletionController, ScheduledMessageController, MailAttachmentController, @@ -162,8 +171,12 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { JmapFilterScriptStore, // 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다 JmapFilterRunner, + JmapConversations, { provide: MAIL_PROVIDER, useClass: JmapMailProvider }, MailService, + // 메일 목록 · 검색 (14단계 — 색인 방식 · 엔진 방식을 고르고 강조를 단다) + MessageListService, + MailConversationService, // 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown) MailEventsService, // 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file) diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 36150a8..76cc46a 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -1,7 +1,10 @@ import { BusinessException } from '@/common/errors/business.exception'; +import type { SearchScope } from '@/modules/mail-search/mail-search.types'; import type { AttachmentContent, BlockedSender, + ConversationListQuery, + ConversationSummaryPage, Correspondence, CredentialCheck, DraftContent, @@ -40,6 +43,7 @@ import type { SavedDraft, SentMessage, SentMessageRecord, + SummariesWithHeld, TwoFactorLoginResult, UploadedFile, } from './mail.types'; @@ -254,6 +258,51 @@ export interface MailProvider { */ listMessages(user: string, query: MessageListQuery): Promise; + /** + * 자체 색인으로 찾을 범위 (14단계 — 색인 방식 검색) — 메일함 하나(없으면 RES_001), 또는 휴지통 · 스팸메일함만 뺀 모든 메일함과 + * 목록에서 빼는 키워드(발송 중인 내부 초안). 엔진의 메일함 역할 · 키워드 이름은 어댑터만 안다 + */ + searchScope(user: string, mailboxId?: string): Promise; + + /** + * 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다. + * `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001) + */ + conversationScope(user: string, mailboxId?: string): Promise; + + /** + * 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때) + */ + getSummaries(user: string, ids: readonly string[]): Promise; + + /** + * 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식: 줄의 messageIds 가운데 붙잡힌 메일 pendingSendIds). + * getSummaries 와 같은 요청 하나 — 기다리는 제출은 한 번만 읽는다 + */ + getSummariesWithHeld( + user: string, + ids: readonly string[], + ): Promise; + + /** + * 같은 대화 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 + * 지금 보는 메일(`emailId`)은 넣는다. 그 메일이 없으면 RES_001. 커서는 이 메일의 대화에서만 쓴다(다르면 VAL_001) + */ + getConversation( + user: string, + emailId: string, + page: { readonly limit: number; readonly cursor?: string }, + ): Promise; + + /** + * 대화로 묶은 목록 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 그 메일함 안 마지막 받은 시각 최신부터. 없는 메일함은 RES_001. + * 커서는 그 메일함의 묶은 목록에서만 쓴다(다르면 VAL_001) + */ + listConversations( + user: string, + query: ConversationListQuery, + ): Promise; + /** * 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다 */ diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 035dbf8..f55a7f1 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -99,6 +99,12 @@ function fakeProvider(): ProviderMocks { getMailState: mockOf<'getMailState'>(), watchMail: mockOf<'watchMail'>(), listMessages: mockOf<'listMessages'>(), + searchScope: mockOf<'searchScope'>(), + conversationScope: mockOf<'conversationScope'>(), + getSummaries: mockOf<'getSummaries'>(), + getSummariesWithHeld: mockOf<'getSummariesWithHeld'>(), + getConversation: mockOf<'getConversation'>(), + listConversations: mockOf<'listConversations'>(), getMessage: mockOf<'getMessage'>(), updateMessage: mockOf<'updateMessage'>(), updateMessages: mockOf<'updateMessages'>(), @@ -281,9 +287,11 @@ interface NinthStepMocks { function fakeNinthStep(): NinthStepMocks { return { userSettings: { - get: jest - .fn, [string]>() - .mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }), + get: jest.fn, [string]>().mockResolvedValue({ + undoSendSeconds: 0, + externalImages: 'ask', + groupByConversation: false, + }), }, contacts: { hasEmail: jest @@ -452,59 +460,7 @@ describe('MailService', () => { expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001'); }); - it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => { - provider.listMessages.mockResolvedValue({ - items: [], - hasNext: false, - nextCursor: null, - total: 0, - }); - - await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' }); - await service.listMessages(user, { - mailboxId: 'a', - limit: 20, - q: ' 이전 ', - }); - - expect(provider.listMessages.mock.calls[0][1]).toMatchObject({ - text: undefined, - }); - expect(provider.listMessages.mock.calls[1][1]).toMatchObject({ - text: '이전', - }); - }); - - describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => { - const emptyPage = { - items: [], - hasNext: false, - nextCursor: null, - total: 0, - }; - - it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => { - provider.listMessages.mockResolvedValue(emptyPage); - - await service.listMessages(user, { limit: 20, q: ' 회의 ' }); - - expect(provider.listMessages).toHaveBeenCalledWith(user.email, { - mailboxId: undefined, - limit: 20, - cursor: undefined, - text: '회의', - }); - }); - - it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => { - for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) { - expect(await codeOf(() => service.listMessages(user, query))).toBe( - 'VAL_001', - ); - } - expect(provider.listMessages).not.toHaveBeenCalled(); - }); - + describe('새 메일 확인 · 여러 통 (3단계 — 목록 · 검색은 message-list.service.spec.ts)', () => { it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => { provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' }); @@ -2866,6 +2822,7 @@ describe('MailService', () => { ninth.userSettings.get.mockResolvedValue({ undoSendSeconds: 20, externalImages: 'ask', + groupByConversation: false, }); await service.sendMessage(user, sendDto()); expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 }); @@ -2876,6 +2833,7 @@ describe('MailService', () => { ninth.userSettings.get.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask', + groupByConversation: false, }); await service.sendMessage(user, sendDto()); expect(sentMessage()).not.toHaveProperty('hold'); @@ -2901,6 +2859,7 @@ describe('MailService', () => { ninth.userSettings.get.mockResolvedValue({ undoSendSeconds: 10, externalImages: 'ask', + groupByConversation: false, }); await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY); const first = sentMessage(); @@ -2983,6 +2942,7 @@ describe('MailService', () => { ninth.userSettings.get.mockResolvedValue({ undoSendSeconds: 10, externalImages, + groupByConversation: false, }); /** 본문의 중계 주소 → 표를 푼 원래 주소 */ const proxied = (html: string | null) => diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2062d9c..40ef947 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -42,7 +42,6 @@ import { SHOW_IMAGES, type MessageDetailQueryDto, } from './dto/message-detail-query.dto'; -import type { MessageListQueryDto } from './dto/message-list-query.dto'; import type { ComposeContentDto, DraftBodyDto, @@ -58,6 +57,7 @@ import { selectInlineImages, toDataUri, } from './html/inline-images'; +import { highlightTermsOf } from '@/modules/mail-search/search-plan'; import { checkHtmlParseBudget } from './html/html-parse-budget'; import { sanitizeMailHtml } from './html/sanitize-mail-html'; import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html'; @@ -118,6 +118,7 @@ import type { SentMessage, } from './mail.types'; import { presentMailbox, presentMailboxes } from './mailbox.presenter'; +import { highlightDetail } from './message-highlight'; import { RecipientSuggestionService } from './recipient-suggestions.service'; import { idempotentMessageId, @@ -660,34 +661,6 @@ export class MailService { }; } - /** - * 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001). - * mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다. - */ - async listMessages( - user: AuthenticatedUser, - query: MessageListQueryDto, - ): Promise> { - const text = query.q?.trim() || undefined; - if (query.mailboxId === undefined && text === undefined) { - throw new BusinessException('VAL_001', { - context: { reason: 'mailbox-or-search-required' }, - }); - } - const page = await this.provider.listMessages(user.email, { - mailboxId: query.mailboxId, - limit: query.limit, - cursor: query.cursor, - text, - }); - - return toPaginatedResult(page.items, { - hasNext: page.hasNext, - nextCursor: page.nextCursor, - total: page.total, - }); - } - /** * 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다. * 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 — @@ -705,6 +678,26 @@ export class MailService { id: string, query: MessageDetailQueryDto = {}, ): Promise { + // 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다). + // 보낸 사람 · 받는 사람 조건은 강조하지 않지만 넘긴다 — 본문에 강조할 말(한 글자 낱말)을 목록과 같게 고른다 + return highlightDetail( + await this.readMessage(user, id, query), + highlightTermsOf({ + text: query.q, + from: query.from, + to: query.to, + subject: query.subject, + body: query.body, + }), + ); + } + + /** getMessage 의 본체 — 강조 전의 상세 */ + private async readMessage( + user: AuthenticatedUser, + id: string, + query: MessageDetailQueryDto, + ): Promise> { const { rawHtmlBody, isRawHtmlBodyTruncated, @@ -724,7 +717,9 @@ export class MailService { })); /** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */ - const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({ + const withoutHtml = ( + isHtmlBodyOmitted: boolean, + ): Omit => ({ ...detail, attachments: markInline(NO_INLINE_ATTACHMENTS), htmlBody: null, diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index e4ead21..3ba5a07 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -6,6 +6,11 @@ import type { LargeFile, MissingLargeFile, } from '@/modules/file-storage/file-storage.types'; +import type { + MessageDetailHighlight, + MessageListHighlight, + SearchConditions, +} from '@/modules/mail-search/mail-search.types'; /** * 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다. @@ -49,6 +54,11 @@ export interface PendingSend { readonly kind: PendingSendKind; } +/** 목록 항목 (GET /api/messages — 14단계) — 요약 + 검색 강조. 색인으로 찾았을 때만 강조가 있고, 목록 · 엔진 방식 검색이면 null */ +export interface MessageListItem extends MessageSummary { + readonly searchHighlight: MessageListHighlight | null; +} + export interface MessageSummary { readonly id: string; readonly threadId: string; @@ -137,6 +147,11 @@ export interface MessageDetail extends MessageSummary { * 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다 */ readonly largeFiles: MessageLargeFile[]; + /** + * 검색 강조 (14단계 — 같은 검색 쿼리로 열었을 때) — 제목 · 첨부 이름(맞은 것만) · 글 본문의 맞은 곳. htmlBody 는 글 조각에 `` 를 넣어 준다. + * 검색 쿼리가 없으면 null + */ + readonly searchHighlight: MessageDetailHighlight | null; } /** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */ @@ -165,6 +180,7 @@ export interface MessageDetailSource extends Omit< | 'isBodyTruncated' | 'attachments' | 'largeFiles' + | 'searchHighlight' > { /** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */ readonly attachments: MailAttachment[]; @@ -189,6 +205,8 @@ export interface MessageListQuery { readonly cursor?: string; /** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */ readonly text?: string; + /** 조건 검색의 조건 (14단계 — 엔진 방식은 JMAP 필터로 · 낱말 통째 일치) */ + readonly conditions?: SearchConditions; } export interface MessagePage { @@ -198,6 +216,55 @@ export interface MessagePage { readonly total: number | null; } +/** + * 묶은 목록의 대화 하나 (14단계 — GET /api/conversations). 고르기 · 읽음 · 중요 · 이동 · 삭제는 `messageIds`(그 메일함 안)에 여러 통 API 로 한다 + */ +export interface ConversationSummary { + /** 대화 — 불투명 (색인이면 대화 키, 엔진이면 스레드 id) */ + readonly id: string; + /** 그 메일함 안 마지막 메일 */ + readonly latest: MessageSummary; + /** + * 보낸 사람들 — 받은 차례, 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지 (대화 전체 — 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만) + */ + readonly participants: MailAddress[]; + /** + * 대화 전체의 메일 수 — 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다 + */ + readonly messageCount: number; + /** 그 메일함 안 그 대화의 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 (엔진 방식에서 상한을 넘은 스레드는 읽은 최신 메일에서만) */ + readonly messageIds: string[]; + /** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일) — 없으면 빈 배열. 화면은 여러 통 처리에서 뺀다 */ + readonly pendingSendIds: string[]; + /** 그 메일함 안 메일을 모두 읽었다 */ + readonly isRead: boolean; + /** 그 메일함 안 메일 가운데 하나라도 중요 표시 */ + readonly isFlagged: boolean; + /** 그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 첨부 표시와 같은 판정) */ + readonly hasAttachment: boolean; +} + +/** 요약 여러 통과 계정의 붙잡힌 메일 (14단계 3차 — 묶은 목록의 색인 방식, 기다리는 제출은 한 번만 읽는다) */ +export interface SummariesWithHeld { + readonly summaries: MessageSummary[]; + /** 지금 붙잡힌 메일 id — 보이기 경로의 판정(보낼 시각이 아직 · pendingSend 와 같다), 계정 전체에서 보낼 시각이 이른 것부터 HELD_SUBMISSIONS_MAX 개까지 */ + readonly heldIds: ReadonlySet; +} + +export interface ConversationSummaryPage { + readonly items: ConversationSummary[]; + readonly hasNext: boolean; + readonly nextCursor: string | null; + readonly total: number | null; +} + +/** 묶은 목록 쿼리 (엔진 방식) */ +export interface ConversationListQuery { + readonly mailboxId: string; + readonly limit: number; + readonly cursor?: string; +} + export interface MessageUpdate { readonly isRead?: boolean; readonly isFlagged?: boolean; diff --git a/backend/src/modules/mail/message-highlight.spec.ts b/backend/src/modules/mail/message-highlight.spec.ts new file mode 100644 index 0000000..3908d46 --- /dev/null +++ b/backend/src/modules/mail/message-highlight.spec.ts @@ -0,0 +1,175 @@ +import { highlightTermsOf } from '@/modules/mail-search/search-plan'; +import type { MessageDetail } from './mail.types'; +import { highlightDetail } from './message-highlight'; + +type Detail = Omit; + +function detail(overrides: Partial = {}): Detail { + return { + id: 'm1', + threadId: 't1', + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }], + cc: [], + bcc: [], + replyTo: [], + subject: '[공지] 계약 갱신 안내', + preview: '계약 갱신 일정', + receivedAt: '2026-11-02T01:00:00.000Z', + sentAt: '2026-11-02T01:00:00.000Z', + messageId: 'x@example.com', + references: [], + isRead: true, + isFlagged: false, + hasAttachment: true, + size: 2048, + pendingSend: null, + htmlBody: '

이번 달 계약 갱신 일정입니다.

', + textBody: '이번 달 계약 갱신 일정입니다.', + hasBlockedImages: false, + imagesShown: false, + isHtmlBodyOmitted: false, + isBodyTruncated: false, + attachments: [ + { + id: 'p1', + name: '계약서_초안.pdf', + type: 'application/pdf', + size: 100, + isInline: false, + }, + { + id: 'p2', + name: '견적.xlsx', + type: 'application/vnd.ms-excel', + size: 100, + isInline: false, + }, + ], + largeFiles: [], + ...overrides, + }; +} + +const slices = (text: string, ranges: readonly (readonly [number, number])[]) => + ranges.map(([start, end]) => text.slice(start, end)); + +describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계 C2)', () => { + it('검색 쿼리가 없으면 searchHighlight 는 null 이고 나머지는 그대로', () => { + const source = detail(); + expect(highlightDetail(source, highlightTermsOf({}))).toEqual({ + ...source, + searchHighlight: null, + }); + }); + + it('검색어는 제목 · 본문(textBody 위치 · htmlBody 의 ) · 맞은 첨부 이름에', () => { + const result = highlightDetail( + detail(), + highlightTermsOf({ text: '계약' }), + ); + + expect(slices(result.subject, result.searchHighlight!.subject)).toEqual([ + '계약', + ]); + expect(result.searchHighlight!.attachments).toEqual({ p1: [[0, 2]] }); + expect(slices(result.textBody!, result.searchHighlight!.textBody)).toEqual([ + '계약', + ]); + expect(result.htmlBody).toBe( + '

이번 달 계약 갱신 일정입니다.

', + ); + }); + + it('제목 조건은 제목에만, 내용 조건은 본문에만 — 첨부 이름은 검색어로만', () => { + const subjectOnly = highlightDetail( + detail(), + highlightTermsOf({ subject: '갱신' }), + ); + expect(subjectOnly.searchHighlight).toEqual({ + subject: [[8, 10]], + attachments: {}, + textBody: [], + }); + expect(subjectOnly.htmlBody).toBe(detail().htmlBody); + + const bodyOnly = highlightDetail( + detail(), + highlightTermsOf({ body: '일정' }), + ); + expect(bodyOnly.searchHighlight!.subject).toEqual([]); + expect(bodyOnly.searchHighlight!.attachments).toEqual({}); + expect( + slices(bodyOnly.textBody!, bodyOnly.searchHighlight!.textBody), + ).toEqual(['일정']); + expect(bodyOnly.htmlBody).toContain('일정'); + }); + + it('한 글자 낱말뿐이면 본문은 강조하지 않는다 — 본문을 찾지 않았다, 제목 · 첨부 이름은 그대로 (2026-11-06 실사용 시험)', () => { + const source = detail({ + subject: '견적 안내', + htmlBody: '

이번 달 견적 안내

', + textBody: '이번 달 견적 안내', + }); + // '견' 은 제목 · 첨부 이름(견적.xlsx) · 본문에, '달' 은 본문에만 있다 + const single = highlightDetail(source, highlightTermsOf({ text: '달 견' })); + expect(single.searchHighlight).toEqual({ + subject: [[0, 1]], + attachments: { p2: [[0, 1]] }, + textBody: [], + }); + expect(single.htmlBody).toBe('

이번 달 견적 안내

'); + + // 두 글자 이상 낱말과 섞이면 그 낱말만 본문에 — 한 글자 낱말은 제목 · 첨부 이름에만 + const mixed = highlightDetail( + source, + highlightTermsOf({ text: '달 견적' }), + ); + expect(slices(mixed.textBody!, mixed.searchHighlight!.textBody)).toEqual([ + '견적', + ]); + expect(mixed.htmlBody).toBe('

이번 달 견적 안내

'); + expect(mixed.searchHighlight!.subject).toEqual([[0, 2]]); + expect(mixed.searchHighlight!.attachments).toEqual({ p2: [[0, 2]] }); + }); + + it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => { + const nfd = (text: string) => text.normalize('NFD'); + const result = highlightDetail( + detail({ + subject: nfd('계약 안내'), + textBody: nfd('계약 내용'), + attachments: [ + { + id: 'p1', + name: nfd('계약서.pdf'), + type: 'application/pdf', + size: 1, + isInline: false, + }, + ], + }), + highlightTermsOf({ text: '계약' }), + ); + + expect(result.subject).toBe('계약 안내'); + expect(result.textBody).toBe('계약 내용'); + expect(result.attachments[0].name).toBe('계약서.pdf'); + expect(result.searchHighlight).toEqual({ + subject: [[0, 2]], + attachments: { p1: [[0, 2]] }, + textBody: [[0, 2]], + }); + }); + + it('본문이 없거나 HTML 을 비웠으면(isHtmlBodyOmitted) 있는 쪽에만', () => { + const result = highlightDetail( + detail({ htmlBody: null, isHtmlBodyOmitted: true, textBody: null }), + highlightTermsOf({ text: '계약' }), + ); + expect(result.htmlBody).toBeNull(); + expect(result.textBody).toBeNull(); + expect(result.searchHighlight!.textBody).toEqual([]); + }); +}); diff --git a/backend/src/modules/mail/message-highlight.ts b/backend/src/modules/mail/message-highlight.ts new file mode 100644 index 0000000..68e7c26 --- /dev/null +++ b/backend/src/modules/mail/message-highlight.ts @@ -0,0 +1,62 @@ +import type { + MessageDetailHighlight, + SearchRange, +} from '@/modules/mail-search/mail-search.types'; +import type { HighlightTerms } from '@/modules/mail-search/search-plan'; +import { matchRanges } from '@/shared/utils/search-text'; +import { markHtmlText } from './html/html-mark'; +import type { MessageDetail } from './mail.types'; + +/** + * 읽기 화면의 검색 강조 (순수 함수 — 14단계 설계 C2) — 검색에서 연 메일을 같은 검색 쿼리로 받으면 맞은 곳을 알려 준다. + * - 제목: 검색어 · 제목 조건의 말 / 본문(textBody 위치 · htmlBody 의 ``): 검색어 · 내용 조건의 말 / 첨부 이름: 검색어의 말(맞은 첨부만) + * - 위치는 NFC 로 맞춘 글 기준이라 강조가 있으면 subject · 첨부 이름 · textBody 를 NFC 로 바꿔 싣는다(화면에는 같게 보인다) + * - htmlBody 는 **정화한 뒤** 의 HTML 에만 `` 를 넣는다(html/html-mark.ts — 태그 · 속성은 건드리지 않는다) + * 강조할 말이 없으면 searchHighlight 는 null 이고 나머지는 그대로다 + */ +export function highlightDetail( + detail: Omit, + terms: HighlightTerms | null, +): MessageDetail { + if (terms === null) return { ...detail, searchHighlight: null }; + const subject = detail.subject.normalize('NFC'); + const attachments = detail.attachments.map((attachment) => ({ + ...attachment, + name: attachment.name.normalize('NFC'), + })); + const attachmentRanges: Record = {}; + if (terms.attachment.length > 0) { + for (const attachment of attachments) { + const ranges = matchRanges(attachment.name, terms.attachment); + if (ranges.length > 0) { + attachmentRanges[attachment.id] = ranges.map( + ([start, end]) => [start, end] as const, + ); + } + } + } + const textBody = detail.textBody?.normalize('NFC') ?? null; + const highlight: MessageDetailHighlight = { + subject: matchRanges(subject, terms.subject).map( + ([start, end]) => [start, end] as const, + ), + attachments: attachmentRanges, + textBody: + textBody !== null && terms.body.length > 0 + ? matchRanges(textBody, terms.body).map( + ([start, end]) => [start, end] as const, + ) + : [], + }; + return { + ...detail, + subject, + attachments, + textBody, + htmlBody: + detail.htmlBody === null || terms.body.length === 0 + ? detail.htmlBody + : markHtmlText(detail.htmlBody, terms.body).html, + searchHighlight: highlight, + }; +} diff --git a/backend/src/modules/mail/message-list.service.spec.ts b/backend/src/modules/mail/message-list.service.spec.ts new file mode 100644 index 0000000..39fe63c --- /dev/null +++ b/backend/src/modules/mail/message-list.service.spec.ts @@ -0,0 +1,442 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; +import type { + IndexHighlightText, + IndexSearchPage, + MailSearchQueryService, +} from '@/modules/mail-search/mail-search-query.service'; +import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; +import type { SearchScope } from '@/modules/mail-search/mail-search.types'; +import { encodeIndexCursor } from '@/modules/mail-search/search-cursor'; +import { encodeCursor } from './jmap/jmap-cursor'; +import type { MailProvider } from './mail.provider'; +import type { MessagePage, MessageSummary } from './mail.types'; +import { MessageListService } from './message-list.service'; + +const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; + +const ALL_SCOPE: SearchScope = { + mailboxId: null, + excludedMailboxIds: ['trash', 'junk'], + excludedKeywords: ['$ara-sending'], +}; + +function summary( + id: string, + extra: Partial = {}, +): MessageSummary { + return { + id, + threadId: `t-${id}`, + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: user.email }], + subject: `${id} 계약 갱신`, + preview: '계약 갱신 일정을 알려 드립니다', + receivedAt: '2026-11-02T01:00:00.000Z', + isRead: false, + isFlagged: false, + hasAttachment: false, + size: 1200, + pendingSend: null, + ...extra, + }; +} + +const emptyEnginePage: MessagePage = { + items: [], + hasNext: false, + nextCursor: null, + total: 0, +}; + +function setup(options: { ready?: boolean } = {}) { + const provider = { + listMessages: jest.fn().mockResolvedValue(emptyEnginePage), + searchScope: jest.fn().mockResolvedValue(ALL_SCOPE), + getSummaries: jest.fn().mockResolvedValue([]), + }; + const searchIndex = { + isSearchReady: jest.fn().mockResolvedValue(options.ready ?? true), + }; + const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) }; + const indexPage: IndexSearchPage = { + hits: [], + hasNext: false, + nextCursor: null, + total: 0, + }; + const searchQuery = { + search: jest.fn().mockResolvedValue(indexPage), + highlightTexts: jest + .fn() + .mockResolvedValue(new Map()), + }; + const service = new MessageListService( + provider as unknown as MailProvider, + searchIndex as unknown as MailSearchIndexService, + searchSync as unknown as MailSearchSyncService, + searchQuery as unknown as MailSearchQueryService, + ); + return { service, provider, searchIndex, searchSync, searchQuery }; +} + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('메일함도 검색도 없으면(공백뿐인 검색어 포함) 엔진 · 색인에 묻지 않고 VAL_001', async () => { + const { service, provider, searchIndex } = setup(); + for (const query of [ + { limit: 20 }, + { limit: 20, q: ' ' }, + { limit: 20, from: ' ', subject: '' }, + ]) { + expect(await codeOf(() => service.list(user, query))).toBe('VAL_001'); + } + expect(provider.listMessages).not.toHaveBeenCalled(); + expect(searchIndex.isSearchReady).not.toHaveBeenCalled(); + }); + + it('검색이 아닌 목록은 늘 엔진 — 색인 상태를 묻지 않고, 항목의 searchHighlight 는 null', async () => { + const { service, provider, searchIndex } = setup(); + provider.listMessages.mockResolvedValue({ + items: [summary('m1')], + hasNext: true, + nextCursor: 'next', + total: 9, + }); + + const page = await service.list(user, { + mailboxId: 'inbox', + limit: 20, + q: ' ', + }); + + expect(provider.listMessages).toHaveBeenCalledWith(user.email, { + mailboxId: 'inbox', + limit: 20, + cursor: undefined, + text: undefined, + conditions: undefined, + }); + expect(searchIndex.isSearchReady).not.toHaveBeenCalled(); + expect(page.items).toEqual([{ ...summary('m1'), searchHighlight: null }]); + expect(page.pageInfo).toEqual({ + hasNext: true, + nextCursor: 'next', + total: 9, + }); + }); + + it('색인이 준비되지 않았으면 엔진 방식 — 검색어 앞뒤 공백을 지우고, 준 조건만 넘긴다', async () => { + const { service, provider, searchQuery } = setup({ ready: false }); + + await service.list(user, { limit: 20, q: ' 이전 ' }); + await service.list(user, { + limit: 20, + from: ' 이도윤 ', + hasAttachment: 'true', + after: '2026-10-31T15:00:00.000Z', + }); + + expect(provider.listMessages).toHaveBeenNthCalledWith(1, user.email, { + mailboxId: undefined, + limit: 20, + cursor: undefined, + text: '이전', + conditions: undefined, + }); + expect(provider.listMessages).toHaveBeenNthCalledWith(2, user.email, { + mailboxId: undefined, + limit: 20, + cursor: undefined, + text: undefined, + conditions: { + from: '이도윤', + after: '2026-10-31T15:00:00.000Z', + hasAttachment: true, + }, + }); + expect(searchQuery.search).not.toHaveBeenCalled(); + }); + + it('색인이 준비됐으면 색인 방식 — 바로 전 맞추기 → 엔진의 범위 → 색인 찾기 → 찾은 쪽의 요약은 엔진에서, 찾은 차례대로', async () => { + const { service, provider, searchSync, searchQuery } = setup(); + const order: string[] = []; + searchSync.syncBeforeRead.mockImplementation(() => { + order.push('sync'); + return Promise.resolve(true); + }); + provider.searchScope.mockImplementation(() => { + order.push('scope'); + return Promise.resolve(ALL_SCOPE); + }); + searchQuery.search.mockImplementation(() => { + order.push('search'); + return Promise.resolve({ + hits: [ + { id: 'm2', receivedAt: new Date('2026-11-02T02:00:00Z') }, + { id: 'm1', receivedAt: new Date('2026-11-02T01:00:00Z') }, + ], + hasNext: true, + nextCursor: 'index-next', + total: 42, + }); + }); + // 엔진은 차례를 보장하지 않는다 + provider.getSummaries.mockResolvedValue([summary('m1'), summary('m2')]); + + const page = await service.list(user, { limit: 2, q: '계약' }); + + expect(order).toEqual(['sync', 'scope', 'search']); + expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email); + expect(provider.searchScope).toHaveBeenCalledWith(user.email, undefined); + const [, scope, criteria, plan, pageOptions] = searchQuery.search.mock + .calls[0] as unknown[]; + expect(scope).toBe(ALL_SCOPE); + expect(criteria).toMatchObject({ text: '계약', hasAttachment: false }); + expect(plan).toMatchObject({ grams: ['계약'] }); + expect(pageOptions).toEqual({ limit: 2, cursor: undefined }); + expect(provider.getSummaries).toHaveBeenCalledWith(user.email, [ + 'm2', + 'm1', + ]); + expect(page.items.map((item) => item.id)).toEqual(['m2', 'm1']); + expect(page.pageInfo).toEqual({ + hasNext: true, + nextCursor: 'index-next', + total: 42, + }); + expect(provider.listMessages).not.toHaveBeenCalled(); + }); + + it('색인 방식의 강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기(색인의 본문), 본문이 없으면 엔진의 미리 보기', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockResolvedValue({ + hits: [ + { id: 'm1', receivedAt: new Date() }, + { id: 'm2', receivedAt: new Date() }, + ], + hasNext: false, + nextCursor: null, + total: 2, + }); + provider.getSummaries.mockResolvedValue([ + summary('m1', { subject: '계약 갱신 안내' }), + summary('m2', { subject: '안내', preview: '갱신 계약서 초안' }), + ]); + searchQuery.highlightTexts.mockResolvedValue( + new Map([ + [ + 'm1', + { + subject: '계약 갱신 안내', + bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.', + preview: '안녕하세요.', + otherFields: ['', '', '계약 갱신 안내', ''], + }, + ], + ]), + ); + + const page = await service.list(user, { limit: 20, q: '계약' }); + + expect(page.items[0].searchHighlight).toEqual({ + subject: [[0, 2]], + preview: { + text: '안녕하세요. 이번 달 계약 갱신 일정입니다.', + ranges: [[12, 14]], + }, + }); + expect(page.items[1].searchHighlight).toEqual({ + subject: [], + preview: { text: '갱신 계약서 초안', ranges: [[3, 5]] }, + }); + }); + + it('색인 방식의 미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 색인의 다른 칸(제목)에서도 맞으면 인용 앞의 글 (2026-11-06 사용자 관점 점검)', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockResolvedValue({ + hits: [{ id: 'm1', receivedAt: new Date() }], + hasNext: false, + nextCursor: null, + total: 1, + }); + provider.getSummaries.mockResolvedValue([ + summary('m1', { + subject: 'RE: 해오름 견적', + preview: '네, 확인했습니다. > 견적서를 보내 드립니다.', + }), + ]); + searchQuery.highlightTexts.mockResolvedValue( + new Map([ + [ + 'm1', + { + subject: 'RE: 해오름 견적', + bodyText: '네, 확인했습니다.\n\n> 견적서를 보내 드립니다.', + preview: '네, 확인했습니다.', + otherFields: ['', '', 're: 해오름 견적', ''], + }, + ], + ]), + ); + + const page = await service.list(user, { limit: 20, q: '견적' }); + + expect(page.items[0].searchHighlight).toEqual({ + subject: [[8, 10]], + preview: { text: '네, 확인했습니다.…', ranges: [] }, + }); + }); + + it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockResolvedValue({ + hits: ['m1', 'm2', 'm3'].map((id) => ({ id, receivedAt: new Date() })), + hasNext: false, + nextCursor: null, + total: 3, + }); + provider.getSummaries.mockResolvedValue([ + summary('m1', { mailboxIds: ['trash'] }), + summary('m2', { mailboxIds: ['trash', 'project'] }), + ]); + + const all = await service.list(user, { limit: 20, q: '계약' }); + expect(all.items.map((item) => item.id)).toEqual(['m2']); + + provider.searchScope.mockResolvedValue({ + mailboxId: 'project', + excludedMailboxIds: [], + excludedKeywords: ['$ara-sending'], + }); + provider.getSummaries.mockResolvedValue([ + summary('m1', { mailboxIds: ['inbox'] }), + summary('m2', { mailboxIds: ['project'] }), + ]); + const inProject = await service.list(user, { + mailboxId: 'project', + limit: 20, + q: '계약', + }); + expect(provider.searchScope).toHaveBeenLastCalledWith( + user.email, + 'project', + ); + expect(inProject.items.map((item) => item.id)).toEqual(['m2']); + }); + + it('찾은 것이 없으면 엔진에 요약을 묻지 않는다', async () => { + const { service, provider, searchQuery } = setup(); + const page = await service.list(user, { limit: 20, subject: '없는말' }); + expect(provider.getSummaries).not.toHaveBeenCalled(); + expect(searchQuery.highlightTexts).not.toHaveBeenCalled(); + expect(page).toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + }); + + it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 색인으로(준비 전이어도), 엔진 커서는 엔진으로(준비됐어도). 색인 상태를 묻지 않는다', async () => { + const indexCursor = encodeIndexCursor( + { receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' }, + 'scope', + ); + const engineCursor = encodeCursor( + { anchor: 'm9', position: 20 }, + { text: '계약' }, + ); + + const notReady = setup({ ready: false }); + await notReady.service.list(user, { + limit: 20, + q: '계약', + cursor: indexCursor, + }); + expect(notReady.searchQuery.search).toHaveBeenCalledTimes(1); + expect(notReady.provider.listMessages).not.toHaveBeenCalled(); + + const ready = setup({ ready: true }); + await ready.service.list(user, { + limit: 20, + q: '계약', + cursor: engineCursor, + }); + expect(ready.provider.listMessages).toHaveBeenCalledTimes(1); + expect(ready.searchQuery.search).not.toHaveBeenCalled(); + + expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled(); + expect(ready.searchIndex.isSearchReady).not.toHaveBeenCalled(); + }); + + it('첫 쪽의 색인 찾기가 실패하면(DB 장애) 엔진 방식으로 찾는다 — 경고에 검색 글 · 주소 원문을 남기지 않는다', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockRejectedValue( + Object.assign(new Error('connection terminated'), { code: '57P01' }), + ); + provider.listMessages.mockResolvedValue({ + items: [summary('m1')], + hasNext: false, + nextCursor: null, + total: 1, + }); + + const page = await service.list(user, { limit: 20, q: '비밀 계약' }); + + expect(page.items.map((item) => item.searchHighlight)).toEqual([null]); + expect(provider.listMessages).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + const [line] = warnSpy.mock.calls[0] as [string]; + expect(line).toContain('[SYS_001]'); + expect(line).toContain('Error/57P01'); + expect(line).not.toContain('비밀'); + expect(line).not.toContain('계약'); + expect(line).not.toContain(user.email); + }); + + it('코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001)는 그대로 — 엔진 방식으로 다시 찾지 않는다', async () => { + for (const code of ['RES_001', 'MAIL_001'] as const) { + const { service, provider } = setup(); + provider.searchScope.mockRejectedValue(new BusinessException(code)); + expect( + await codeOf(() => + service.list(user, { mailboxId: 'zz', limit: 20, q: '계약' }), + ), + ).toBe(code); + expect(provider.listMessages).not.toHaveBeenCalled(); + } + }); + + it('다음 쪽(색인 커서)의 색인 찾기가 실패하면 그대로 실패한다 — 엔진 커서와 섞지 않는다', async () => { + const { service, provider, searchQuery } = setup(); + searchQuery.search.mockRejectedValue(new Error('db down')); + const cursor = encodeIndexCursor( + { receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' }, + 'scope', + ); + expect( + await codeOf(() => service.list(user, { limit: 20, q: '계약', cursor })), + ).toBe('NOT_BUSINESS'); + expect(provider.listMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/mail/message-list.service.ts b/backend/src/modules/mail/message-list.service.ts new file mode 100644 index 0000000..fe8df08 --- /dev/null +++ b/backend/src/modules/mail/message-list.service.ts @@ -0,0 +1,214 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service'; +import { + MailSearchQueryService, + type IndexHighlightText, +} from '@/modules/mail-search/mail-search-query.service'; +import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service'; +import type { + SearchConditions, + SearchCriteria, + SearchScope, +} from '@/modules/mail-search/mail-search.types'; +import { cursorMethod } from '@/modules/mail-search/search-cursor'; +import { + highlightedSubject, + listHighlight, +} from '@/modules/mail-search/search-highlight'; +import { + isSearchCriteria, + planSearch, +} from '@/modules/mail-search/search-plan'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { + HAS_ATTACHMENT_ONLY, + type MessageListQueryDto, +} from './dto/message-list-query.dto'; +import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; +import type { MessageListItem, MessageSummary } from './mail.types'; + +/** 앞뒤 공백을 지운 글 — 비면 없는 것 (DTO 가 이미 지우지만 HTTP 를 거치지 않는 호출도 같게) */ +function trimmed(value: string | undefined): string | undefined { + return value?.trim() || undefined; +} + +/** 검색 쿼리 → 검색 조건 */ +export function criteriaOf(query: MessageListQueryDto): SearchCriteria { + return { + text: trimmed(query.q), + from: trimmed(query.from), + to: trimmed(query.to), + subject: trimmed(query.subject), + body: trimmed(query.body), + after: query.after, + before: query.before, + hasAttachment: query.hasAttachment === HAS_ATTACHMENT_ONLY, + }; +} + +/** 검색어 밖의 조건 — 하나도 없으면 undefined (엔진 방식 검색의 필터 · 커서 지문이 예전과 같다) */ +function conditionsOf(criteria: SearchCriteria): SearchConditions | undefined { + const conditions: SearchConditions = { + ...(criteria.from ? { from: criteria.from } : {}), + ...(criteria.to ? { to: criteria.to } : {}), + ...(criteria.subject ? { subject: criteria.subject } : {}), + ...(criteria.body ? { body: criteria.body } : {}), + ...(criteria.after ? { after: criteria.after } : {}), + ...(criteria.before ? { before: criteria.before } : {}), + ...(criteria.hasAttachment === true ? { hasAttachment: true } : {}), + }; + return Object.keys(conditions).length > 0 ? conditions : undefined; +} + +/** 엔진이 지금 그 메일을 범위 안에 두나 — 색인이 몇 초 늦을 수 있어 보이기 전에 엔진의 메일함으로 한 번 더 본다 */ +function inScope(summary: MessageSummary, scope: SearchScope): boolean { + if (scope.mailboxId !== null) { + return summary.mailboxIds.includes(scope.mailboxId); + } + const excluded = new Set(scope.excludedMailboxIds); + return summary.mailboxIds.some((mailboxId) => !excluded.has(mailboxId)); +} + +function errorKind(error: unknown): string { + const name = error instanceof Error ? error.name : typeof error; + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? `${name}/${code}` : name; +} + +/** + * 메일 목록 · 검색 (GET /api/messages — 계약 2절 "검색", 14단계 설계 C1). + * - mailboxId 와 검색(검색어 q · 조건 하나라도) 가운데 하나는 있어야 한다 — 둘 다 없으면 VAL_001. 검색 글 · 조건은 어떤 로그에도 남기지 않는다 + * - 검색 방식: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로), 없으면 색인이 준비됐을 때(ready · 지금 판) 색인, + * 아니면 엔진(낱말 통째 일치 · 조건은 JMAP 필터) + * - 색인 방식: 바로 전 맞추기(PRE_READ_SYNC_WAIT_MS 안 — 넘으면 지금 색인으로) → 엔진에 범위(메일함 · 빼는 메일함) → 색인 찾기 → 찾은 쪽의 요약은 엔진에서 + * (목록과 같은 모양 · 지금 상태) → 엔진이 범위 밖으로 둔 메일(그사이 옮김)은 뺀다 → 제목 · 미리 보기 강조. 첫 쪽의 색인 찾기가 DB 장애로 실패하면 엔진 방식으로 찾는다 + * 목록 항목은 늘 searchHighlight 를 싣는다 — 색인 방식 검색에서만 값이 있고 그 밖에는 null + */ +@Injectable() +export class MessageListService { + private readonly logger = new Logger(MessageListService.name); + + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + private readonly searchIndex: MailSearchIndexService, + private readonly searchSync: MailSearchSyncService, + private readonly searchQuery: MailSearchQueryService, + ) {} + + async list( + user: AuthenticatedUser, + query: MessageListQueryDto, + ): Promise> { + const criteria = criteriaOf(query); + const searching = isSearchCriteria(criteria); + if (query.mailboxId === undefined && !searching) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-or-search-required' }, + }); + } + if (!searching) return this.fromEngine(user, query, criteria); + if (query.cursor) { + return cursorMethod(query.cursor) === 'index' + ? this.fromIndex(user, query, criteria) + : this.fromEngine(user, query, criteria); + } + if (!(await this.searchIndex.isSearchReady(user.email))) { + return this.fromEngine(user, query, criteria); + } + try { + return await this.fromIndex(user, query, criteria); + } catch (error) { + // 코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001 등)는 그대로 — 색인(DB)이 실패했으면 엔진으로 찾는다 + if (error instanceof BusinessException) throw error; + this.logger.warn( + `[SYS_001]${traceTag()} 검색 색인으로 찾지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`, + ); + return this.fromEngine(user, query, criteria); + } + } + + /** 엔진 방식 — 목록 · 색인 준비 전의 검색 (강조 없음) */ + private async fromEngine( + user: AuthenticatedUser, + query: MessageListQueryDto, + criteria: SearchCriteria, + ): Promise> { + const page = await this.provider.listMessages(user.email, { + mailboxId: query.mailboxId, + limit: query.limit, + cursor: query.cursor, + text: criteria.text, + conditions: conditionsOf(criteria), + }); + return toPaginatedResult( + page.items.map((item) => ({ ...item, searchHighlight: null })), + { + hasNext: page.hasNext, + nextCursor: page.nextCursor, + total: page.total, + }, + ); + } + + /** 색인 방식 — 부분 일치 · 조건 · 강조 */ + private async fromIndex( + user: AuthenticatedUser, + query: MessageListQueryDto, + criteria: SearchCriteria, + ): Promise> { + // 방금 온 메일도 찾게 — 기다리는 시간을 넘으면 지금 색인으로 답한다 (맞추기는 뒤에서 이어 한다) + await this.searchSync.syncBeforeRead(user.email); + const scope = await this.provider.searchScope(user.email, query.mailboxId); + const plan = planSearch(criteria); + const found = await this.searchQuery.search( + user.email, + scope, + criteria, + plan, + { limit: query.limit, cursor: query.cursor }, + ); + const ids = found.hits.map((hit) => hit.id); + const [summaries, texts] = + ids.length > 0 + ? await Promise.all([ + this.provider.getSummaries(user.email, ids), + this.searchQuery.highlightTexts(user.email, ids), + ]) + : [[], new Map()]; + const byId = new Map(summaries.map((summary) => [summary.id, summary])); + const items = ids.flatMap((id): MessageListItem[] => { + const summary = byId.get(id); + if (!summary || !inScope(summary, scope)) return []; + const subject = highlightedSubject(summary.subject); + const text = texts.get(id); + return [ + { + ...summary, + subject, + searchHighlight: listHighlight( + { + subject, + preview: summary.preview, + bodyText: text?.bodyText ?? null, + otherFields: text?.otherFields ?? null, + }, + plan.highlight, + ), + }, + ]; + }); + return toPaginatedResult(items, { + hasNext: found.hasNext, + nextCursor: found.nextCursor, + total: found.total, + }); + } +} diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index e1d22b1..4f81c29 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -68,6 +68,7 @@ import { UpdateMessageDto } from './dto/update-message.dto'; import { MailIdPipe } from './mail-id.pipe'; import { MailLargeFilesService } from './mail-large-files.service'; import { MailService } from './mail.service'; +import { MessageListService } from './message-list.service'; import type { AttachmentContent } from './mail.types'; import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency'; @@ -94,24 +95,27 @@ export class MessageController { constructor( private readonly mail: MailService, private readonly largeFiles: MailLargeFilesService, + private readonly messageList: MessageListService, ) {} @Get() @ApiOperation({ summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)', description: - 'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' + - '엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' + - '(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' + - '낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' + - '붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.', + 'mailboxId 와 검색(q 나 조건 from · to · subject · body · after · before · hasAttachment 하나라도) 가운데 하나는 있어야 한다. 메일함 없이 검색하면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' + + '검색 방식(14단계): 그 직원의 색인이 준비됐으면(GET /api/search-index 의 ready) 자체 색인으로 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 · 전각 없이, 줄바꿈으로 갈린 구절도) — 항목에 searchHighlight 가 있다. ' + + '한 글자 낱말뿐이면 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름에서만 찾는다(본문은 훑지 않는다 — 두 글자 넘는 낱말과 함께면 본문도). ' + + '색인 방식은 한 요청이 확인하는 메일 수에 상한이 있어, 쪽이 limit 보다 적거나 비어도 hasNext · nextCursor 가 있으면 이어 찾는다. total 은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). ' + + '준비 중이면 엔진 검색 — 낱말이 통째로 같을 때만 맞고, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다(조건 칸도 같다), searchHighlight 는 null. ' + + '커서에는 방식이 실려 쪽을 넘기는 중에 준비가 끝나도 처음 방식대로 이어 간다. 낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). ' + + '문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 글을 준 칸에 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). 발송 중인 내부 초안은 빠진다.', }) @ApiStandardResponse(MessagePageDto, { description: '메일 목록 (total 포함)', }) @ApiErrorResponse( 'VAL_001', - `mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`, + `mailboxId 와 검색이 둘 다 없음 · 검색 글 칸(q · from · to · subject · body)이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과 · after · before 가 ISO 8601 UTC 가 아님 · hasAttachment 가 true 가 아님 · 다른 메일함·검색어·조건의 커서 · limit 범위 초과`, ) @ApiErrorResponse( 'RES_001', @@ -122,7 +126,7 @@ export class MessageController { @CurrentUser() user: AuthenticatedUser, @Query() query: MessageListQueryDto, ) { - return this.mail.listMessages(user, query); + return this.messageList.list(user, query); } @Patch() @@ -162,10 +166,15 @@ export class MessageController { '정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' + `보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` + 'images=show 면 외부 이미지( 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' + - '없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).', + '없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계). ' + + '검색 쿼리(q · subject · body — 14단계)를 함께 보내면 searchHighlight 에 제목 · 첨부 이름 · textBody 의 맞은 곳을 싣고, htmlBody 의 맞은 글 조각에 를 넣는다(목록과 같은 접기 · 부분 일치, 태그를 건너는 말은 강조하지 않는다). ' + + '검색 쿼리의 검사는 목록과 같다 — from · to 는 받기만 하고 강조하지 않는다.', }) @ApiStandardResponse(MessageDetailDto, { description: '메일 상세' }) - @ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리') + @ApiErrorResponse( + 'VAL_001', + `모르는 images 값 · 모르는 쿼리(cursor · limit · after · before · hasAttachment 포함) · 검색 글 칸이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과`, + ) @ApiErrorResponse('RES_001', '없는 메일') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') get( diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts index 38e7a52..cca3993 100644 --- a/backend/src/modules/ops/lock-commands.ts +++ b/backend/src/modules/ops/lock-commands.ts @@ -52,6 +52,8 @@ const ROW_LABELS: Record = { directory: '사내 목록 기록', files: '대용량 파일', shareLinks: '대용량 첨부 링크', + searchStates: '검색 색인 상태', + searchMessages: '검색 색인(메일)', }; /** 남은 줄을 보이는 글 */ diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts index d0f4c02..e2e6230 100644 --- a/backend/src/modules/ops/ops-commands.spec.ts +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -55,6 +55,8 @@ const NO_ROWS: AddressRows = { directory: 0, files: 0, shareLinks: 0, + searchStates: 0, + searchMessages: 0, }; function employee(overrides: Partial = {}): EngineEmployee { diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts index 5df7b84..aaa25cc 100644 --- a/backend/src/modules/ops/ops-data.service.ts +++ b/backend/src/modules/ops/ops-data.service.ts @@ -16,6 +16,8 @@ import { } from '@/modules/file-storage/file-blob-store'; import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity'; import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity'; +import { MailSearchMessageEntity } from '@/modules/mail-search/mail-search-message.entity'; +import { MailSearchStateEntity } from '@/modules/mail-search/mail-search-state.entity'; import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity'; import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity'; import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; @@ -48,6 +50,9 @@ export interface AddressRows { readonly directory: number; readonly files: number; readonly shareLinks: number; + /** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */ + readonly searchStates: number; + readonly searchMessages: number; } /** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */ @@ -255,7 +260,7 @@ export class OpsDataService { /** * 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다 - * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기). + * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기). * 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다 */ async purgeAddress(email: string): Promise { @@ -378,7 +383,9 @@ export class OpsDataService { | typeof MailUserSettingsEntity | typeof MailDirectoryEntryEntity | typeof StoredFileEntity - | typeof ShareLinkEntity, + | typeof ShareLinkEntity + | typeof MailSearchStateEntity + | typeof MailSearchMessageEntity, where: Record, ): Promise => { if (mode === 'count') return manager.countBy(entity, where); @@ -402,6 +409,10 @@ export class OpsDataService { files: await run(StoredFileEntity, { ownerEmail: email }), // 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다 shareLinks: await run(ShareLinkEntity, { ownerEmail: email }), + // 검색 색인(14단계) — 상태 줄을 먼저 지운다: 서버의 색인 쓰기는 상태 줄을 잠그고 하므로(없으면 쓰지 않는다) 그사이 끝난 쓰기가 + // 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다) + searchStates: await run(MailSearchStateEntity, { userEmail: email }), + searchMessages: await run(MailSearchMessageEntity, { userEmail: email }), }; } } diff --git a/backend/src/shared/config/database.config.spec.ts b/backend/src/shared/config/database.config.spec.ts new file mode 100644 index 0000000..a55808f --- /dev/null +++ b/backend/src/shared/config/database.config.spec.ts @@ -0,0 +1,17 @@ +import { databaseLogging } from './database.config'; + +describe('databaseLogging (TypeORM 로그)', () => { + it('운영 · 시험은 끈다', () => { + expect(databaseLogging(true, 'production')).toBe(false); + expect(databaseLogging(true, 'development')).toBe(false); + expect(databaseLogging(false, 'test')).toBe(false); + }); + + it('개발은 오류 · 경고 · 마이그레이션만 — 쿼리(매개변수에 메일 글 · 주소가 든다)는 남기지 않는다 (14단계)', () => { + for (const nodeEnv of ['development', undefined]) { + const logging = databaseLogging(false, nodeEnv); + expect(logging).toEqual(['error', 'warn', 'migration']); + expect(logging).not.toContain('query'); + } + }); +}); diff --git a/backend/src/shared/config/database.config.ts b/backend/src/shared/config/database.config.ts index d72be10..f95b619 100644 --- a/backend/src/shared/config/database.config.ts +++ b/backend/src/shared/config/database.config.ts @@ -57,6 +57,19 @@ function resolvePaths(baseDir: string) { }; } +/** + * TypeORM 로그 — 운영 · 시험은 끄고, 개발은 오류 · 경고 · 마이그레이션만 남긴다(쿼리는 남기지 않는다). + * 쿼리 로그는 매개변수째 찍는다 — 14단계부터 DB 에 메일 글 · 제목 · 주소(검색 색인)가 있어 개발 로그에도 남기지 않는다 (.claude/rules/logging.md). + * ⚠️ 'error' 는 실패한 쿼리를 매개변수째 찍는다(TypeORM 의 logQueryError) — 실패한 색인 쓰기는 개발 로그에 글이 남을 수 있다 + */ +export function databaseLogging( + isProduction: boolean, + nodeEnv: string | undefined, +): DataSourceOptions['logging'] { + if (isProduction || nodeEnv === 'test') return false; + return ['error', 'warn', 'migration']; +} + export function buildDatabaseOptions(): DataSourceOptions { const isProduction = isProductionEnv(); const paths = resolvePaths(__dirname); @@ -71,8 +84,8 @@ export function buildDatabaseOptions(): DataSourceOptions { synchronize: shouldSynchronize(), // 운영 배포 시 미적용 마이그레이션을 자동 실행한다 migrationsRun: isProduction, - // SQL 로그는 개발에서만 — 테스트(e2e·CI)는 수천 줄의 쿼리·파라미터(주소 등)가 출력을 덮는다 - logging: !isProduction && process.env.NODE_ENV !== 'test', + // 개발도 쿼리는 남기지 않는다(매개변수에 메일 글 · 주소가 든다) — 오류 · 경고 · 마이그레이션만. 운영 · 시험은 끈다 + logging: databaseLogging(isProduction, process.env.NODE_ENV), extra: { ...POOL_OPTIONS }, entities: paths.entities, migrations: paths.migrations, diff --git a/backend/src/shared/utils/kst-date.spec.ts b/backend/src/shared/utils/kst-date.spec.ts index c362734..2fb404a 100644 --- a/backend/src/shared/utils/kst-date.spec.ts +++ b/backend/src/shared/utils/kst-date.spec.ts @@ -5,6 +5,7 @@ import { formatKstDate, formatKstDateEnglish, kstEndOfDayAfter, + kstMinuteOfDay, } from './kst-date'; describe('kstEndOfDayAfter — 한국 날짜 + n일의 끝', () => { @@ -99,3 +100,13 @@ describe('formatKstDateTime — 알림 메일의 시각 (디자인 README 13단 ); }); }); + +describe('kstMinuteOfDay — 한국 시각의 하루 몇 분째 (14단계 밤 채우기 창)', () => { + it('UTC 에 9시간을 더한 시각의 분이다 — 날이 바뀌어도', () => { + expect(kstMinuteOfDay(new Date('2026-11-05T13:00:00Z'))).toBe(22 * 60); + expect(kstMinuteOfDay(new Date('2026-11-05T15:00:00Z'))).toBe(0); + expect(kstMinuteOfDay(new Date('2026-11-05T17:45:30Z'))).toBe(2 * 60 + 45); + expect(kstMinuteOfDay(new Date('2026-11-05T14:59:59.999Z'))).toBe(1439); + expect(kstMinuteOfDay(new Date('1969-12-31T14:59:00Z'))).toBe(1439); + }); +}); diff --git a/backend/src/shared/utils/kst-date.ts b/backend/src/shared/utils/kst-date.ts index fbd889a..6bc6643 100644 --- a/backend/src/shared/utils/kst-date.ts +++ b/backend/src/shared/utils/kst-date.ts @@ -108,3 +108,13 @@ export function formatKstDateTime(at: Date): string { const hour12 = hours % 12 === 0 ? 12 : hours % 12; return `${year}년 ${month}월 ${day}일 (${WEEKDAYS_KO[weekday]}) ${period} ${hour12}:${minutes}`; } + +/** 하루의 분 */ +const MINUTES_PER_DAY = 24 * 60; +const MS_PER_MINUTE = 60 * 1000; + +/** `at` 의 한국 시각이 하루의 몇 분째인가 (0 ~ 1439) — 14단계 검색 색인의 밤 채우기 창 */ +export function kstMinuteOfDay(at: Date): number { + const minutes = Math.floor((at.getTime() + KST_OFFSET_MS) / MS_PER_MINUTE); + return ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; +} diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts index 4f2f4e7..9713a07 100644 --- a/backend/src/shared/utils/mask.spec.ts +++ b/backend/src/shared/utils/mask.spec.ts @@ -15,6 +15,23 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => { ).toBe('/api/messages?mailboxId=a&q=<26자>'); }); + it('조건 검색의 칸(from · to · subject · body — 14단계)도 검색어처럼 길이만 남긴다 — 날짜 · 첨부 조건은 그대로', () => { + expect( + maskUrlForLog( + '/api/messages?in=all&from=%EC%9D%B4%EB%8F%84%EC%9C%A4&to=ara.kim%40example.com&subject=%EA%B2%AC%EC%A0%81&body=%EA%B3%84%EC%95%BD&after=2026-10-01T00%3A00%3A00Z&hasAttachment=true', + ), + ).toBe( + '/api/messages?in=all&from=<3자>&to=<19자>&subject=<2자>&body=<2자>&after=2026-10-01T00%3A00%3A00Z&hasAttachment=true', + ); + // 대소문자 · 배열 표기도 같은 이름 · 비슷한 다른 이름은 그대로 + expect( + maskUrlForLog('/api/x?FROM=a&To[]=bb&Subject%5B0%5D=ccc&BODY=dddd'), + ).toBe('/api/x?FROM=<1자>&To[]=<2자>&Subject%5B0%5D=<3자>&BODY=<4자>'); + expect( + maskUrlForLog('/api/x?fromDate=1&toDate=2&subjects=3&bodyHtml=4'), + ).toBe('/api/x?fromDate=1&toDate=2&subjects=3&bodyHtml=4'); + }); + it('커서는 검색어의 해시(범위 해시)를 담으므로 검색 없는 목록의 커서도 가린다 — 메일함 id 는 그대로', () => { expect( maskUrlForLog( diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts index 5aa1720..0480d70 100644 --- a/backend/src/shared/utils/mask.ts +++ b/backend/src/shared/utils/mask.ts @@ -9,13 +9,15 @@ const MASK = '***'; /** - * 값을 남기지 않는 쿼리 이름 — 검색어(q)와 목록 커서(cursor). + * 값을 남기지 않는 쿼리 이름 — 검색어(q) · 조건 검색의 칸(from · to · subject · body — 14단계)과 목록 커서(cursor). * - q: 메일 검색어 · 받는 사람 추천의 거르는 글자 — 사람 이름·주소가 들어간다 (계약 2절 "검색" · "로그에는 개수만") + * - from · to · subject · body: 조건 검색의 보낸 사람 · 받는 사람 · 제목 · 내용 — 검색어와 같다 (14단계 설계 C1) * - cursor: 다른 목록의 커서를 막으려고 검색어의 해시(범위 해시)를 담는다 — 로그를 읽는 사람이 후보 검색어를 대입해 맞춰 볼 수 있다 * (modules/mail/jmap/jmap-cursor.ts · recipient-suggestion-cursor.ts) * 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다. */ -const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i; +const REDACTED_QUERY_NAME = + /^(?:q|from|to|subject|body|cursor)(?:\[[^\]]*\])*$/i; /** UUID 모양의 경로 조각 — 공개 링크의 파일 id 는 남긴다 */ const UUID_SEGMENT = diff --git a/backend/src/shared/utils/search-query.ts b/backend/src/shared/utils/search-query.ts new file mode 100644 index 0000000..cfa17c8 --- /dev/null +++ b/backend/src/shared/utils/search-query.ts @@ -0,0 +1,84 @@ +/** + * 검색어 나누기 (순수 함수 — 계약 2절 "검색") — 엔진 검색(modules/mail/jmap/search-terms.ts)과 자체 색인 찾기(modules/mail-search — 14단계)가 함께 쓴다. + * 낱말 · 구절로 나누고 앞뒤 문장부호를 뗀다. 엔진 쪽은 여기에 조사 형태를 붙이고, 색인 쪽은 접기(search-text.ts) 뒤 부분 일치로 찾는다. + */ + +/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 */ +export const SEARCH_WORD_SEPARATOR = /\s+/u; + +/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */ +const QUOTE = '"'; + +/** + * NUL(U+0000) — PostgreSQL 글에 넣을 수 없다(22021). 색인은 저장할 때 U+FFFD 로 1:1 바꾸므로(index-document.ts 의 storableText) + * 검색 글도 같게 바꿔 찾는다 (계약 2절 "검색 — 14단계") — 엔진 방식 · 강조도 같은 낱말을 쓴다 + */ +const NUL = String.fromCharCode(0); +const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd); + +/** + * 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}). + * 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다 + */ +const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u; + +/** + * 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다 + * ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다. + * 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고, + * 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다. + */ +const WORD_EDGE_PUNCTUATION = + /^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu; + +/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */ +function coreWord(word: string): string { + return word.replace(WORD_EDGE_PUNCTUATION, ''); +} + +/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */ +export interface SearchTerm { + readonly kind: 'word' | 'phrase'; + readonly text: string; +} + +function splitWords(text: string): string[] { + return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== ''); +} + +/** + * 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만. + * - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로) + * - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다 + * - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR) + * - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다 + * - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) — + * 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다) + * - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다 + * - NUL 은 U+FFFD 로 1:1 바꾼다 (색인의 저장과 같다 — 위치 · 길이는 그대로) + */ +export function parseSearchTerms(query: string): SearchTerm[] { + const segments = query + .normalize('NFC') + .replaceAll(NUL, REPLACEMENT_CHARACTER) + .split(QUOTE); + const terms: SearchTerm[] = []; + const seen = new Set(); + const add = (kind: SearchTerm['kind'], text: string) => { + const key = `${kind}\n${text}`; + if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return; + seen.add(key); + terms.push({ kind, text }); + }; + + segments.forEach((segment, index) => { + // 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다) + const isPhrase = index % 2 === 1 && index < segments.length - 1; + if (isPhrase) { + add('phrase', splitWords(segment).join(' ')); + return; + } + for (const word of splitWords(segment)) add('word', coreWord(word)); + }); + return terms; +} diff --git a/backend/src/shared/utils/search-text.spec.ts b/backend/src/shared/utils/search-text.spec.ts new file mode 100644 index 0000000..e444a9d --- /dev/null +++ b/backend/src/shared/utils/search-text.spec.ts @@ -0,0 +1,130 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + FOLD_SAMPLES, + GRAM_SAMPLES, + MATCH_SAMPLES, +} from '@/../test/search-text-samples'; +import { + MAX_MATCH_RANGES, + foldText, + gramsOf, + isSpaceCode, + matchRanges, +} from './search-text'; + +describe('foldText (검색의 접기)', () => { + it.each(FOLD_SAMPLES)('%j → %j', (input, folded) => { + expect(foldText(input)).toBe(folded); + }); + + it('접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 — 위치를 원문에 그대로 쓴다', () => { + for (const [input] of FOLD_SAMPLES) { + expect(foldText(input).length).toBe(input.normalize('NFC').length); + } + // 글자 범위를 훑어 본다 — 소문자가 길이를 바꾸는 글자(İ 등)도 그대로라 길이가 같다 + const changedLength: number[] = []; + for (let code = 0; code <= 0xffff; code += 1) { + if (code >= 0xd800 && code <= 0xdfff) continue; + const char = String.fromCharCode(code).normalize('NFC'); + if (foldText(char).length !== char.length) changedLength.push(code); + } + expect(changedLength).toEqual([]); + }); + + it('두 번 접어도 같다', () => { + for (const [input, folded] of FOLD_SAMPLES) { + expect(foldText(folded)).toBe(folded); + expect(foldText(foldText(input))).toBe(folded); + } + }); + + it('짝이 없는 서로게이트도 그대로 둔다 (길이가 같다)', () => { + expect(foldText('a\ud800B')).toBe('a\ud800b'); + expect(foldText('\udc00A')).toBe('\udc00a'); + }); + + it('긴 글도 길이에 비례해 접는다', () => { + const unit = '계약서 Project ABC 회의록 ÀÉÎ '; + const small = unit.repeat(2_000); + const large = unit.repeat(40_000); + const smallCpu = startCpuTimer(); + foldText(small); + const smallMs = smallCpu(); + const largeCpu = startCpuTimer(); + expect(foldText(large).length).toBe(large.length); + const largeMs = largeCpu(); + // 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts) + expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10); + expect(largeMs).toBeLessThan(5_000); + }); +}); + +describe('gramsOf (글자 두 개 묶음)', () => { + it.each(GRAM_SAMPLES)('%j → %j', (fold, grams) => { + expect([...gramsOf(fold)]).toEqual(grams); + }); + + it('주어진 모음에 더한다 — 칸 경계를 넘지 않는다', () => { + const into = new Set(); + gramsOf('회의', into); + gramsOf('일정', into); + expect([...into]).toEqual(['회의', '일정']); + expect(into.has('의일')).toBe(false); + }); + + it('공백은 정규식 \\s 와 같은 글자다 (검색어 낱말 나누기와 같다)', () => { + const differ: number[] = []; + for (let code = 0; code <= 0xffff; code += 1) { + if (isSpaceCode(code) !== /\s/u.test(String.fromCharCode(code))) { + differ.push(code); + } + } + expect(differ).toEqual([]); + }); + + it('20,000자 본문도 길이에 비례해 묶는다', () => { + const body = '계약서를 보내 드립니다 Project plan 회의록 '.repeat(800); + const cpu = startCpuTimer(); + const grams = gramsOf(foldText(body)); + expect(grams.size).toBeGreaterThan(10); + expect(cpu()).toBeLessThan(2_000); + }); +}); + +describe('matchRanges (맞은 곳)', () => { + it.each(MATCH_SAMPLES)('%j 에서 %j → %j', (original, terms, ranges) => { + expect(matchRanges(original, terms)).toEqual(ranges); + }); + + it('맞은 곳은 limit 개까지만 모은다 (한 글자 낱말이 흔해도)', () => { + // 찾은 자리를 말마다 limit 개까지 모은 뒤 합친다 — 이어진 글이면 앞 limit 자리만큼 + const text = '가'.repeat(5_000); + expect(matchRanges(text, ['가'])).toEqual([[0, MAX_MATCH_RANGES]]); + const spaced = '가 '.repeat(5_000); + const ranges = matchRanges(spaced, ['가']); + expect(ranges).toHaveLength(MAX_MATCH_RANGES); + expect(ranges[0]).toEqual([0, 1]); + expect(matchRanges(spaced, ['가'], 3)).toEqual([ + [0, 1], + [2, 3], + [4, 5], + ]); + }); + + it('긴 글 · 낱말 여럿도 오래 걸리지 않는다', () => { + const body = '계약서를 보내 드립니다 Project plan 회의록 '.repeat(800); + const cpu = startCpuTimer(); + const ranges = matchRanges(body, [ + '계약', + 'project', + '회의', + '드립', + 'plan', + '보내', + '없는말', + '록', + ]); + expect(ranges.length).toBeGreaterThan(0); + expect(cpu()).toBeLessThan(2_000); + }); +}); diff --git a/backend/src/shared/utils/search-text.ts b/backend/src/shared/utils/search-text.ts new file mode 100644 index 0000000..498b512 --- /dev/null +++ b/backend/src/shared/utils/search-text.ts @@ -0,0 +1,234 @@ +/** + * 메일 검색의 접기(정규화) · 글자 두 개 묶음 · 맞은 곳 (순수 함수 — 14단계 설계 B1). + * ⚠️ frontend `shared/utils/search-match.ts` 와 **짝**이다 — 같은 접기 · 같은 위치 규칙, 같은 표본 표(backend `test/search-text-samples.ts` · + * frontend `src/test-utils/search-text-samples.ts`)를 두 쪽 테스트에 둔다. 한쪽을 고치면 둘 다 고친다. + * + * 접기(foldText)는 먼저 NFC 로 맞춘 뒤 **코드 포인트 하나씩** 바꾼다 — 전각 ASCII(! ~ ~) → 반각, 소문자(결과가 코드 포인트 하나이고 + * UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로), 공백류(줄바꿈 · 탭 · NBSP · U+3000 등 정규식 `\s` 의 글자) → 띄어쓰기(U+0020) 하나. + * 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다 → 접은 글에서 찾은 위치를 원문에 그대로 쓴다(강조 · 미리 보기). + * NFKC 는 쓰지 않는다 — ㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다. 공백은 1:1 로만 바꾼다(이어진 공백을 하나로 줄이지 않는다) — + * 구절 "계약 갱신" 은 줄바꿈으로 갈린 글에도 맞지만, 띄어쓰기 두 칸으로 갈린 글에는 맞지 않는다(알려진 한계). + * ⚠️ 접기 · 묶음 규칙을 바꾸면 색인 판(mail-search.constants.ts 의 INDEX_FORMAT)을 올린다 — 옛 판으로 만든 색인은 다시 채운다 + * + * 모든 함수는 입력을 한 번(맞은 곳은 낱말마다 한 번) 훑는다 — 길이에 비례한다. + */ + +/** 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) */ +const FULLWIDTH_FIRST = 0xff01; +const FULLWIDTH_LAST = 0xff5e; +const FULLWIDTH_OFFSET = 0xfee0; + +const ASCII_UPPER_A = 0x41; +const ASCII_UPPER_Z = 0x5a; +const ASCII_CASE_OFFSET = 0x20; +const ASCII_LIMIT = 0x80; + +/** 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호 (toLowerCase 를 부르지 않고 건너뛴다) */ +const HANGUL_JAMO_FIRST = 0x1100; +const HANGUL_JAMO_LAST = 0x11ff; +const CJK_FIRST = 0x2e80; +const CJK_LAST = 0x9fff; +const HANGUL_SYLLABLE_FIRST = 0xac00; +const HANGUL_SYLLABLE_LAST = 0xd7a3; + +const HIGH_SURROGATE_FIRST = 0xd800; +const HIGH_SURROGATE_LAST = 0xdbff; +const LOW_SURROGATE_FIRST = 0xdc00; +const LOW_SURROGATE_LAST = 0xdfff; + +/** 맞은 곳을 이만큼까지만 모은다 — 글이 길고 낱말이 흔하면(한 글자) 끝없이 늘지 않게 */ +export const MAX_MATCH_RANGES = 500; + +/** 맞은 곳 — [시작, 끝) UTF-16 위치 */ +export type MatchRange = readonly [start: number, end: number]; + +function isHighSurrogate(code: number): boolean { + return code >= HIGH_SURROGATE_FIRST && code <= HIGH_SURROGATE_LAST; +} + +function isLowSurrogate(code: number): boolean { + return code >= LOW_SURROGATE_FIRST && code <= LOW_SURROGATE_LAST; +} + +/** 소문자가 따로 없는 글자인가 (빠른 길 — 한글 · 한자) */ +function hasNoCase(code: number): boolean { + return ( + (code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) || + (code >= CJK_FIRST && code <= CJK_LAST) || + (code >= HANGUL_JAMO_FIRST && code <= HANGUL_JAMO_LAST) + ); +} + +/** 공백류를 바꾼 글자 — 띄어쓰기 하나 */ +const SPACE = ' '; +const SPACE_CODE = 0x20; + +/** ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 */ +function foldAsciiCode(code: number): number { + return code >= ASCII_UPPER_A && code <= ASCII_UPPER_Z + ? code + ASCII_CASE_OFFSET + : code; +} + +/** + * ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null. + * `char` 는 그 코드 포인트의 글(UTF-16 한두 단위)이다 + */ +function foldCodePoint(code: number, char: string): string | null { + if (code >= FULLWIDTH_FIRST && code <= FULLWIDTH_LAST) { + return String.fromCharCode(foldAsciiCode(code - FULLWIDTH_OFFSET)); + } + if (hasNoCase(code)) return null; + const lower = char.toLowerCase(); + if (lower === char || lower.length !== char.length) return null; + // 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만 + const first = lower.codePointAt(0) ?? 0; + const firstWidth = first > 0xffff ? 2 : 1; + return firstWidth === lower.length ? lower : null; +} + +/** + * 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기. + * 결과는 `text.normalize('NFC')` 와 UTF-16 길이 · 위치가 같다 + */ +export function foldText(text: string): string { + const source = text.normalize('NFC'); + let folded = ''; + let runStart = 0; + for (let index = 0; index < source.length; ) { + const code = source.charCodeAt(index); + let width = 1; + let point = code; + if ( + isHighSurrogate(code) && + index + 1 < source.length && + isLowSurrogate(source.charCodeAt(index + 1)) + ) { + width = 2; + point = source.codePointAt(index) ?? code; + } + let replacement: string | null; + if (isSpaceCode(code)) { + // 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1 + replacement = code === SPACE_CODE ? null : SPACE; + } else if (code < ASCII_LIMIT) { + const folded = foldAsciiCode(code); + replacement = folded === code ? null : String.fromCharCode(folded); + } else { + replacement = foldCodePoint(point, source.slice(index, index + width)); + } + if (replacement !== null) { + folded += source.slice(runStart, index) + replacement; + runStart = index + width; + } + index += width; + } + return runStart === 0 ? source : folded + source.slice(runStart); +} + +/** + * 공백 글자인가 — 낱말 나누기 · 묶음의 경계 (JS 정규식 `\s` 와 같은 글자들: 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등). + * 검색어를 낱말로 나누는 기준(SEARCH_WORD_SEPARATOR — `\s+`)과 같다 + */ +export function isSpaceCode(code: number): boolean { + if (code <= 0x20) { + return code === 0x20 || (code >= 0x09 && code <= 0x0d); + } + if (code < 0xa0) return false; + return ( + code === 0xa0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff + ); +} + +/** + * 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번). + * 접은 글(foldText)에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다. + * `into` 를 주면 거기에 더한다 (색인의 다섯 칸을 한 모음으로 — 칸 경계를 넘지 않는다) + */ +export function gramsOf( + fold: string, + into: Set = new Set(), +): Set { + let previous: string | null = null; + for (let index = 0; index < fold.length; ) { + const code = fold.charCodeAt(index); + const width = + isHighSurrogate(code) && + index + 1 < fold.length && + isLowSurrogate(fold.charCodeAt(index + 1)) + ? 2 + : 1; + if (isSpaceCode(code)) { + previous = null; + } else { + const char = width === 1 ? fold[index] : fold.slice(index, index + 2); + if (previous !== null) into.add(previous + char); + previous = char; + } + index += width; + } + return into; +} + +/** 맞은 곳들을 시작 차례로 놓고 겹치거나 맞닿은 것을 합친다 */ +function mergeRanges(ranges: MatchRange[]): MatchRange[] { + ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const merged: [number, number][] = []; + for (const [start, end] of ranges) { + const last = merged[merged.length - 1]; + if (last && start <= last[1]) { + if (end > last[1]) last[1] = end; + } else { + merged.push([start, end]); + } + } + return merged; +} + +/** + * 맞은 곳 — 원문(`original` — NFC 여야 위치가 맞는다)에서 찾는 말들(`terms` — 낱말 · 구절, 접기 전 글)이 나오는 자리를 모두 찾아 + * 겹치거나 맞닿은 것을 합친 [시작, 끝) 목록(UTF-16, 시작 차례). 같은 접기 · 부분 일치다(찾기와 같다 — 'aa' 는 'aaa' 의 [0, 3)). + * 빈 말은 건너뛴다. 찾은 자리는 말마다 `limit`(기본 MAX_MATCH_RANGES)개까지 모으고, 합친 목록도 앞쪽부터 그만큼이다 + */ +export function matchRanges( + original: string, + terms: readonly string[], + limit: number = MAX_MATCH_RANGES, +): MatchRange[] { + return matchFoldedRanges(foldText(original), foldTerms(terms), limit); +} + +/** 찾는 말들을 접는다 — 같은 말은 한 번, 빈 말은 뺀다 (여러 글에 같은 말을 맞출 때 한 번만 접게) */ +export function foldTerms(terms: readonly string[]): string[] { + return [...new Set(terms.map(foldText))].filter((term) => term !== ''); +} + +/** matchRanges 의 접은 글 판 — `haystack` 은 foldText 한 글, `terms` 는 foldTerms 한 말 */ +export function matchFoldedRanges( + haystack: string, + terms: readonly string[], + limit: number = MAX_MATCH_RANGES, +): MatchRange[] { + const found: MatchRange[] = []; + for (const term of terms) { + if (term === '') continue; + let count = 0; + for ( + let at = haystack.indexOf(term); + at >= 0 && count < limit; + at = haystack.indexOf(term, at + 1) + ) { + found.push([at, at + term.length]); + count += 1; + } + } + return mergeRanges(found).slice(0, limit); +} diff --git a/backend/src/shared/utils/subject-base.spec.ts b/backend/src/shared/utils/subject-base.spec.ts new file mode 100644 index 0000000..529c35d --- /dev/null +++ b/backend/src/shared/utils/subject-base.spec.ts @@ -0,0 +1,30 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { SUBJECT_BASE_SAMPLES } from '@/../test/search-text-samples'; +import { subjectBase } from './subject-base'; + +describe('subjectBase (말머리를 뗀 제목)', () => { + it.each(SUBJECT_BASE_SAMPLES)('%j → %j', (subject, base) => { + expect(subjectBase(subject)).toBe(base); + }); + + it('답장 · 전달 제목과 원본이 같은 글이 된다', () => { + const original = '[공지] 11월 회의 일정'; + for (const reply of [ + 'Re: [공지] 11월 회의 일정', + '회신: [공지] 11월 회의 일정', + 'RE: 전달: [공지] 11월 회의 일정', + 'Fwd:[공지] 11월 회의 일정', + ]) { + expect(subjectBase(reply)).toBe(subjectBase(original)); + } + }); + + it('말머리가 아주 많아도 · 닫히지 않은 대괄호가 길어도 길이에 비례한다', () => { + const prefixes = 'Re: [a] '.repeat(20_000) + '회의'; + const open = '['.repeat(100_000); + const cpu = startCpuTimer(); + expect(subjectBase(prefixes)).toBe('회의'); + expect(subjectBase(open)).toBe(open); + expect(cpu()).toBeLessThan(3_000); + }); +}); diff --git a/backend/src/shared/utils/subject-base.ts b/backend/src/shared/utils/subject-base.ts new file mode 100644 index 0000000..d6baeea --- /dev/null +++ b/backend/src/shared/utils/subject-base.ts @@ -0,0 +1,49 @@ +import { foldText, isSpaceCode } from './search-text'; + +/** + * 말머리를 뗀 제목의 접기 글 (순수 함수 — 14단계 설계 B4) — 같은 대화인지 볼 때 제목을 견주는 글이다. + * ⚠️ frontend `shared/utils/search-match.ts` 와 짝 — 같은 표본 표(`test/search-text-samples.ts` 의 SUBJECT_BASE_SAMPLES)를 두 쪽에 둔다. + * + * 차례: 접기(foldText — 소문자 · 전각 → 반각이라 `RE:` · `[공지]` 도 같다) → 공백을 하나로 · 앞뒤 공백 빼기 → + * 앞의 말머리를 여러 겹 뗀다 → 다시 공백 정리. + * - 답장 · 전달 말머리: `re` · `fw` · `fwd` · `aw` · `wg` · `회신` · `답장` · `전달` (대소문자 없이) — 뒤에 `[2]` · `(2)` 같은 수가 붙어도, + * 쌍점 앞에 공백이 있어도 된다. 쌍점은 꼭 있어야 한다(`Reply to` 는 떼지 않는다) + * - 대괄호 꼬리표: `[…]` (첫 `]` 까지) + * 엔진(Stalwart 0.16.27)은 `회신:` · `답장:` · `전달:` 을 떼지 않아 한국어 Outlook 의 답장이 따로 떨어진다(2026-11-03 실측) — 우리 규칙은 이것도 뗀다. + * 다 떼어 빈 글이 되어도 그대로 빈 글이다(빈 제목의 답장과 원본이 같다). + * + * 한 번 훑는다 — 말머리를 떼는 고리는 늘 앞으로만 간다(길이에 비례). + */ + +/** 앞의 말머리 하나 — 답장 · 전달 말머리 또는 대괄호 꼬리표, 뒤의 공백까지 (접은 글 — 소문자 · 반각) */ +const LEADING_PREFIX = + /(?:(?:re|fwd|fw|aw|wg|회신|답장|전달) ?(?:\[\d{1,4}\]|\(\d{1,4}\))? ?:|\[[^\]]*\]) ?/uy; + +/** 공백 글자의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (search-text.ts 의 isSpaceCode — 낱말 나누기와 같은 공백) */ +function collapseSpaces(text: string): string { + let collapsed = ''; + let pendingSpace = false; + for (let index = 0; index < text.length; index += 1) { + if (isSpaceCode(text.charCodeAt(index))) { + pendingSpace = collapsed !== ''; + continue; + } + if (pendingSpace) collapsed += ' '; + pendingSpace = false; + collapsed += text[index]; + } + return collapsed; +} + +export function subjectBase(subject: string): string { + const text = collapseSpaces(foldText(subject)); + // 붙은 자리(sticky)에서만 맞춘다 — 뗄 때마다 글을 자르지 않고 자리만 옮긴다 + let position = 0; + for (;;) { + LEADING_PREFIX.lastIndex = position; + const prefix = LEADING_PREFIX.exec(text); + if (!prefix || prefix[0] === '') break; + position += prefix[0].length; + } + return text.slice(position).trim(); +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 517c3b5..12fe445 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -12,6 +12,7 @@ import request from 'supertest'; import type { Response, Test as HttpTest } from 'supertest'; import { App } from 'supertest/types'; import { DataSource } from 'typeorm'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { AppModule } from '@/app.module'; import { configureApp, createHttpAdapter } from '@/app.setup'; @@ -122,6 +123,22 @@ import { SIGNATURE_IMAGE_MAX_BYTES, } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; +import { + MAIL_INDEX_SOURCE, + type IndexDocument, + type MailIndexSource, +} from '@/modules/mail-search/mail-index-source'; +import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service'; +import type { SearchScope } from '@/modules/mail-search/mail-search.types'; +import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository'; +import { PLANNER_SETTINGS } from '@/modules/mail-search/mail-search-query.service'; +import { + INDEX_FORMAT, + SEARCH_CHECK_ROWS_LIMIT, +} from '@/modules/mail-search/mail-search.constants'; +import { toIndexRow } from '@/modules/mail-search/index-document'; +import { planSearch } from '@/modules/mail-search/search-plan'; +import { candidatesSql } from '@/modules/mail-search/search-sql'; import { SystemSettingsService, type SystemSettingsPatch, @@ -212,6 +229,8 @@ const MAIL_STATE = 'c2Iy.c2Fq'; /** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */ const MISSING_MAILBOX = 'nomailbox'; +/** 대역 엔진에 없는 메일 (14단계 3차 — 같은 대화의 404) */ +const MISSING_MESSAGE = 'nosuchmail'; /** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */ const GONE_MESSAGE = 'gonemessage'; @@ -533,6 +552,139 @@ const testFileStorageSettings = (dir: string): FileStorageSettings => ({ clamav: null, }); +/** + * 14단계 — 검색 색인의 대역 엔진: 직원마다 메일 글과 state. 실제 엔진 읽기(JMAP)는 jmap-index-source.spec.ts 가 본다. + * 넣지 않은 직원은 엔진 장애(MAIL_001)로 답한다 — 개발 DB 를 함께 쓰는 실제 직원의 줄을 대역으로 채우지 않게 + */ +class FakeIndexSource implements MailIndexSource { + readonly mailboxes = new Map< + string, + { state: string; emails: IndexDocument[] } + >(); + + private mailbox(user: string) { + const found = this.mailboxes.get(user); + if (!found) { + throw new BusinessException('MAIL_001', { + context: { reason: 'e2e-unknown-index-user' }, + }); + } + return found; + } + + snapshot(user: string) { + const { state, emails } = this.mailbox(user); + return Promise.resolve({ state, total: emails.length }); + } + + changes(user: string, sinceState: string) { + const { state } = this.mailbox(user); + // 같은 state 면 변화 없음, 다르면 셀 수 없음(전체 대조로) + return Promise.resolve( + sinceState === state + ? { + kind: 'changes' as const, + created: [], + updated: [], + destroyed: [], + newState: state, + hasMore: false, + } + : { kind: 'cannotCalculate' as const }, + ); + } + + documents(user: string, ids: readonly string[]) { + const { emails } = this.mailbox(user); + return Promise.resolve(emails.filter((email) => ids.includes(email.id))); + } + + mutable(user: string, ids: readonly string[]) { + return this.documents(user, ids); + } + + pageIds( + user: string, + from: { anchor: string | null; position: number }, + limit: number, + ) { + const ids = this.newestFirst(user); + const anchorAt = from.anchor === null ? -1 : ids.indexOf(from.anchor); + const start = anchorAt >= 0 ? anchorAt + 1 : from.position; + return Promise.resolve({ + ids: ids.slice(start, start + limit), + position: start, + total: ids.length, + }); + } + + allIds(user: string) { + const { state } = this.mailbox(user); + return Promise.resolve({ state, ids: this.newestFirst(user) }); + } + + private newestFirst(user: string): string[] { + return [...this.mailbox(user).emails] + .sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)) + .map((email) => email.id); + } +} + +/** 14단계 — 색인에 넣을 메일 한 통 (대역) */ +function indexDocument(overrides: Partial): IndexDocument { + return { + id: 'e2e-mail', + threadId: 'e2e-thread', + mailboxIds: ['inbox'], + keywords: [], + receivedAt: '2026-11-05T01:00:00Z', + size: 100, + messageIds: [], + inReplyTo: [], + references: [], + subject: '', + from: [{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }], + to: [], + cc: [], + attachmentNames: [], + hasAttachment: false, + preview: '', + bodyText: '', + ...overrides, + }; +} + +/** 대역 엔진이 색인 검색에서 빼는 메일함 (휴지통 · 스팸메일함 역할 — 14단계 2차) */ +const SEARCH_EXCLUDED_MAILBOX = 'idxtrash'; + +/** 색인 문서의 주소 → 요약의 주소 (주소가 없는 것은 뺀다) */ +function mailAddressesOf( + addresses: readonly { name: string | null; email: string | null }[], +): { name: string | null; email: string }[] { + return addresses.flatMap(({ name, email }) => + email === null ? [] : [{ name, email }], + ); +} + +/** 대역 엔진의 요약 — 색인 문서와 같은 메일 (14단계 2차 — 색인으로 찾은 쪽의 요약은 엔진에서 읽는다) */ +function summaryOfDocument(document: IndexDocument): MessageSummary { + return { + id: document.id, + threadId: document.threadId ?? document.id, + mailboxIds: [...document.mailboxIds], + from: mailAddressesOf(document.from)[0] ?? null, + to: mailAddressesOf(document.to), + subject: document.subject, + preview: document.preview, + receivedAt: new Date(document.receivedAt).toISOString(), + isRead: document.keywords.includes('$seen'), + isFlagged: document.keywords.includes('$flagged'), + hasAttachment: document.hasAttachment, + size: document.size, + pendingSend: null, + }; +} + /** 목록 응답의 data — 계약의 { items, pageInfo } */ interface Page { items: T[]; @@ -644,6 +796,21 @@ describe('응답 계약 (e2e)', () => { /** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */ const listQueries: { user: string; query: MessageListQuery }[] = []; + /** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */ + const summaryRequests: { user: string; ids: readonly string[] }[] = []; + /** 14단계 3차 — 대역 엔진이 받은 대화 요청 (엔진의 스레드 — 색인 전) */ + const engineConversations: { + user: string; + emailId: string; + page: { limit: number; cursor?: string }; + }[] = []; + const engineConversationLists: { + user: string; + query: { mailboxId: string; limit: number; cursor?: string }; + }[] = []; + /** 14단계 3차 리뷰 L6 — 대역 엔진의 붙잡힌 메일(보내기 취소 · 예약을 기다리는) id 와 그것을 함께 읽은 요약 요청 */ + const fakeHeldIds = new Set(); + const heldSummaryRequests: { user: string; ids: readonly string[] }[] = []; /** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */ const bulkUpdates: { user: string; @@ -967,6 +1134,9 @@ describe('응답 계약 (e2e)', () => { const rotated = (res: Response) => cookieValue(setCookie(res, 'refresh_token')); + /** 14단계 — 검색 색인의 대역 엔진 */ + const fakeIndexSource = new FakeIndexSource(); + beforeAll(async () => { fileDir = mkdtempSync(join(tmpdir(), 'arasystem-e2e-files-')); const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -1163,6 +1333,111 @@ describe('응답 계약 (e2e)', () => { total: 0, }); }, + // 14단계 2차 — 색인 검색의 범위 · 찾은 쪽의 요약 (엔진 쪽 판단은 jmap-mail.provider.spec.ts 가 본다) + searchScope: ( + _user: string, + mailboxId?: string, + ): Promise => { + if (mailboxId === MISSING_MAILBOX) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ); + } + return Promise.resolve( + mailboxId === undefined + ? { + mailboxId: null, + excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX], + excludedKeywords: ['$ara-sending'], + } + : { + mailboxId, + excludedMailboxIds: [], + excludedKeywords: ['$ara-sending'], + }, + ); + }, + // 14단계 3차 — 대화의 범위(늘 휴지통을 뺀다) · 엔진의 스레드로 본 대화(색인 전 — 받은 것만 적는다) + conversationScope: ( + _user: string, + mailboxId?: string, + ): Promise => + mailboxId === MISSING_MAILBOX + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ) + : Promise.resolve({ + mailboxId: mailboxId ?? null, + excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX], + excludedKeywords: ['$ara-sending'], + }), + getConversation: ( + user: string, + emailId: string, + page: { limit: number; cursor?: string }, + ) => { + engineConversations.push({ user, emailId, page }); + return emailId === MISSING_MESSAGE + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ) + : Promise.resolve({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + }, + listConversations: ( + user: string, + query: { mailboxId: string; limit: number; cursor?: string }, + ) => { + engineConversationLists.push({ user, query }); + return query.mailboxId === MISSING_MAILBOX + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ) + : Promise.resolve({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + }, + getSummaries: ( + user: string, + ids: readonly string[], + ): Promise => { + summaryRequests.push({ user, ids }); + const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? []; + const byId = new Map(emails.map((email) => [email.id, email])); + return Promise.resolve( + ids.flatMap((id) => { + const email = byId.get(id); + return email ? [summaryOfDocument(email)] : []; + }), + ); + }, + getSummariesWithHeld: (user: string, ids: readonly string[]) => { + heldSummaryRequests.push({ user, ids }); + const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? []; + const byId = new Map(emails.map((email) => [email.id, email])); + return Promise.resolve({ + summaries: ids.flatMap((id) => { + const email = byId.get(id); + return email ? [summaryOfDocument(email)] : []; + }), + heldIds: new Set(fakeHeldIds), + }); + }, updateMessages: ( user: string, ids: readonly string[], @@ -1599,6 +1874,9 @@ describe('응답 계약 (e2e)', () => { .useValue(fakeAccountSecurityProvider) .overrideProvider(ACCOUNT_SECURITY_SETTINGS) .useValue(TEST_ACCOUNT_SECURITY_SETTINGS) + // 14단계 — 검색 색인의 엔진 읽기는 대역으로 + .overrideProvider(MAIL_INDEX_SOURCE) + .useValue(fakeIndexSource) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1614,6 +1892,13 @@ describe('응답 계약 (e2e)', () => { configureApp(app); await app.init(); + // 14단계 — 검색 색인의 5분 맞추기 · 1분 채우기는 돌리지 않는다 — 테스트가 직접 부른다. + // 거꾸로 같은 DB 를 쓰는 개발 서버(개발 PC 에서만 — CI 에는 없다)의 cron 은 사내 도메인(arasystem.test) 주소만 맞추고 채우므로 + // 이 시험의 줄(e2e.arasystem.test)을 건드리지 않는다 (MailSearchSyncService.isIndexedAddress) + const scheduler = app.get(SchedulerRegistry); + for (const name of ['mail-search-sync', 'mail-search-build']) { + void scheduler.getCronJob(name).stop(); + } server = app.getHttpServer(); jwt = app.get(JwtService, { strict: false }); authSettings = app.get(AUTH_SETTINGS, { strict: false }); @@ -1645,6 +1930,9 @@ describe('응답 계약 (e2e)', () => { 'DELETE FROM login_events WHERE user_email LIKE $1', 'DELETE FROM app_password_usages WHERE user_email LIKE $1', 'DELETE FROM two_factor_settings WHERE user_email LIKE $1', + // 14단계 — 검색 색인 (로그인하면 상태 줄이 생긴다) + 'DELETE FROM mail_search_messages WHERE user_email LIKE $1', + 'DELETE FROM mail_search_states WHERE user_email LIKE $1', ]) { await dataSource.query(statement, [TEST_DOMAIN_PATTERN]); } @@ -5042,7 +5330,11 @@ describe('응답 계약 (e2e)', () => { expect(body(res)).toEqual({ success: true, - data: { undoSendSeconds: 10, externalImages: 'ask' }, + data: { + undoSendSeconds: 10, + externalImages: 'ask', + groupByConversation: false, + }, }); }); @@ -5053,6 +5345,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(first)).toEqual({ undoSendSeconds: 10, externalImages: 'always', + groupByConversation: false, }); const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect( @@ -5061,6 +5354,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(second)).toEqual({ undoSendSeconds: 30, externalImages: 'always', + groupByConversation: false, }); const read = await request(server) @@ -5070,6 +5364,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(read)).toEqual({ undoSendSeconds: 30, externalImages: 'always', + groupByConversation: false, }); const rows: { undo_send_seconds: number; @@ -5083,6 +5378,40 @@ describe('응답 계약 (e2e)', () => { ]); }); + it('대화로 묶어 보기(14단계) — 처음 끔, PATCH 로 켜고 끄며 다른 칸은 그대로 · DB 의 열에 저장', async () => { + const GROUP = 'settings.group@e2e.arasystem.test'; + const on = await patchSettings(GROUP, { + groupByConversation: true, + }).expect(200); + expect(dataOf(on)).toEqual({ + undoSendSeconds: 10, + externalImages: 'ask', + groupByConversation: true, + }); + const read = await request(server) + .get('/api/mail-settings') + .set('Cookie', cookieOf(GROUP)) + .expect(200); + expect(dataOf<{ groupByConversation: boolean }>(read)).toMatchObject({ + groupByConversation: true, + }); + const [row]: { group_by_conversation: boolean }[] = + await dataSource.query( + 'SELECT group_by_conversation FROM mail_settings WHERE user_email = $1', + [GROUP], + ); + expect(row).toEqual({ group_by_conversation: true }); + const off = await patchSettings(GROUP, { + groupByConversation: false, + undoSendSeconds: 5, + }).expect(200); + expect(dataOf(off)).toEqual({ + undoSendSeconds: 5, + externalImages: 'ask', + groupByConversation: false, + }); + }); + it.each<[string, object]>([ ['빈 본문(하나 이상)', {}], ['고를 수 없는 초', { undoSendSeconds: 7 }], @@ -5090,6 +5419,8 @@ describe('응답 계약 (e2e)', () => { ['null 초', { undoSendSeconds: null }], ['모르는 외부 이미지 값', { externalImages: 'never' }], ['null 외부 이미지', { externalImages: null }], + ['글로 쓴 대화로 묶기', { groupByConversation: 'true' }], + ['null 대화로 묶기', { groupByConversation: null }], ['모르는 칸', { theme: 'dark' }], ])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => { const res = await patchSettings(ME, payload).expect(400); @@ -7573,6 +7904,1352 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('14단계 1차 — 검색 색인 바탕 (GET /api/search-index · 색인 표 · 채우기)', () => { + const ME = 'search.index@e2e.arasystem.test'; + const OTHER = 'search.other@e2e.arasystem.test'; + const FROM_IP = '198.51.100.140'; + + const signIn = async (email: string) => { + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', FROM_IP) + .send({ email, password: CORRECT_PASSWORD }) + .expect(200); + return `access_token=${cookieValue(setCookie(res, 'access_token'))}`; + }; + const statusOf = (cookie: string) => + request(server).get('/api/search-index').set('Cookie', cookie); + + afterEach(async () => { + fakeIndexSource.mailboxes.clear(); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query( + `DELETE FROM "${table}" WHERE user_email = ANY($1::text[])`, + [[ME, OTHER]], + ); + } + }); + + it('로그인하면 상태 줄이 building 으로 생긴다 — 준비 상태는 { building, 0, null } · 로그인 없이는 401 AUTH_001', async () => { + const cookie = await signIn(ME); + const rows: { status: string; email_state: string | null }[] = + await dataSource.query( + 'SELECT status, email_state FROM mail_search_states WHERE user_email = $1', + [ME], + ); + expect(rows).toEqual([{ status: 'building', email_state: null }]); + + const res = await statusOf(cookie).expect(200); + expect(dataOf(res)).toEqual({ status: 'building', done: 0, total: null }); + + const anonymous = await request(server) + .get('/api/search-index') + .expect(401); + expect(body(anonymous).error?.code).toBe('AUTH_001'); + }); + + it('상태 줄이 없던 직원은 준비 상태를 물을 때 만든다 · 멈춘 줄은 로그인하면 푼다', async () => { + // 세션 없이 서명만 맞춘 쿠키 — 로그인을 거치지 않았다 + await statusOf(cookieOf(OTHER)).expect(200); + await dataSource.query( + `UPDATE mail_search_states SET paused_reason = 'locked', failure_count = 3 WHERE user_email = $1`, + [OTHER], + ); + await signIn(OTHER); + const rows: { paused_reason: string | null; failure_count: number }[] = + await dataSource.query( + 'SELECT paused_reason, failure_count FROM mail_search_states WHERE user_email = $1', + [OTHER], + ); + expect(rows).toEqual([{ paused_reason: null, failure_count: 0 }]); + }); + + it('채우기 → ready — 색인 줄의 글자 두 개 묶음(GIN)과 접기 글(strpos)로 부분 일치를 찾는다 (실제 PostgreSQL)', async () => { + const cookie = await signIn(ME); + fakeIndexSource.mailboxes.set(ME, { + state: 's1', + emails: [ + indexDocument({ + id: 'm-contract', + subject: 'Re: [공지] 11월 계약서', + bodyText: '계약서를 보내 드립니다. Project PLAN 첨부', + attachmentNames: ['계약서.PDF'], + hasAttachment: true, + mailboxIds: ['inbox', 'archive'], + keywords: ['$seen'], + receivedAt: '2026-11-04T01:00:00Z', + }), + indexDocument({ + id: 'm-meeting', + subject: '회의록', + bodyText: '내일 회의는 3층 회의실', + receivedAt: '2026-11-05T01:00:00Z', + }), + ], + }); + + const build = app.get(MailSearchBuildService); + // 마무리(따라잡기 · 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만 시작한다 — 넉넉히 + expect(await build.build(ME, Date.now() + 120_000)).toBe('ready'); + expect(dataOf(await statusOf(cookie).expect(200))).toEqual({ + status: 'ready', + done: 2, + total: 2, + }); + + const find = async (column: string, word: string, grams: string[]) => + ( + await dataSource.query<{ email_id: string }[]>( + `SELECT email_id FROM mail_search_messages WHERE user_email = $1 AND grams @> $2::text[] AND strpos(${column}, $3) > 0 ORDER BY received_at DESC`, + [ME, grams, word], + ) + ).map((row) => row.email_id); + // 붙여 쓴 말 · 앞부분 · 말머리 · 대소문자 + expect(await find('fold_body', '계약', ['계약'])).toEqual(['m-contract']); + expect(await find('fold_body', 'proj', ['pr', 'ro', 'oj'])).toEqual([ + 'm-contract', + ]); + expect(await find('fold_subject', '공지', ['공지'])).toEqual([ + 'm-contract', + ]); + expect(await find('fold_attachment', '.pdf', ['.p', 'pd', 'df'])).toEqual( + ['m-contract'], + ); + expect(await find('fold_body', '회의', ['회의'])).toEqual(['m-meeting']); + // 메일함 범위(GIN) · 배열 칸이 그대로 들어갔다 + const inArchive: { email_id: string; keywords: string[] }[] = + await dataSource.query( + `SELECT email_id, keywords FROM mail_search_messages WHERE user_email = $1 AND mailbox_ids && $2::text[]`, + [ME, ['archive']], + ); + expect(inArchive).toEqual([ + { email_id: 'm-contract', keywords: ['$seen'] }, + ]); + }); + + it('표 쓰기 — 넣기 · 메일함 · 키워드만 바꾸기(한 문장) · 지우기, 상태 줄이 없으면 아무것도 쓰지 않는다', async () => { + await statusOf(cookieOf(OTHER)).expect(200); + const repository = app.get(MailSearchIndexRepository); + const now = new Date(); + const row = toIndexRow(indexDocument({ id: 'w1', subject: '견적' })); + + expect( + ( + await repository.write( + OTHER, + { upserts: [row, { ...row, emailId: 'w2' }] }, + now, + ) + ).written, + ).toBe(true); + const updated = await repository.write( + OTHER, + { + updates: [ + { + id: 'w1', + mailboxIds: ['trash'], + keywords: ['$seen', '$flagged'], + }, + { id: 'not-indexed', mailboxIds: ['inbox'], keywords: [] }, + ], + deletes: ['w2'], + state: { emailState: 's9', lastSyncedAt: now }, + }, + now, + ); + expect([...updated.updatedIds]).toEqual(['w1']); + expect(await repository.indexedIds(OTHER)).toEqual(['w1']); + expect([...(await repository.existingIds(OTHER, ['w1', 'w2']))]).toEqual([ + 'w1', + ]); + const [stored]: { mailbox_ids: string[]; keywords: string[] }[] = + await dataSource.query( + 'SELECT mailbox_ids, keywords FROM mail_search_messages WHERE user_email = $1', + [OTHER], + ); + expect(stored).toEqual({ + mailbox_ids: ['trash'], + keywords: ['$seen', '$flagged'], + }); + expect((await repository.findState(OTHER))?.emailState).toBe('s9'); + expect(await repository.recordFailure(OTHER, now)).toBe(1); + + // 퇴사 마무리처럼 상태 줄 → 색인 줄을 지우면, 뒤늦은 쓰기는 아무것도 하지 않는다 + await dataSource.query( + 'DELETE FROM mail_search_states WHERE user_email = $1', + [OTHER], + ); + await dataSource.query( + 'DELETE FROM mail_search_messages WHERE user_email = $1', + [OTHER], + ); + expect( + (await repository.write(OTHER, { upserts: [row] }, now)).written, + ).toBe(false); + expect(await repository.countMessages(OTHER)).toBe(0); + }); + }); + + describe('14단계 2차 — 색인으로 찾기 · 조건 · 강조 (GET /api/messages · GET /api/messages/{id})', () => { + const ME = 'search.query@e2e.arasystem.test'; + const SEOJUN = { name: '박서준', email: 'seojun.park@e2e.arasystem.test' }; + const DOCUMENTS = [ + indexDocument({ + id: 'm-meeting', + subject: '회의 일정', + bodyText: '내일 회의는 3층 회의실에서 합니다', + preview: '내일 회의는 3층 회의실에서 합니다', + from: [SEOJUN], + to: [{ name: '김아라', email: ME }], + receivedAt: '2026-11-05T01:00:00Z', + }), + indexDocument({ + id: 'm-contract', + subject: '[공지] 11월 계약서', + bodyText: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.', + preview: '계약서를 보내 드립니다.', + attachmentNames: ['계약서.PDF'], + hasAttachment: true, + cc: [{ name: 'Partner', email: 'partner@example.com' }], + receivedAt: '2026-11-04T01:00:00Z', + }), + indexDocument({ + id: 'm-renewal', + subject: '회의록', + bodyText: '계약 갱신 회의 내용을 정리했습니다', + preview: '계약 갱신 회의 내용을 정리했습니다', + receivedAt: '2026-11-03T01:00:00Z', + }), + indexDocument({ + id: 'm-floor', + subject: '7층 공사 안내', + bodyText: '공사 기간 동안 엘리베이터를 쓸 수 없습니다', + receivedAt: '2026-11-02T01:00:00Z', + }), + // 휴지통에만 든 메일 — 메일함을 정하지 않은 검색에서 빠진다 + indexDocument({ + id: 'm-trash', + subject: '지난 계약 해지', + bodyText: '계약 해지 통보', + mailboxIds: [SEARCH_EXCLUDED_MAILBOX], + receivedAt: '2026-11-01T01:00:00Z', + }), + // 답장 — 내 글 아래에 원문 인용(> 줄)이 있다 (미리 보기 고르기 — 2026-11-06 사용자 관점 점검) + indexDocument({ + id: 'm-reply', + subject: 'RE: 해오름 프로젝트 안내', + bodyText: + '네, 받았습니다. 금요일까지 확인하겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.', + preview: + '네, 받았습니다. 금요일까지 확인하겠습니다. > 원본 메일 > 해오름 프로젝트 R9A51 자료를 보냅니다.', + receivedAt: '2026-10-30T01:00:00Z', + }), + ]; + + interface ListItem { + id: string; + subject: string; + searchHighlight: { + subject: [number, number][]; + preview: { text: string; ranges: [number, number][] }; + } | null; + } + + const list = (params: Record) => + request(server) + .get('/api/messages') + .query(params) + .set('Cookie', cookieOf(ME)); + const idsOf = async (params: Record) => + dataOf>(await list(params).expect(200)).items.map( + (item) => item.id, + ); + const setStatus = (status: 'ready' | 'building') => + dataSource.query( + 'UPDATE mail_search_states SET status = $2 WHERE user_email = $1', + [ME, status], + ); + + beforeAll(async () => { + // 상태 줄은 준비 상태를 물을 때 생긴다 — 채우기로 ready (색인 판은 지금 판) + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(ME)) + .expect(200); + fakeIndexSource.mailboxes.set(ME, { state: 'q1', emails: DOCUMENTS }); + const build = app.get(MailSearchBuildService); + expect(await build.build(ME, Date.now() + 120_000)).toBe('ready'); + }); + + afterAll(async () => { + fakeIndexSource.mailboxes.delete(ME); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query(`DELETE FROM "${table}" WHERE user_email = $1`, [ + ME, + ]); + } + }); + + it('색인이 준비되면 부분 일치로 찾는다 — 휴지통 · 스팸메일함에만 든 메일은 빼고 받은 시각 최신부터, 요약은 엔진에서 · 첫 쪽의 total', async () => { + const queriesBefore = listQueries.length; + + const res = await list({ q: '계약' }).expect(200); + const page = dataOf>(res); + + expect(page.items.map((item) => item.id)).toEqual([ + 'm-contract', + 'm-renewal', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 2, + }); + // 엔진의 목록 검색은 묻지 않았다 — 찾은 쪽의 요약만 + expect(listQueries.length).toBe(queriesBefore); + expect(summaryRequests.at(-1)).toEqual({ + user: ME, + ids: ['m-contract', 'm-renewal'], + }); + }); + + it('강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳 (UTF-16 [시작, 끝))', async () => { + const [contract] = dataOf>( + await list({ q: '계약 갱신' }).expect(200), + ).items; + + expect(contract.id).toBe('m-contract'); + expect(contract.searchHighlight).toEqual({ + subject: [[9, 11]], + preview: { + text: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.', + ranges: [ + [0, 2], + [14, 16], + ], + }, + }); + }); + + it('조건 — 보낸 사람 · 받는 사람(참조 포함) · 제목 · 내용 · 기간 · 첨부, 검색어 없이도 · 검색어와 함께면 모두 맞아야', async () => { + expect(await idsOf({ from: '박서준' })).toEqual(['m-meeting']); + expect(await idsOf({ from: 'seojun' })).toEqual(['m-meeting']); + expect(await idsOf({ to: 'partner' })).toEqual(['m-contract']); + expect(await idsOf({ subject: '회의' })).toEqual([ + 'm-meeting', + 'm-renewal', + ]); + expect(await idsOf({ body: '회의실' })).toEqual(['m-meeting']); + expect(await idsOf({ hasAttachment: 'true' })).toEqual(['m-contract']); + expect( + await idsOf({ + after: '2026-11-03T00:00:00.000Z', + before: '2026-11-04T01:00:00.000Z', + }), + ).toEqual(['m-renewal']); + expect(await idsOf({ q: '회의', from: '이도윤' })).toEqual(['m-renewal']); + expect(await idsOf({ q: '"갱신 일정"' })).toEqual(['m-contract']); + // 메일함 범위 + expect(await idsOf({ mailboxId: 'inbox', q: '계약' })).toEqual([ + 'm-contract', + 'm-renewal', + ]); + expect( + await idsOf({ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }), + ).toEqual(['m-trash']); + }); + + it('한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만 — 본문에만 있는 메일은 빠진다', async () => { + // '층' 은 m-floor 의 제목 · m-meeting 의 본문에 있다 + expect(await idsOf({ q: '층' })).toEqual(['m-floor']); + // 두 글자 넘는 낱말과 함께면 본문에서도 확인한다 + expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']); + // 본문을 찾지 않았으니 미리 보기에도 강조하지 않는다 — '공' 은 m-floor 의 제목과 본문에 있다 (2026-11-06 실사용 시험) + const found = dataOf>( + await list({ q: '공' }).expect(200), + ).items; + expect(found.map((item) => item.id)).toEqual(['m-contract', 'm-floor']); + expect(found[1].searchHighlight).toEqual({ + subject: [[3, 4]], + preview: { text: '', ranges: [] }, + }); + }); + + it('미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 제목에서도 맞으면 인용 앞의 글, 인용에만 있는 말은 > 를 뗀 인용 조각 (2026-11-06 사용자 관점 점검)', async () => { + const [byTitle] = dataOf>( + await list({ q: '해오름' }).expect(200), + ).items; + expect(byTitle.id).toBe('m-reply'); + expect(byTitle.searchHighlight).toEqual({ + subject: [[4, 7]], + preview: { + text: '네, 받았습니다. 금요일까지 확인하겠습니다.…', + ranges: [], + }, + }); + + const [byQuote] = dataOf>( + await list({ q: 'R9A51' }).expect(200), + ).items; + expect(byQuote.id).toBe('m-reply'); + expect(byQuote.searchHighlight?.preview).toEqual({ + text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.', + ranges: [[16, 21]], + }); + }); + + it('메일함 범위의 후보는 찾기 GIN 하나로 — 주소 · 묶음 · 메일함을 색인 조건으로 맞추고, 메일함만으로 줄을 모아 묶음을 줄마다 풀지 않는다 (실제 PostgreSQL — 2026-11-06 실사용 시험)', async () => { + // 메일함만 담은 GIN 은 없다 — 있으면 계획기가 그것만으로 메일함의 줄(회사 전체 posting)을 읽고 묶음 배열을 줄마다 풀 수 있다 + const gins: { indexname: string }[] = await dataSource.query( + `SELECT indexname FROM pg_indexes WHERE tablename = 'mail_search_messages' AND indexdef ILIKE '%USING gin%' ORDER BY indexname`, + ); + expect(gins.map((row) => row.indexname)).toEqual([ + 'IDX_mail_search_messages_refs', + 'IDX_mail_search_messages_user_grams_mailboxes', + ]); + + interface PlanNode { + 'Index Name'?: string; + 'Index Cond'?: string; + Filter?: string; + 'Recheck Cond'?: string; + Plans?: PlanNode[]; + } + const nodesOf = (node: PlanNode): PlanNode[] => [ + node, + ...(node.Plans ?? []).flatMap(nodesOf), + ]; + const scope: SearchScope = { + mailboxId: 'inbox', + excludedMailboxIds: [], + excludedKeywords: ['$ara-sending'], + }; + for (const text of ['계약', '계약서 갱신']) { + const query = candidatesSql( + ME, + scope, + planSearch({ text, hasAttachment: false }), + null, + SEARCH_CHECK_ROWS_LIMIT + 1, + ); + // 서비스와 같은 계획기 설정(SET LOCAL — 트랜잭션 안)으로 + const rows: { 'QUERY PLAN': unknown }[] = await dataSource.transaction( + async (manager) => { + for (const setting of PLANNER_SETTINGS.bitmap) { + await manager.query(setting); + } + return manager.query( + `EXPLAIN (FORMAT JSON) ${query.sql}`, + query.params, + ); + }, + ); + const raw = rows[0]['QUERY PLAN']; + const [explained] = ( + typeof raw === 'string' ? JSON.parse(raw) : raw + ) as { Plan: PlanNode }[]; + const nodes = nodesOf(explained.Plan); + const scans = nodes.filter((node) => node['Index Name']); + expect(scans.map((node) => node['Index Name'])).toEqual([ + 'IDX_mail_search_messages_user_grams_mailboxes', + ]); + expect(scans[0]['Index Cond']).toContain('mailbox_ids'); + expect(scans[0]['Index Cond']).toContain('grams'); + // 묶음은 줄마다 보지 않는다 — 걸러 내기(Filter)에 묶음이 없다 + expect(nodes.map((node) => node.Filter ?? '').join(' ')).not.toContain( + 'grams', + ); + } + }); + + it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => { + const first = dataOf>( + await list({ q: '계약', limit: 1 }).expect(200), + ); + expect(first.items.map((item) => item.id)).toEqual(['m-contract']); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 }); + const cursor = first.pageInfo.nextCursor as string; + + const second = dataOf>( + await list({ q: '계약', limit: 1, cursor }).expect(200), + ); + expect(second.items.map((item) => item.id)).toEqual(['m-renewal']); + expect(second.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: null, + }); + + const others: Record[] = [ + { q: '견적', cursor }, + { q: '계약', from: '이도윤', cursor }, + { q: '계약', mailboxId: 'inbox', cursor }, + ]; + for (const params of others) { + const res = await list(params).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('쪽을 넘기는 중에는 처음 방식대로 — 색인 커서는 다시 채우기에 들어가도 색인으로, 준비 중의 새 검색은 엔진 방식(조건도 넘긴다 · 강조 없음)', async () => { + const cursor = dataOf>( + await list({ q: '계약', limit: 1 }).expect(200), + ).pageInfo.nextCursor as string; + await setStatus('building'); + try { + const queriesBefore = listQueries.length; + const next = dataOf>( + await list({ q: '계약', limit: 1, cursor }).expect(200), + ); + expect(next.items.map((item) => item.id)).toEqual(['m-renewal']); + expect(listQueries.length).toBe(queriesBefore); + + const engine = dataOf>( + await list({ + q: ' 계약 ', + from: '이도윤', + hasAttachment: 'true', + }).expect(200), + ); + expect(engine.items).toEqual([]); + expect(listQueries.slice(queriesBefore)).toEqual([ + { + user: ME, + query: { + mailboxId: undefined, + limit: 20, + cursor: undefined, + text: '계약', + conditions: { from: '이도윤', hasAttachment: true }, + }, + }, + ]); + } finally { + await setStatus('ready'); + } + }); + + it('다른 직원의 줄은 섞이지 않는다 — 같은 낱말 · 같은 메일함 id 여도 (두 글자 · 확인 · 한 글자 · 조건만 · 메일함 범위 — 리뷰 L6)', async () => { + const OTHER = 'search.neighbor@e2e.arasystem.test'; + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(OTHER)) + .expect(200); + // 엔진의 메일함 id 는 계정마다 같은 꼴이다(inbox) — 글도 ME 의 것과 같게 + fakeIndexSource.mailboxes.set(OTHER, { + state: 'n1', + emails: DOCUMENTS.map((document) => ({ + ...document, + id: `x-${document.id}`, + })), + }); + try { + const build = app.get(MailSearchBuildService); + expect(await build.build(OTHER, Date.now() + 120_000)).toBe('ready'); + + const searches: Record[] = [ + { q: '계약' }, + { q: '계약서' }, + { q: '층' }, + { subject: '회의' }, + { hasAttachment: 'true' }, + { after: '2026-11-01T00:00:00.000Z' }, + { mailboxId: 'inbox', q: '회의' }, + { mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }, + ]; + for (const params of searches) { + const before = summaryRequests.length; + const ids = await idsOf(params); + expect(ids.length).toBeGreaterThan(0); + // 색인이 찾은 id 그대로(엔진에 요약을 묻기 전) — 다른 직원의 줄이 없다 + const asked = summaryRequests + .slice(before) + .flatMap((request) => [...request.ids]); + expect(asked.length).toBeGreaterThan(0); + expect(asked.filter((id) => id.startsWith('x-'))).toEqual([]); + } + } finally { + fakeIndexSource.mailboxes.delete(OTHER); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query( + `DELETE FROM "${table}" WHERE user_email = $1`, + [OTHER], + ); + } + } + }); + + it('검색 글의 NUL 은 U+FFFD 로 바꿔 찾는다 — DB 오류(500)가 아니다, 엔진 방식도 (리뷰 L2)', async () => { + const NUL = String.fromCharCode(0); + expect(await idsOf({ q: `계약${NUL}` })).toEqual([ + 'm-contract', + 'm-renewal', + ]); + expect(await idsOf({ q: `계${NUL}약`, subject: `회${NUL}의` })).toEqual( + [], + ); + await setStatus('building'); + try { + const res = await list({ q: `계${NUL}약` }).expect(200); + expect(dataOf>(res).items).toEqual([]); + } finally { + await setStatus('ready'); + } + }); + + it('검색어와 조건 칸 넷의 낱말은 합쳐 8개까지 — 넘으면 400 VAL_001 (읽기 화면도 같다 — 리뷰 M2)', async () => { + const words = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix}${index}`).join( + ' ', + ); + await list({ q: words('q', 4), to: words('t', 4) }).expect(200); + const overLimit: Record[] = [ + { q: words('q', 4), to: words('t', 5) }, + { from: words('f', 3), subject: words('s', 3), body: words('b', 3) }, + ]; + for (const params of overLimit) { + const res = await list(params).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + const detail = await request(server) + .get(`/api/messages/${IMAGE_MESSAGE}`) + .query({ q: words('q', 5), body: words('b', 4) }) + .set('Cookie', cookieOf(ME)) + .expect(400); + expect(body(detail).error?.code).toBe('VAL_001'); + }); + + it('판을 되돌리면(서버가 뜰 때) building · 엔진 방식이 되고, 다시 채울 때 첫 묶음이 옛 판 줄을 지운다 — 실제 PostgreSQL (리뷰 M1)', async () => { + const RESET = 'search.reset@e2e.arasystem.test'; + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(RESET)) + .expect(200); + fakeIndexSource.mailboxes.set(RESET, { + state: 'r1', + emails: DOCUMENTS.map((document) => ({ + ...document, + id: `r-${document.id}`, + })), + }); + try { + const build = app.get(MailSearchBuildService); + expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready'); + // 옛 판으로 만든 줄처럼 — 판을 내리고 엔진에 없는 줄도 하나 남긴다 + await dataSource.query( + `UPDATE mail_search_states SET index_format = $2 WHERE user_email = $1`, + [RESET, INDEX_FORMAT - 1], + ); + await dataSource.query( + `UPDATE mail_search_messages SET fold_body = 'old-format' WHERE user_email = $1`, + [RESET], + ); + + // 판이 다르면 되돌리기 전에도 상태는 building (검색은 엔진 방식) + expect( + dataOf( + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(RESET)) + .expect(200), + ), + ).toEqual({ status: 'building', done: 0, total: null }); + + const repository = app.get(MailSearchIndexRepository); + expect( + await repository.resetOutdatedFormats( + `@${TEST_MAIL_SETTINGS.domain}`, + INDEX_FORMAT, + new Date(), + ), + ).toBeGreaterThanOrEqual(1); + const [state]: { + status: string; + index_format: number; + build_started_at: Date | null; + }[] = await dataSource.query( + 'SELECT status, index_format, build_started_at FROM mail_search_states WHERE user_email = $1', + [RESET], + ); + expect(state).toEqual({ + status: 'building', + index_format: INDEX_FORMAT, + build_started_at: null, + }); + + expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready'); + const rows: { email_id: string; fold_body: string }[] = + await dataSource.query( + 'SELECT email_id, fold_body FROM mail_search_messages WHERE user_email = $1 ORDER BY email_id', + [RESET], + ); + expect(rows.map((row) => row.email_id)).toEqual( + DOCUMENTS.map((document) => `r-${document.id}`).sort(), + ); + expect(rows.filter((row) => row.fold_body === 'old-format')).toEqual( + [], + ); + } finally { + fakeIndexSource.mailboxes.delete(RESET); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query( + `DELETE FROM "${table}" WHERE user_email = $1`, + [RESET], + ); + } + } + }); + + it('검색 칸의 검사 — 길이 · 낱말 수 · 기간 형식 · 첨부 값이 틀리면 400 VAL_001 (엔진 · 색인에 묻지 않는다)', async () => { + const before = summaryRequests.length; + const rejected: Record[] = [ + { subject: '가'.repeat(MAX_SEARCH_LENGTH + 1) }, + { + body: Array.from( + { length: MAX_SEARCH_WORDS + 1 }, + (_, index) => `w${index}`, + ).join(' '), + }, + { after: '2026-11-01' }, + { before: '2026-11-01T00:00:00+09:00' }, + { hasAttachment: 'false' }, + ]; + for (const params of rejected) { + const res = await list(params).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(summaryRequests.length).toBe(before); + }); + + describe('읽기 화면의 강조 (GET /api/messages/{id} 의 q · subject · body)', () => { + interface ReadDetail { + subject: string; + htmlBody: string | null; + textBody: string | null; + imagesShown: boolean; + searchHighlight: { + subject: [number, number][]; + attachments: Record; + textBody: [number, number][]; + } | null; + } + const read = (query: Record) => + request(server) + .get(`/api/messages/${IMAGE_MESSAGE}`) + .query(query) + .set('Cookie', cookieOf(ME)); + + it('검색 쿼리가 없으면 searchHighlight 는 null', async () => { + const detail = dataOf(await read({}).expect(200)); + expect(detail.searchHighlight).toBeNull(); + expect(detail.htmlBody).not.toContain(''); + }); + + it('검색어 — 본문의 글에 (정화한 뒤 — 태그 · 속성은 그대로) · textBody 의 위치, 보낸 사람 조건은 강조하지 않는다', async () => { + const detail = dataOf( + await read({ q: '안녕', from: '바깥' }).expect(200), + ); + expect(detail.searchHighlight).toEqual({ + subject: [], + attachments: {}, + textBody: [[0, 2]], + }); + expect(detail.htmlBody).toContain('

안녕하세요

'); + expect(detail.htmlBody).not.toContain('로고'); + }); + + it('한 글자 낱말뿐이면 본문에는 강조하지 않는다 — 제목은 그대로 (2026-11-06 실사용 시험)', async () => { + // '든' 은 제목(그림이 든 메일)에, '안' 은 본문(안녕하세요)에 있다 + const detail = dataOf( + await read({ q: '든 안' }).expect(200), + ); + expect(detail.searchHighlight).toEqual({ + subject: [[4, 5]], + attachments: {}, + textBody: [], + }); + expect(detail.htmlBody).not.toContain(''); + }); + + it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => { + const detail = dataOf( + await read({ images: 'show', subject: '그림', q: '안녕' }).expect( + 200, + ), + ); + expect(detail.imagesShown).toBe(true); + expect(detail.htmlBody).toContain('/api/mail-images/'); + expect(detail.htmlBody).toContain('안녕'); + expect(detail.searchHighlight?.subject).toEqual([[0, 2]]); + }); + + it('검색 칸은 목록과 같은 검사 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다 — 400 VAL_001', async () => { + const rejected: Record[] = [ + { q: '가'.repeat(MAX_SEARCH_LENGTH + 1) }, + { after: '2026-11-01T00:00:00.000Z' }, + { before: '2026-11-30T15:00:00.000Z' }, + { hasAttachment: 'true' }, + { cursor: 'abc' }, + { limit: '20' }, + { mailboxId: 'inbox' }, + ]; + for (const query of rejected) { + const res = await read(query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + }); + }); + + describe('14단계 3차 — 대화 키 · 같은 대화 · 대화로 묶은 목록 (실제 PostgreSQL)', () => { + const ME = 'thread.me@e2e.arasystem.test'; + const OTHER = 'thread.other@e2e.arasystem.test'; + const MINE = { name: '김아라', email: ME }; + const at = (day: number, hour: number) => `2026-11-0${day}T0${hour}:00:00Z`; + /** 견적 대화 — 회신 · 답장 · 전달 말머리 · 겹친 Re · 꼬리표, 휴지통에만 든 답장 하나 */ + const DOCUMENTS = [ + indexDocument({ + id: 't-orig', + subject: '견적 요청', + messageIds: ['orig@e2e.test'], + receivedAt: at(1, 1), + }), + indexDocument({ + id: 't-re', + subject: 'Re: 견적 요청', + messageIds: ['re1@e2e.test'], + inReplyTo: ['orig@e2e.test'], + references: ['orig@e2e.test'], + receivedAt: at(1, 2), + }), + indexDocument({ + id: 't-hoesin', + subject: '회신: 견적 요청', + messageIds: ['re2@e2e.test'], + inReplyTo: ['re1@e2e.test'], + references: ['orig@e2e.test', 're1@e2e.test'], + from: [MINE], + mailboxIds: ['sent'], + receivedAt: at(1, 3), + }), + indexDocument({ + id: 't-fwd', + subject: 'FW: 전달: [외부] 견적 요청', + messageIds: ['fw@e2e.test'], + references: ['orig@e2e.test'], + receivedAt: at(1, 4), + }), + indexDocument({ + id: 't-rere', + subject: 'RE: RE: Re[2]: 견적 요청', + messageIds: ['re3@e2e.test'], + inReplyTo: ['re2@e2e.test'], + keywords: [], + receivedAt: at(1, 5), + }), + indexDocument({ + id: 't-trash', + subject: 'Re: 견적 요청', + messageIds: ['re4@e2e.test'], + inReplyTo: ['re3@e2e.test'], + mailboxIds: [SEARCH_EXCLUDED_MAILBOX], + receivedAt: at(1, 6), + }), + // 답장 관계는 있지만 제목이 다르다 — 따로 + indexDocument({ + id: 't-other', + subject: 'Re: 다른 일', + messageIds: ['other@e2e.test'], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 7), + }), + // Message-ID 가 없다 — 혼자 + indexDocument({ + id: 't-nomid', + subject: 'Re: 견적 요청', + messageIds: [], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 8), + }), + // 원본(lo@)보다 먼저 들어온 답장 둘 — 원본이 오면 합친다 + indexDocument({ + id: 't-late-r1', + subject: '답장: 회의록 공유', + messageIds: ['lr1@e2e.test'], + inReplyTo: ['lo@e2e.test'], + receivedAt: at(2, 1), + }), + indexDocument({ + id: 't-late-r2', + subject: 'Re: 회의록 공유', + messageIds: ['lr2@e2e.test'], + inReplyTo: ['lo@e2e.test'], + receivedAt: at(2, 2), + }), + // 발송 중인 내부 초안 — 견적 대화에 이어지지만 목록 · 다른 메일의 대화에서는 빠진다 + indexDocument({ + id: 't-sending', + subject: 'Re: 견적 요청', + messageIds: ['re5@e2e.test'], + inReplyTo: ['re3@e2e.test'], + mailboxIds: ['drafts'], + keywords: ['$draft', '$ara-sending'], + receivedAt: at(2, 3), + }), + // 주소 없이 이름만 `대표 ` 꼴인 보낸 사람 — 참여자가 그 주소로 보이지 않는다 (리뷰 L3, 공지 메일함) + indexDocument({ + id: 't-notice', + subject: '공지', + messageIds: ['notice@e2e.test'], + from: [{ name: '대표 ', email: null }], + mailboxIds: ['notice'], + receivedAt: at(3, 1), + }), + indexDocument({ + id: 't-notice-re', + subject: 'Re: 공지', + messageIds: ['notice-re@e2e.test'], + inReplyTo: ['notice@e2e.test'], + mailboxIds: ['notice'], + receivedAt: at(3, 2), + }), + ]; + const LATE_ORIGINAL = indexDocument({ + id: 't-late-orig', + subject: '회의록 공유', + messageIds: ['lo@e2e.test'], + receivedAt: at(1, 9), + }); + + const keysOf = async (user: string) => + Object.fromEntries( + ( + await dataSource.query<{ email_id: string; thread_key: string }[]>( + 'SELECT email_id, thread_key FROM mail_search_messages WHERE user_email = $1', + [user], + ) + ).map((row) => [row.email_id, row.thread_key]), + ); + const conversation = ( + id: string, + query: Record = {}, + ) => + request(server) + .get(`/api/messages/${id}/conversation`) + .query(query) + .set('Cookie', cookieOf(ME)); + const conversations = (query: Record) => + request(server) + .get('/api/conversations') + .query(query) + .set('Cookie', cookieOf(ME)); + interface ConversationPage { + items: { id: string }[]; + pageInfo: Page['pageInfo']; + basis: string; + } + interface ConversationRow { + id: string; + latest: { id: string }; + participants: { name: string | null; email: string }[]; + messageCount: number; + messageIds: string[]; + pendingSendIds: string[]; + isRead: boolean; + isFlagged: boolean; + hasAttachment: boolean; + } + interface ConversationListPage { + items: ConversationRow[]; + pageInfo: Page['pageInfo']; + basis: string; + } + const setStatus = (status: 'ready' | 'building') => + dataSource.query( + 'UPDATE mail_search_states SET status = $2 WHERE user_email = $1', + [ME, status], + ); + + beforeAll(async () => { + for (const [user, emails] of [ + [ME, DOCUMENTS], + [ + OTHER, + // 같은 메일을 받은 다른 직원 — 같은 Message-ID · 제목이어도 그 직원의 대화다 + [ + indexDocument({ + id: 'x-orig', + subject: '견적 요청', + messageIds: ['orig@e2e.test'], + receivedAt: at(1, 1), + }), + indexDocument({ + id: 'x-re', + subject: 'Re: 견적 요청', + messageIds: ['re1@e2e.test'], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 2), + }), + ], + ], + ] as const) { + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(user)) + .expect(200); + fakeIndexSource.mailboxes.set(user, { + state: 'c1', + emails: [...emails], + }); + expect( + await app + .get(MailSearchBuildService) + .build(user, Date.now() + 120_000), + ).toBe('ready'); + } + }); + + afterAll(async () => { + for (const user of [ME, OTHER]) { + fakeIndexSource.mailboxes.delete(user); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query( + `DELETE FROM "${table}" WHERE user_email = $1`, + [user], + ); + } + } + }); + + it('대화 키 — 답장 관계 + 말머리(Re · RE · Re[2] · 회신 · 전달 · FW · [꼬리표])를 뗀 제목이 같으면 가장 작은 키 하나, 제목이 다르거나 Message-ID 가 없으면 혼자 · 다른 직원과 섞이지 않는다', async () => { + const keys = await keysOf(ME); + for (const id of [ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + 't-trash', + ]) { + expect(keys[id]).toBe('t-fwd'); + } + expect(keys['t-other']).toBe('t-other'); + expect(keys['t-nomid']).toBe('t-nomid'); + // 원본이 아직 없다 — 두 답장은 따로 + expect(keys['t-late-r1']).toBe('t-late-r1'); + expect(keys['t-late-r2']).toBe('t-late-r2'); + expect(await keysOf(OTHER)).toEqual({ + 'x-orig': 'x-orig', + 'x-re': 'x-orig', + }); + }); + + it('늦게 온 원본은 들어올 때 갈려 있던 답장들을 잇는다 — 같은 메일을 다시 넣어도(겹친 채우기) 합친 키를 풀지 않는다', async () => { + const repository = app.get(MailSearchIndexRepository); + fakeIndexSource.mailboxes.get(ME)?.emails.push(LATE_ORIGINAL); + await repository.write( + ME, + { upserts: [toIndexRow(LATE_ORIGINAL)] }, + new Date(), + ); + let keys = await keysOf(ME); + for (const id of ['t-late-orig', 't-late-r1', 't-late-r2']) { + expect(keys[id]).toBe('t-late-orig'); + } + + await repository.write( + ME, + { upserts: [toIndexRow(DOCUMENTS[1]), toIndexRow(DOCUMENTS[8])] }, + new Date(), + ); + keys = await keysOf(ME); + expect(keys['t-re']).toBe('t-fwd'); + expect(keys['t-late-r1']).toBe('t-late-orig'); + }); + + it('같은 대화 — 오래된 차례 · 보낸편지함 포함 · 휴지통에만 든 메일은 빼되 지금 메일은 넣는다, total · basis index', async () => { + const page = dataOf( + await conversation('t-rere').expect(200), + ); + expect(page.items.map((item) => item.id)).toEqual([ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 5, + }); + expect(page.basis).toBe('index'); + + const fromTrash = dataOf( + await conversation('t-trash').expect(200), + ); + expect(fromTrash.items.map((item) => item.id)).toContain('t-trash'); + expect(fromTrash.pageInfo.total).toBe(6); + + // 발송 중인 내부 초안도 그 메일을 열면 넣는다 (엔진 방식과 같다) + const sending = dataOf( + await conversation('t-sending').expect(200), + ); + expect(sending.items.map((item) => item.id)).toEqual([ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + 't-sending', + ]); + expect(sending.pageInfo.total).toBe(6); + + // 한 통뿐인 대화는 그 메일 하나 + const alone = dataOf( + await conversation('t-other').expect(200), + ); + expect(alone.items.map((item) => item.id)).toEqual(['t-other']); + }); + + it('같은 대화의 쪽 넘기기 — 커서는 그 메일의 대화에서만, limit 처음 값이자 최대 100', async () => { + const first = dataOf( + await conversation('t-rere', { limit: 2 }).expect(200), + ); + expect(first.items.map((item) => item.id)).toEqual(['t-orig', 't-re']); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 }); + const second = dataOf( + await conversation('t-rere', { + limit: 2, + cursor: first.pageInfo.nextCursor as string, + }).expect(200), + ); + expect(second.items.map((item) => item.id)).toEqual([ + 't-hoesin', + 't-fwd', + ]); + expect(second.pageInfo.total).toBeNull(); + + for (const [id, query] of [ + ['t-re', { cursor: first.pageInfo.nextCursor as string }], + ['t-rere', { limit: 101 }], + ['t-rere', { limit: 0 }], + ['t-rere', { q: '견적' }], + ] as const) { + const res = await conversation(id, query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('색인 준비 중 · 색인에 없는 메일은 엔진의 스레드(basis engine) — 엔진에도 없으면 404 RES_001', async () => { + const before = engineConversations.length; + const missing = await conversation(MISSING_MESSAGE).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + await setStatus('building'); + try { + const engine = dataOf( + await conversation('t-re').expect(200), + ); + expect(engine.basis).toBe('engine'); + } finally { + await setStatus('ready'); + } + expect( + engineConversations.slice(before).map((call) => call.emailId), + ).toEqual([MISSING_MESSAGE, 't-re']); + }); + + it('대화로 묶은 목록 — 그 메일함 안 마지막 받은 시각 최신부터, 통 수는 대화 전체(휴지통에만 든 것 빼고) · 그 메일함 안 id · 읽음 · 참여자', async () => { + const page = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect(page.basis).toBe('index'); + expect(page.items.map((row) => row.latest.id)).toEqual([ + 't-late-r2', + 't-nomid', + 't-other', + 't-rere', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 4, + }); + const quote = page.items[3]; + expect(quote).toMatchObject({ + id: 't-fwd', + messageCount: 5, + messageIds: ['t-rere', 't-fwd', 't-re', 't-orig'], + pendingSendIds: [], + isRead: false, + isFlagged: false, + hasAttachment: false, + }); + expect(quote.participants.map((p) => p.email)).toEqual([ + 'doyun.lee@e2e.arasystem.test', + ME, + ]); + expect(page.items[0]).toMatchObject({ + id: 't-late-orig', + messageCount: 3, + messageIds: ['t-late-r2', 't-late-r1', 't-late-orig'], + }); + + // 보낸편지함에서는 그 메일함의 메일만 — 통 수는 대화 전체. 붙잡힌 메일(보내기 취소를 기다리는)은 pendingSendIds 로 (리뷰 L6) + const before = heldSummaryRequests.length; + fakeHeldIds.add('t-hoesin'); + try { + const sent = dataOf( + await conversations({ mailboxId: 'sent' }).expect(200), + ); + expect(sent.items).toHaveLength(1); + expect(sent.items[0]).toMatchObject({ + latest: { id: 't-hoesin' }, + messageIds: ['t-hoesin'], + pendingSendIds: ['t-hoesin'], + messageCount: 5, + }); + } finally { + fakeHeldIds.clear(); + } + // 기다리는 제출은 쪽마다 한 번 — 마지막 메일의 요약과 같은 요청 + expect(heldSummaryRequests.slice(before)).toEqual([ + { user: ME, ids: ['t-hoesin'] }, + ]); + }); + + it('참여자는 첫 보낸 사람 칸에서 — 이름 안의 `<주소>` 는 참여자 주소가 되지 않는다 (리뷰 L3)', async () => { + const keys = await keysOf(ME); + expect(keys['t-notice-re']).toBe('t-notice'); + const senders = await dataSource.query< + { + email_id: string; + from_email: string | null; + from_name: string | null; + }[] + >( + 'SELECT email_id, from_email, from_name FROM mail_search_messages WHERE user_email = $1 AND email_id = ANY($2::text[]) ORDER BY email_id', + [ME, ['t-notice', 't-notice-re']], + ); + expect(senders).toEqual([ + { email_id: 't-notice', from_email: null, from_name: null }, + { + email_id: 't-notice-re', + from_email: 'doyun.lee@e2e.arasystem.test', + from_name: '이도윤', + }, + ]); + const notice = dataOf( + await conversations({ mailboxId: 'notice' }).expect(200), + ); + expect(notice.items).toHaveLength(1); + expect(notice.items[0]).toMatchObject({ + messageCount: 2, + messageIds: ['t-notice-re', 't-notice'], + participants: [ + { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }, + ], + }); + }); + + it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음)는 두 경로 모두 400 VAL_001 — 색인에 없는 메일이어도 404 보다 먼저 (리뷰 L1 · L8)', async () => { + const forged = (payload: Record) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + for (const [id, cursor] of [ + ['t-rere', forged({ m: 'ti' })], + ['t-rere', forged({ v: 1, m: 'ti', t: 'x', k: '../a', s: 'zz' })], + [MISSING_MESSAGE, forged({ m: 'ti' })], + [MISSING_MESSAGE, forged({ v: 1, m: 'ti', t: 1, k: 'a', s: 'zz' })], + ] as const) { + const res = await conversation(id, { cursor }).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + for (const cursor of [ + forged({ m: 'li' }), + forged({ v: 1, m: 'li', t: -1, k: 't-fwd', s: 'zz' }), + ]) { + const res = await conversations({ mailboxId: 'inbox', cursor }).expect( + 400, + ); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('묶은 목록의 쪽 넘기기 · 범위 — 커서는 그 메일함에서만, 없는 메일함은 404 · mailboxId 없음은 400', async () => { + const first = dataOf( + await conversations({ mailboxId: 'inbox', limit: 2 }).expect(200), + ); + expect(first.items.map((row) => row.latest.id)).toEqual([ + 't-late-r2', + 't-nomid', + ]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 }); + const second = dataOf( + await conversations({ + mailboxId: 'inbox', + limit: 2, + cursor: first.pageInfo.nextCursor as string, + }).expect(200), + ); + expect(second.items.map((row) => row.latest.id)).toEqual([ + 't-other', + 't-rere', + ]); + expect(second.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: null, + }); + + const otherMailbox = await conversations({ + mailboxId: 'sent', + cursor: first.pageInfo.nextCursor as string, + }).expect(400); + expect(body(otherMailbox).error?.code).toBe('VAL_001'); + const missing = await conversations({ + mailboxId: MISSING_MAILBOX, + }).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + const invalid: Record[] = [ + {}, + { mailboxId: '../a' }, + { mailboxId: 'inbox', q: '견적' }, + ]; + for (const query of invalid) { + const res = await conversations(query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('색인 준비 중이면 엔진의 스레드(basis engine — collapseThreads) · 다른 직원의 줄은 섞이지 않는다', async () => { + const before = engineConversationLists.length; + await setStatus('building'); + try { + const engine = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect(engine.basis).toBe('engine'); + } finally { + await setStatus('ready'); + } + expect(engineConversationLists.slice(before)).toEqual([ + { + user: ME, + query: { mailboxId: 'inbox', limit: 20, cursor: undefined }, + }, + ]); + const mine = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect( + mine.items + .flatMap((row) => [row.id, ...row.messageIds]) + .filter((id) => id.startsWith('x-')), + ).toEqual([]); + }); + }); + describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => { let systemSettings: SystemSettingsService; /** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */ diff --git a/backend/test/search-text-samples.ts b/backend/test/search-text-samples.ts new file mode 100644 index 0000000..bbbbfc2 --- /dev/null +++ b/backend/test/search-text-samples.ts @@ -0,0 +1,146 @@ +/** + * 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다). + * 서버의 색인 · 찾기(src/shared/utils/search-text.ts · subject-base.ts)와 화면의 미리 확인 · 강조(frontend `shared/utils/search-match.ts`)를 같은 표로 본다. + * + * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). + * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). + * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다). + */ + +/** 접기 — [넣은 글, 접은 글]. 접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 */ +export const FOLD_SAMPLES: readonly (readonly [ + input: string, + folded: string, +])[] = [ + ['계약서', '계약서'], + ['Project PLAN', 'project plan'], + // 전각 ASCII → 반각 (대문자는 소문자로까지) + ['ABC123!', 'abc123!'], + ['RE:회의', 're:회의'], + ['[공지]', '[공지]'], + // 공백류(전각 공백 · 줄바꿈 · 탭 · NBSP · CR)는 띄어쓰기 하나로 1:1 (색인 판 2) — 이어진 공백을 줄이지는 않는다(위치를 지킨다) + ['회의\u3000일정', '회의 일정'], + ['계약\n갱신\t안내\u00a0끝', '계약 갱신 안내 끝'], + ['a\r\n\nb', 'a b'], + // 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로 + ['İstanbul', 'İstanbul'], + ['ÀÉÎÕÜ', 'àéîõü'], + ['ΣΑΣ', 'σασ'], + // 풀어 쓴 한글(NFD)은 NFC 로 모은다 + ['\u1112\u1161\u11ab\u1100\u1173\u11af', '한글'], + // ㈜ 는 그대로 (NFKC 가 아니다 — 길이를 바꾸지 않는다) + ['㈜아라', '㈜아라'], + // BMP 밖 글자 — Deseret 대문자는 소문자로(둘 다 두 단위), 이모지는 그대로 + ['\u{10400}😀', '\u{10428}😀'], + ['', ''], +]; + +/** 글자 두 개 묶음 — [접은 글, 묶음(나온 차례, 겹치지 않게)] */ +export const GRAM_SAMPLES: readonly (readonly [ + fold: string, + grams: readonly string[], +])[] = [ + ['계약서', ['계약', '약서']], + ['회의 일정', ['회의', '일정']], + ['계약', ['계약']], + ['계', []], + ['a b c', []], + ['aaaa', ['aa']], + ['ab\nab\tab', ['ab']], + // 전각 공백 · NBSP 도 공백이다 + ['회의\u3000일정\u00a0안내', ['회의', '일정', '안내']], + // 문장부호는 글자다 — 're:' 의 'e:' 도 묶음 + ['re:회의', ['re', 'e:', ':회', '회의']], + // BMP 밖 글자는 코드 포인트 하나 + ['😀😁', ['😀😁']], + ['', []], +]; + +/** 맞은 곳 — [원문(NFC), 찾는 말들, 맞은 곳들 [시작, 끝) UTF-16] — 겹치거나 맞닿으면 합친다 */ +export const MATCH_SAMPLES: readonly (readonly [ + original: string, + terms: readonly string[], + ranges: readonly (readonly [number, number])[], +])[] = [ + ['계약서를 보냈습니다', ['계약'], [[0, 2]]], + ['Project plan', ['PROJ'], [[0, 4]]], + ['ABC 주문', ['abc'], [[0, 3]]], + [ + '회의 회의록 회의실', + ['회의'], + [ + [0, 2], + [3, 5], + [7, 9], + ], + ], + // 겹치는 자리도 찾아 합친다 + ['aaa', ['aa'], [[0, 3]]], + // 맞닿은 두 말은 하나로 + ['계약서', ['계약', '서'], [[0, 3]]], + [ + '회의 일정 안내', + ['일정', '회의'], + [ + [0, 2], + [3, 5], + ], + ], + // 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 (공백류 → 띄어쓰기 1:1) — 위치는 원문 그대로 + ['계약\n갱신 안내', ['계약 갱신'], [[0, 5]]], + ['계약\u00a0갱신', ['계약 갱신'], [[0, 5]]], + // 구절은 띄어쓰기까지 그대로 + ['회의 일정 안내', ['회의 일정'], [[0, 5]]], + ['회의 일정', ['회의 일정'], []], + [ + '[공지] Re: 회의', + ['공지', 're'], + [ + [1, 3], + [5, 7], + ], + ], + // 빈 말은 건너뛴다 · 없는 말은 맞은 곳 없음 + ['계약', ['', '없음'], []], + // BMP 밖 글자 앞뒤에서도 UTF-16 위치 + ['😀계약', ['계약'], [[2, 4]]], + ['', ['계약'], []], +]; + +/** 말머리 떼기 — [제목, 말머리를 뗀 접기 글] */ +export const SUBJECT_BASE_SAMPLES: readonly (readonly [ + subject: string, + base: string, +])[] = [ + ['회의 일정 안내', '회의 일정 안내'], + ['Re: 회의 일정 안내', '회의 일정 안내'], + ['RE: Re: FW: 회의 일정 안내', '회의 일정 안내'], + ['Re:회의', '회의'], + ['Fwd: 견적서', '견적서'], + ['AW: WG: Angebot', 'angebot'], + // 한국어 Outlook — 엔진은 떼지 않는다 + ['회신: 회의 일정 안내', '회의 일정 안내'], + ['답장: 회신: 전달: 회의', '회의'], + ['회신 : 회의', '회의'], + // 수 · 전각 쌍점 + ['Re[2]: 회의', '회의'], + ['RE(3): 회의', '회의'], + ['Re:회의', '회의'], + ['RE: 회의', '회의'], + // 대괄호 꼬리표 (여러 겹 · 말머리와 섞여도) + ['[공지] 회의 일정', '회의 일정'], + ['Re: [공지] [중요] 회의', '회의'], + ['[외부] RE: 견적', '견적'], + // 공백은 하나로 · 대소문자 없이 + [' Re: 회의 일정 ', '회의 일정'], + ['Project PLAN', 'project plan'], + // 쌍점이 없으면 말머리가 아니다 + ['Re 회의', 're 회의'], + ['Reply to 회의', 'reply to 회의'], + ['Regarding: 회의', 'regarding: 회의'], + ['회신드립니다', '회신드립니다'], + // 다 떼면 빈 글 + ['Re:', ''], + ['[공지]', ''], + ['', ''], +]; diff --git a/docker-compose.yml b/docker-compose.yml index 209a2ea..38871d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -234,6 +234,10 @@ services: # 판 · digest 까지 고정한다(루트 env 의 DB_IMAGE — docker 스킬 "이미지 버전 고정"). 메이저(15)는 데이터 형식이라 그대로 둔다 — # 올리려면 pg_dump → 새 메이저에 복원(mail/README.md "백업"의 복원 차례). 아래 watchtower 표시는 13단계에 뺐다(판을 사람이 올린다) image: ${DB_IMAGE} + # 공유 버퍼(shared_buffers) 256MB — 기본 128MB 의 두 배 · 메모리 한도(아래 deploy — 1g)의 1/4. 14단계 메일 검색 색인 때문이다(아래 deploy 의 "메모리"). + # 공식 이미지의 꼴이다 — entrypoint(docker-entrypoint.sh)가 처음 기동의 initdb 를 하고 이 인자를 postgres 에 그대로 넘긴다. + # 명령줄 값은 데이터 폴더의 postgresql.conf · ALTER SYSTEM 보다 이긴다 — 값은 이 한 곳에 둔다(개발도 같다 — docker-compose.dev.yml 은 덮어쓰지 않는다) + command: ["postgres", "-c", "shared_buffers=256MB"] expose: - "5432" environment: @@ -252,11 +256,15 @@ services: timeout: 5s retries: 5 start_period: 10s + # 메모리 — 1g (14단계에 512m 에서 올렸다 — 2026-11-05 사용자 결정). 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있을 때만 1초 안이다 — + # 2026-11-05 개발(1만 통 · 색인 121MB)에서 캐시가 비면 3~8배 느렸다. 512m 로도 모자라지는 않았다(채우기 0.16GB 안팎 · 찾기 시험 최대 236MiB) — 늘린 몫은 색인을 메모리에 두는 자리다. + # 공유 버퍼(위 command) 밖은 연결마다의 작업 메모리와 표 · 색인의 페이지 캐시다 — 페이지 캐시도 이 한도에 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다). + # 8GB 서버의 합은 mail/README.md "메모리 예산". 값을 바꾸면 다음 up 이 db 를 다시 만든다 — 수 초 멈추고 backend 는 다시 붙는다(같은 절의 차례) deploy: resources: limits: cpus: '0.5' - memory: 512m + memory: 1g # ---------------------------------------- # 4. Redis — 내부 네트워크 전용 · 기본 비활성 diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 67430ec..31be2fd 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -297,7 +297,8 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 - **알림**(오른쪽 아래 · 되돌리기가 있으면 10초): "메일 N통을 보낸편지함으로 옮겼습니다." [되돌리기] · "메일 N통을 휴지통으로 옮겼습니다." [되돌리기] · "메일 N통을 영구 삭제했습니다." · 일부만 처리했으면 뒤에 "1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (메일함 이름의 받침에 맞춰 '으로/로'). 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않는다. -- **영구 삭제 확인**: 휴지통에서만. 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · + [되돌리기] 뒤 알림은 한 메일함으로 되돌렸으면 "메일 N통을 받은편지함으로 되돌렸습니다.", 여러 곳이면 "메일 N통을 되돌렸습니다."(2026-11-07 — 잇달아 되돌린 알림을 가를 수 있게). +- **영구 삭제 확인**: 휴지통에서만. 제목 "메일 N통을 영구 삭제할까요?"(대화로 묶은 휴지통에서 줄을 골랐으면 "휴지통에 있는 메일 N통을 영구 삭제할까요?" — 14단계) · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다. - 메일함을 바꾸거나 검색하면 선택을 푼다. @@ -539,10 +540,10 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **보낸 뒤 알림**(오른쪽 아래): "메일을 보냈습니다." + [보내기 취소] + 아래 남은 시간 막대(설정한 초 — 마우스를 올려도 멈추지 않는다) → 시간이 지나면 저절로 닫힌다. 일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. 주소 (까닭)" + [보내기 취소](주소를 고쳐 다시 보낼 수 있게). [보내기 취소] → "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 그 메일로 쓰기 화면(받는 사람 · 제목 · 본문 · 첨부 그대로). 늦게 누르면 "이미 보낸 메일이라 취소할 수 없습니다." - 예약 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다. + 예약 → "10월 30일 (금) 오전 9:00에 보냅니다." + [예약됨 보기]. 로그아웃 · 다른 계정 로그인 때 알림은 닫는다 — 메일은 그대로 보내진다. - **「예약됨」**: 메일함 패널의 보낸편지함 아래(예약한 메일이 있을 때만, 시계 아이콘, 오른쪽에 통 수). 목록은 받는 사람 · 제목 · 보낼 시각(청록 글 — "10월 30일 (금) 오전 9:00") 차례, 고르는 칸 · 여러 통 처리는 없다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. -- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 — +- **예약한 메일을 열면**: 머리 아래 청록 안내 줄 "10월 30일 (금) 오전 9:00에 보낼 메일입니다." + [고치기](예약을 취소하고 쓰기 화면으로) [예약 취소](보내지 않고 임시보관함으로 — 알림 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]). 답장 · 전달 · 스팸 신고 단추는 없다. [삭제]는 예약을 취소한 뒤 휴지통으로(확인 창). - **보낸편지함**: 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00"(청록) — 보내기 취소를 기다리는 동안은 "보내는 중". @@ -571,7 +572,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 쓰기 화면 — 첨부 칸 (`ComposeLargeFiles`) - **링크로 바뀌는 때**: 파일 하나가 20MB 를 넘을 때, 메일에 담은 파일의 합계를 25MB 넘게 만들 때(고른 차례대로 그 파일부터), 메일에 담지 못하는 형식(mp4 · mov · dwg · dxf · psd · ai · 7z)일 때 저절로 링크로 보낸다. - 묻는 창은 없다 — 합계 때문에 바뀌면 칸 아래 ⓘ 한 줄 "‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB 를 넘어 링크로 보냅니다."(role=status). 받는 사람이 사내뿐이어도 같은 규칙이다. + 묻는 창은 없다 — 합계 때문에 바뀌면 칸 아래 ⓘ 한 줄 "‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB를 넘어 링크로 보냅니다."(role=status). 받는 사람이 사내뿐이어도 같은 규칙이다. 메일에 담은 파일도 줄의 링크 단추(40 · 「링크로 보내기」 — 마우스를 올리면 같은 글)로 바꿀 수 있다 — **이 쓰기 화면에서 고른 파일에만** 있다(전달 원본 · 저장본에서 이어 온 첨부에는 없다 — 처음 판). 링크 → 메일에 담기 단추는 없다(빼고 다시 고르면 한도 안에서 담긴다 — 아래 "처음 판에서 미룬 것"). - **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다". @@ -590,7 +591,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". | 위험 | "위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요."(경고 삼각형) | 빨간 줄 · ✕ | - **올리기 전 검사**(지금 첨부 사전 검사와 같은 결 — 넘으면 올리지 않고 빨간 줄, 그 파일만 빠지고 보내기는 막지 않는다): - "파일 하나는 2GB 까지 보낼 수 있습니다." · "링크로 보내는 파일은 메일 한 통에 10개까지입니다." · + "파일 하나는 2GB까지 보낼 수 있습니다." · "링크로 보내는 파일은 메일 한 통에 10개까지입니다." · "대용량 첨부는 10GB까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" · "지금은 서버에 공간이 모자라 올릴 수 없습니다. 잠시 후 다시 시도해 주세요." · 형식은 `FILE_001` 사전 문구. 링크로 보낼 수 있는 형식 = 일반 첨부 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z. 실행 파일(exe · msi · bat · cmd · js · vbs · scr 등)과 디스크 이미지(iso · img · vhd)는 막는다. 파일 고르기 창은 두 목록을 합친 형식만. @@ -651,7 +652,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". "한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다."(날짜만 굵게) → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 결과 이상 없음" · [내려받기](채운 단추 44)) → "내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다." → 청록 상자 "이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요." → - "받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요."(운영 설정 — 처음 값 postmaster@메일 도메인) → 영어 한 줄. + "받을 이유가 없는 파일이면 postmaster@arasystem.it.kr(으)로 알려 주세요."(조사는 주소의 끝 글자로 고르고 영문이면 "(으)로" — 주소는 운영 설정이라 바뀔 수 있다, 2026-11-07)(운영 설정 — 처음 값 postmaster@메일 도메인) → 영어 한 줄. - 메일 제목 · 본문 · 받는 사람, 내려받은 수 · 남은 수는 보이지 않는다. [내려받기]는 형식과 상관없이 늘 저장이고, 누르기 직전에 상태를 한 번 더 묻는다. noindex · no-referrer. - **상태**(모든 상태에서 머리 · 영어 한 줄은 그대로. 신고 주소 줄은 링크 정보를 받은 상태에서만 — 없는 링크 · 요청이 너무 많음 · 오류 응답에는 그 값이 없다): - 검사 중 — 모두 검사 중이면 제목 "보안 검사 중입니다" / "파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요." + [다시 확인]. 일부면 그 줄만 "보안 검사 중" + [내려받기] `aria-disabled`. @@ -685,7 +686,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 최근에 보낸 차례 · 20개씩 [더 보기]. - **[링크 복사]는 두지 않는다** — 서버는 링크를 해시로만 알아 원래 주소를 모른다. 주소는 보낸 메일의 읽기 화면(보낸 사람 자신)에서 복사한다. - [링크 끊기] → 읽기 화면과 같은 확인 창 · 같은 알림(창 글에 메일 제목). 끊는 동안 그 덩이의 [링크 끊기]를 막고, 끝나면 초점을 덩이 머리로. -- **빈 상태**: 사용량 "0 / 10 GB" + "링크로 보낸 파일이 없습니다" / "메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다." +- **빈 상태**: 사용량 "0 / 10 GB" + "링크로 보낸 파일이 없습니다" / "메일 쓰기에서 20MB가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다." 보내지 않은 파일이 있으면 그 묶음은 위에 그대로 있다. 목록을 받지 못하면 지금의 목록 오류와 같은 모양. - **위험이 나오면** 빨간 알림 "올린 대용량 파일 ‘견적_요청서.zip’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기] — 웹메일을 열어 둔 동안 실시간 알림(`large-file`)으로 바로 띄운다. 그때 열려 있지 않았으면 알림은 없고 목록 · 첨부 칸의 상태(위험)로만 안다 — @@ -1034,7 +1035,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다. 까닭은 엔진 실측(2026-11-03)이다 — 엔진 스레드는 `회신:` · `답장:` · `전달:` 을 떼지 않아 한국어 Outlook 의 답장이 따로 떨어지고, 엔진 검색은 어절이 통째로 같을 때만 맞는다. 데이터 · API 규칙(색인 · 대화 · 조건)은 구현 때 계약 2절에 더한다. 구현 · 배포는 전환 뒤 안정화(1~2주) 다음이다. -새 토큰은 없다 — 조건 칸 40 은 필터 조건 칸의 밀도 예외(`--size-filter-control`), [대화로 묶기] 36 은 안내 줄 단추의 밀도 예외(`--size-notice-action`)를 다시 쓴다. +새 크기 토큰은 없다 — 조건 칸 40 은 필터 조건 칸의 밀도 예외(`--size-filter-control`), [대화로 묶기] 36 은 안내 줄 단추의 밀도 예외(`--size-notice-action`)를 다시 쓴다(구현 때 [조건] 32 도 — 아래 "14단계 구현하며 정한 보완"). 강조 노랑만 색 토큰(`--color-search-mark`)을 하나 둔다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다. ### 검색 · 조건 검색 · 강조 (`Search14`) @@ -1050,8 +1051,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **결과 줄**(높이 64, 세 줄): 보낸 사람 · 시각 / 제목(+ 첨부 표시) / 미리 보기 한 줄(맞은 말 둘레). 제목 · 미리 보기의 맞은 말은 ``(노랑 `#FCE7A4` · 글자색은 그대로). - **읽기 화면 강조**: 제목 · 본문 · 첨부 이름의 맞은 말. 본문은 서버가 정화한 HTML 의 글 조각에만 `` 를 넣는다(찾기와 같은 정규화 · 부분 일치). 맞은 곳 사이 이동(다음 · 이전)은 이번에 넣지 않는다. - **검색 준비 중**: 지난 메일의 색인을 만드는 동안 그 직원의 검색은 지금 방식(엔진) 그대로이고, 결과 머리 아래 안내 줄(옅은 청록 바탕 · role=status) - "지난 메일을 검색할 수 있게 준비하고 있습니다. 준비가 끝날 때까지는 낱말이 통째로 같은 메일만 찾습니다." -- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내("낱말 단위로 찾습니다 …")는 색인이 준비된 뒤에는 뺀다. + "지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다."(2026-11-07 사용자 관점 점검 — 색인은 밤에만 채워, 낮 내내 같은 글이면 멈춘 줄 안다) +- **결과 없음**: "‘계약’에 맞는 메일이 없습니다" / "다른 말로 찾거나 조건을 줄여 보세요." + [전체 메일함에서 찾기](그 메일함에서 찾을 때만). 지금의 한계 안내는 색인이 준비된 뒤에는 뺀다 — 준비 중에는 위 안내 줄과 겹치지 않게 "준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다."(2026-11-07). - 검색 결과는 대화로 묶지 않는다(한 통씩). ### 읽기 화면 — 같은 대화 (`Thread14`) @@ -1073,6 +1074,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 처음 판에서 미룬 것 본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석). +같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다. ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) @@ -1374,3 +1376,99 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - **설정 › 대용량 첨부**: 보낸 링크 머리의 메일 제목이 없으면(지웠거나 읽지 못함 — `subject: null`) 글자만, 빈 제목은 메일이 있는 것이라 링크다. [지우기]가 `FILE_003`(그사이 보내져 링크에 실림)이면 목록 · 사용량을 다시 받고 사전 문구로 알린다. - **내려받기 화면**: 보낸 사람 이름이 비어 있으면 주소를 쓴다. 불러오는 동안은 `role="status"` "불러오는 중"(자리 막대는 `aria-hidden`). + +### 14단계 구현하며 정한 보완 — 검색 (2026-11-06 — 캔버스에는 아직 없다) + +주소 · 이어 찾기의 값은 계약 2절 "검색 — 14단계"의 클라이언트 줄이 같은 내용이다. + +- **크기**: [조건] 단추는 높이 32 — 목록 표 별 · 보기 전환의 밀도 예외(`--size-compact-control`)를 다시 쓴다. 패널의 칸 · 단추 40(`--size-filter-control`), 조건 줄 40 · 줄 안 [조건 지우기] 36(`--size-notice-action`). 새 크기 토큰은 없다. +- **[조건] 패널** + - 단추의 title "조건으로 찾기", 조건이 걸려 있으면 화면 낭독에 ", 조건이 걸려 있음"을 붙인다. + - 칸 안내는 "이름이나 메일 주소"(보낸 사람 · 받는 사람) · "제목에 든 말" · "내용에 든 말"(칸 이름 "내용"과 같게 — 2026-11-07)이고, 날짜 칸은 "시작 날짜" · "끝 날짜"다. + - 날짜가 거꾸로면 알림 대신 칸 아래 "끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.", 고를 수 있는 날짜 밖이면 "날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요."(날짜 고르기는 이미 막고, 해를 손으로 칠 때만 보인다). + - 패널의 [조건 지우기]는 패널 안의 칸만 비운다 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간(전체 기간으로) · 날짜 · 첨부 체크와 칸 아래 알림까지(2026-11-07 사용자 확정 — 보드와 다르다). + 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다. 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다. + 비운 채 [찾기]를 누르면 검색어가 있으면 조건만 빼고 다시 찾고, 검색어도 없으면(조건만 걸린 검색) 검색을 닫는다. 걸린 조건을 바로 빼는 것은 결과의 조건 줄 [조건 지우기]다(조건만 걸린 검색이면 검색을 닫는다). + - 패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다. +- **기간**: "최근 N"은 고른 순간 날짜로 바꾼다 — 그만큼 앞의 같은 날부터 오늘까지(1주는 7일 전, 달은 말일을 넘지 않는다). +- **조건 줄 글**: "조건: " 뒤에 쉼표로 잇는다 — "{이름} 님이 보낸 메일" · "{이름} 님이 받은 메일" · "제목에 ‘{말}’이/가 든 메일" · "내용에 ‘{말}’이/가 든 메일" · "최근 1달" 또는 "2026. 10. 1.부터 11. 5.까지"(같은 해면 끝 날짜의 해를 뺀다) · "…부터" · "…까지" · "첨부 있음". 시작과 끝이 같은 날이면 "2026. 11. 6. 하루", 보낸 사람 · 받는 사람 값이 내 이름이나 주소와 통째로 같으면(앞뒤 공백 · 대소문자 무시) "내가 보낸 메일" · "내가 받은 메일"(2026-11-07). +- **결과 줄**: 분할 보기는 보드대로 세 줄 64 다. **목록 보기(표)는 행 38 그대로 두고 제목만 강조한다**(미리 보기 줄 없음). + 결과 줄에도 별 · 고르기 칸을 남겼다 — 3단계부터 검색 결과에서도 여러 통을 고르고 처리한다(보드의 결과 줄에는 없다). +- **글** + - 조건만으로 찾았을 때 머리 글은 "N통" · "찾는 중", 결과 없음은 "조건에 맞는 메일이 없습니다" / "조건을 바꾸거나 줄여 보세요.". + 결과 없음의 도움말은 검색어 + 조건이면 "다른 말로 찾거나 조건을 줄여 보세요.", 검색어만(걸린 조건 없음)이면 "다른 말로 찾아 보세요."(2026-11-07 — 조건이 없는데 "조건을 줄여"라고 했다). + - 옛 한계 안내("낱말 단위로 찾습니다 …")는 색인을 채우는 동안(`building`)만 결과 없음 아래에 더한다. + - 한 글자 안내: "한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요." — 색인이 준비됐고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만. + - 준비 상태를 받지 못하면(요청 실패) 안내 줄 · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. +- **[더 찾기]**: 한 번에 다 찾지 못하면 "아직 맞는 메일을 찾지 못했습니다" / "메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다." + [더 찾기](찾는 동안 "찾는 중…"). + 하나도 못 찾았으면 "찾는 중"을 이어 보이고, 하나라도 찾으면 먼저 보이면서 뒤에서 잇는다. 목록 보기의 [다음]은 이어 받는 동안 기다렸다가 넘기고, 그동안 꺼짐 · `aria-busy` 다. +- **낱말이 너무 많은 주소**(손으로 고친 주소): "찾을 말이 너무 많습니다" / "검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요." — [전체 메일함에서 찾기] · 알림 없이, 조건 줄의 [조건 지우기]는 남긴다. +- **강조 색**: 노랑 `#FCE7A4` 는 색 토큰 `--color-search-mark` 하나로 두고, 본문 틀 안 CSS 는 같은 값을 직접 적는다(짝). 보낸 사람이 본문에 쓴 `` 도 같은 노랑으로 보인다(드물어 가르지 않는다). + 미리 보기 줄(회색 `--color-text-muted`)의 맞은 말만 글자를 `--color-text-secondary`(#4A5363)로 진하게 한다 — 회색 그대로면 노랑 위 대비가 4.05:1 로 4.5:1 에 못 미친다(2026-11-07 사용자 확정 — 보드와 다르다, 앱도 같다). 제목 · 읽기 화면 · 본문 틀의 강조는 진한 글 위라 글자색 그대로다. +- 긴 안내 글(결과 없음 · [더 찾기] · 안내 줄)은 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all`). + +### 14단계 구현하며 정한 보완 — 같은 대화 (2026-11-06 — 캔버스에는 아직 없다) + +받기 · 다시 받기의 값은 계약 2절 "대화 — 14단계"의 클라이언트 줄이 같은 내용이다. + +- **크기**: 줄 48 · 머리글자 28, 펼친 줄의 [답장] · [이 메일만 열기] 36(안내 줄 단추의 밀도 예외 `--size-notice-action` 재사용), [더 보기] · [다시 시도] 44. 새 크기 토큰은 없다. +- **보드와 다르게 한 것** + - 지금 보는 메일 줄의 바탕은 가장 가까운 기존 토큰 `--color-brand-tint`(#F3F8F8 — 보드 #F2F8F8, 시각 글자 대비 4.6:1). + - 칸 제목은 h3 다 — 읽기 화면 제목이 h2 · 첨부 칸이 h3 이라 같은 층으로(보드는 제목을 h1 로 써서 h2 였다). + - 펼친 본문은 읽기 화면과 같은 본문 틀(14px — 보드 13.5px)이다. + - 펼친 줄에는 외부 이미지 안내 · 첨부 목록을 두지 않는다 — 본문을 줄였거나 HTML 을 보이지 못하면 읽기 화면과 같은 노란 안내 줄만 두고, 나머지는 [이 메일만 열기]로 연 읽기 화면에서 본다. + 다만 첨부가 있으면 본문 아래 · [답장] 위에 클립과 "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다."(12.5 회색 — 수는 읽기 화면과 같은 셈: 본문 그림 빼고 대용량 첨부 더해)를 둔다(2026-11-07 — 본문은 첨부했다는데 표시가 없었다). +- **보드에 없어서 정한 것** + - 칸 머리의 통 수는 다 셀 수 있을 때만 "같은 대화 N통"이고, 아직 다 받지 못해 수를 모르면 "같은 대화"다. + - 남은 메일이 있으면 상자 아래 [더 보기](받는 동안 "불러오는 중…" · `aria-busy`). + - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] + 문의 코드. 이 경로가 없는 옛 서버(404)면 칸이 없다. + - 시각은 "오늘 오전 9:30" · "어제 …" · "10월 29일 (목) 오전 9:30" · 다른 해는 "2025. 10. 29. (수) …" 꼴이다. + - 내가 보낸 메일 · 임시 저장본은 이름 자리에 "나" + 회색 메일함 이름("보낸편지함" · "임시보관함"), 다른 메일함에 든 내 메일은 "나"만. 지금 보는 메일 줄에는 회색 글을 두지 않는다(보드 그대로). + - 미리 보기가 비면 제목을 보인다. 우리 웹 · 앱의 원문 인용 머리 "> 원본 메일"(끝에서 잘린 "> 원…" 포함)부터 뒤는 잘라 내고 남는 글이 없으면 제목이다 — 다른 메일 프로그램의 인용 꼴은 그대로(2026-11-07). + - 줄 차례는 서버 차례(받은 시각 — 내 메일은 저장한 시각) 그대로이고, 보내기를 기다리는 메일(보내기 취소 · 예약)만 받은 줄의 맨 끝에 보낼 시각 차례로 둔다(2026-11-07 — 예약 줄이 시각 차례를 거슬렀다). + - 임시 저장본 줄은 [답장]이 없고 [이 메일만 열기]가 쓰기 화면으로 연다. 예약한 메일은 [답장]이 없다(보내기 취소를 기다리는 메일은 읽기 화면처럼 있다). + - 붙잡힌 메일의 시각 자리는 목록과 같은 보내기 취소 · 예약 표시(청록 굵게)다. +- **펼치기**: 안 읽은 메일을 펼치면 읽음이 된다(읽기 화면에서 연 것과 같다). 접힌 줄의 받아 둔 본문은 최근 10통까지만 기억한다. 펼친 본문 안에서 Ctrl+P 는 그 펼친 메일의 인쇄 화면을 연다. +- **안 읽은 줄**(2026-11-07 사용자 확정 — 보드와 다르다): 목록 줄과 같은 굵기 · 색이다 — 이름 650, 미리 보기 · 시각 600(붙잡힌 메일의 시각은 청록 그대로). 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ". 임시 저장본은 목록처럼 읽음 여부를 보이지 않는다. + 이 탭에서 바꾼 읽음(줄을 펼침 · 읽기 화면에서 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 메일함 ⋯ › 모두 읽음)은 줄에 바로 보이고, 바꾸지 못해 되돌리면 줄도 되돌린다. 받는 동안 바꾼 것과 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다. + 다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다(그 줄을 펼치면 받은 본문대로 바로 풀린다). 읽은 줄의 이름(600)과 안 읽은 줄(650)의 차이는 작아 눈에 띄는 차이는 미리 보기 · 시각이다 — 읽은 줄 이름을 낮추는 것은 보드를 바꾸는 일이라 두었다. +- **[이 메일만 열기]**: 분할 보기는 방문 기록을 쌓고(목록 줄을 누른 것과 같다), 목록 보기는 바꿔 끼운다(이전 · 다음과 같다 — [목록으로]가 기록을 쌓지 않게). + 지금 목록에 든 메일은 그 목록 안에서, 검색 결과 · 예약됨은 받아 둔 줄에 있을 때만 그 목록 안에서, 그 밖은 그 메일이 든 메일함에서 연다. +- **초점**: 누른 단추가 사라지면(칸의 [다시 시도]가 성공 · [더 보기]로 끝까지 받음 · 다시 받아 줄이 빠짐) 그 줄의 펼침 단추 → 새 [다시 시도] → 칸 제목 차례로 옮기고, 칸이 통째로 사라지면 본문으로 옮긴다. + [이 메일만 열기] 뒤에는 삭제 뒤 다음 메일로 갈 때처럼 따로 옮기지 않는다(두 길을 함께 고칠 거리). +- **다시 받기**: 새 메일 · 지움 · 옮김으로 메일함 통 수가 바뀔 때와 쓰기 화면에서 돌아올 때만 다시 받는다 — 읽음 표시만 바뀐 소식으로는 받지 않는다(받으면 메일을 열 때마다 대화를 한 번 더 받는다). 칸의 읽음은 위 "안 읽은 줄"처럼 이 탭에서 바꾼 것만 바로 적는다. + +### 14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기 (2026-11-06 — 캔버스에는 아직 없다) + +받기 · 맞추기 · 줄에 하는 일의 규칙은 계약 2절 "대화 — 14단계"의 클라이언트 줄(목록을 대화로 묶어 보기)이 같은 내용이다. + +- **크기 · 모양** + - [대화로 묶기]는 36 — 안내 줄 단추의 밀도 예외(`--size-notice-action`)를 다시 쓴다. 새 크기 토큰은 없다. + 분할 보기는 아이콘만(36×36 — 휴지통 [비우기]와 같은 결, 이름은 title · 화면 낭독), 목록 보기는 이름까지 보인다. + 분할 보기에 이름까지 넣으면(99) 1440 에서 개수 글 자리가 25px 남짓이라 아이콘으로 두고, 설정 설명이 말풍선 모양이라고 알린다(2026-11-07 잼). + - 켠 모양은 옅은 청록 바탕 + 진한 청록 글자이고 테두리는 그대로다(보드의 #9FD3D7 테두리는 새 색 토큰이라 두지 않았다). + - 설정 › 메일 › 보내기 · 읽기의 한 줄은 외부 이미지 아래 가는 선 뒤 — 끔 · 켬 전환(`.segmented--outline` — 보내기 취소와 같은 모양)과 + "답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다." — 줄 이름은 단추와 같은 "대화로 묶기"(2026-11-07). +- **줄** + - 고르기 칸 · 별을 남겼다(보드의 줄에는 없다 — 검색 결과 줄과 같은 판단, 3단계부터 목록에서 여러 통을 고른다). + 메일이 모두 보내기를 기다리는 줄은 고르기 칸 · 별이 꺼짐(aria-disabled) + 까닭이고, 목록 보기에서 Ctrl · Shift + 클릭으로도 고르지 않는다. + - 참여자: 다른 사람은 받은 차례대로, 나는 끝에 "나"(보드 "이도윤, 나" · "인사팀, 김민수, 나"). 보낸편지함 · 임시보관함에서 참여자가 나뿐이면 메일 목록처럼 "받는 사람: …"이다. + 임시보관함의 제목 없는 줄은 흐린 "(제목 없음)"이다(메일 줄과 같다). + - 목록 보기(표)는 고르기 · 별 · 참여자(+ 회색 "N통" — 머리 글은 모든 메일함에서 "참여자", 2026-11-07) · 제목 · 날짜다 — **크기 칸은 뺐다**(대화의 크기는 마지막 메일의 크기가 아니다). + 표 제목은 "{메일함} 대화 목록", 머리 고르기는 "불러온 대화 모두 고르기", 고른 수는 "대화 N개 선택됨"이다(분할 보기의 도구 줄은 "N개 선택됨" — 화면 낭독은 "대화 N개 선택됨", 단추 일곱과 한 줄 53 이 되게, 2026-11-07). + - 목록 머리가 좁으면 개수가 먼저 줄고 메일함 이름은 남는다(개수에 마우스를 올리면 다 보인다). **묶지 않았을 때의 머리 글도 보드 글로 바꿨다**("전체 9 · 안 읽음 2" → "메일 9통, 안 읽은 메일 2통"). + 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 "안 읽은 메일 N통"만, 없으면 "메일 N통" · "대화 N개"를 보이고 전체 글은 title · 화면 낭독에 둔다 — 머리의 칸 사이는 이름↔개수 6 · 머리↔단추 4 · 단추끼리 4(2026-11-07 — 1440 에서 안 읽은 수가 늘 잘렸다). + - 줄에 한 옮기기 · 삭제 · 스팸 신고 · 스팸 아님 알림은 "{메일함}에 있는 메일 N통을 {갈 곳} 옮겼습니다." 꼴이다(줄의 통 수는 대화 전체라 "4통" 줄에서 "메일 2통"이 헷갈렸다 — 2026-11-07). 휴지통의 영구 삭제도 같은 꼴이다 — 확인 창 "휴지통에 있는 메일 N통을 영구 삭제할까요?" · 알림 "휴지통에 있는 메일 N통을 영구 삭제했습니다."(일부만 지웠으면 뒤의 "N통은 그사이 …"는 그대로). 묶지 않은 목록 · 읽기 화면은 그대로다. + - 메일 화면은 탭이 다시 보일 때 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다 — 다른 탭 · PC 에서 바꾼 묶기면 목록을 다시 열고, 보내기 취소 초 · 외부 이미지도 받은 값을 쓴다(받는 동안 단추를 숨기지 않고, 세션이 끝났으면 받지 않는다 — 2026-11-07). + - "새 메일 N통 · 맨 위로"는 맨 앞에 새로 생긴 줄을 1통, 새 메일이 든 기존 줄을 새 메일 수로 센다. +- **줄의 별**: 중요 표시가 하나라도 있으면 모두 풀고, 없으면 모두 표시한다. 보내기를 기다리는 메일만 중요 표시가 남으면 + "보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요." +- **읽기 화면** + - [이전] · [다음]은 대화 사이를 오간다(앞뒤 줄의 마지막 메일). 위치는 "n / 대화 수"이고, 같은 대화 칸의 [이 메일만 열기]로 연 더 오래된 메일도 그 메일이 든 줄을 기준으로 한다. [목록으로]는 그 줄로 초점을 돌려준다. + - [삭제](한 통) 뒤 열 메일(분할 보기 — 목록 보기는 지금처럼 목록으로): 그 줄에서 지운 메일의 다음 칸(더 오래된 메일), 없으면 앞 칸 → + 줄이 비면 다음 줄의 마지막 메일, 없으면 앞 줄의 마지막 메일. 아직 다시 받기 전이라 줄에 남아 있는 옛 마지막 메일이면 남은 메일의 첫 칸이다(메일 목록의 삭제 뒤와 같은 결). +- **첫 진입**: 설정을 받기 전에는 이 브라우저가 그 직원에게서 마지막으로 안 값(처음이면 끔)으로 연다 — 서버 값이 다르면 받는 즉시 바꾼다. + 설정을 받지 못했으면 그 값대로 두고 [대화로 묶기]도 누를 수 있다(받는 동안에는 단추를 숨긴다). 로그아웃해도 그 값은 남고, 다른 직원이 로그인하면 그 직원의 값이다. +- **알려진 한계**: 여러 통인 줄의 마지막 메일만 읽으면 다음 맞추기까지 줄이 굵은 채다. 마지막 메일이 아닌, 보내기를 기다리는 메일은 풀린 뒤에도 다음 맞추기까지 고를 수 없다(계약 2절). + 분할 보기 머리는 [비우기] 단추가 있는 휴지통 · 스팸메일함, 세 자리 이상 안 읽은 수, "받은편지함"보다 긴 메일함 이름, 1120~1220 폭에서는 1440 에서도 개수 글이 잘린다(title 로 다 보인다). + 분할 보기 도구 줄의 "N개 선택됨"은 세 자리부터 두 줄로 넘친다(메일 목록의 "123통 선택됨"도 같다). diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index aad71a7..ca5bcdc 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -264,8 +264,11 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` | | `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` | | `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` | -| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | -| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` | +| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` · 조건 `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`(14단계) — `mailboxId` 와 검색(`q` 또는 조건) 중 하나는 있어야 한다(검색만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색" · "검색 — 14단계") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함(14단계 색인 방식은 첫 쪽만 · 1,000까지 — 넘으면 `null`) | 400 `VAL_001`, `MAIL_001` | +| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") · 검색 쿼리 `q` · `from` · `to` · `subject` · `body`(선택 — 맞은 말 강조, 14단계) | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` | +| `GET /api/messages/{id}/conversation` | `cursor` · `limit`(기본 100, 최대 100) | 200 `{ items: MessageSummary[], pageInfo, basis }` — 그 메일의 같은 대화, 오래된 차례(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`, 503 `MAIL_001` | +| `GET /api/conversations` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) — 검색 쿼리는 받지 않는다 | 200 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함을 대화로 묶은 목록(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`(없는 메일함), 503 `MAIL_001` | +| `GET /api/search-index` | — | 200 `{ status, done, total }` — 그 직원의 검색 색인 준비 상태(아래 "검색 색인", 14단계) | — | | `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) | | `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | | `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | @@ -662,7 +665,7 @@ interface MailState { 영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다. 조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고, 개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다. -- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'), +- **알려진 한계** (엔진 색인 때문 — 14단계부터는 그 직원의 자체 색인을 채우는 동안(`building`)의 검색에만 남는다, 아래 "검색 — 14단계"): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'), 끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다). 화면은 결과가 없을 때 이 한계를 한 줄로 알린다. - 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다. @@ -676,6 +679,172 @@ interface MailState { 범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다). ✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다. +### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 "대화 — 14단계") + +14단계는 엔진 검색 대신 **서버의 자체 색인**(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다. + +- **색인에 드는 것**: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계(Message-ID · In-Reply-To · References) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름 · 본문 글(앞 2만 자 — 잰 뒤 정한다). + 메일 글이 DB 와 매일 백업에 한 벌 더 들어간다(사용자가 받아들였다). 퇴사 마무리는 이 표도 지운다. 언제나 로그인한 직원 주소로만 읽고 쓴다. +- **만드는 때**: 직원이 웹메일에 처음 로그인할 때(비밀번호가 만료된 로그인은 빼고 — 새 비밀번호를 정하면 만든다) 또는 아래 경로를 처음 물을 때 그 직원의 상태 줄을 `building` 으로 만든다. 로그인한 적 없는 계정은 색인하지 않는다. +- **지난 메일 채우기**: 밤에만 한 사람씩(KST 22:00 ~ 02:45 · 05:00 ~ 07:30 — 03:00 · 04:00 · 04:30 엔진 정리 · 03:30 백업을 피한다). 다 채우면 `ready`. +- **새로 온 · 바뀐 · 지운 메일**은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다. + 비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다. +- `GET /api/search-index` → `{ status, done, total }` + - `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색 · 대화는 지금 방식(엔진)이고, 화면은 검색 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`. + - `done`: 채운 메일 수(`total` 을 넘지 않는다) · `total`: 채우기를 시작할 때의 메일 수(시작 전이면 `null`, `ready` 면 `done` 과 같다). + - **조회지만 상태 줄을 만들고 멈춤을 푼다** — 줄이 없던 직원은 이때 `building` 으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등). + - 로그인 필요(401 `AUTH_001`) · 제한된 세션은 403 `AUTH_010`(6절). +- 로그 가림: 검색 조건의 이름 `from` · `to` · `subject` · `body` 도 `q` · `cursor` 처럼 길이만 남긴다(`maskUrlForLog`). 개발 환경도 DB 쿼리 로그(매개변수)를 남기지 않는다 — 메일 글이 DB 에 있다. + +### 검색 — 14단계 (자체 색인 · 조건 검색 · 강조) + +- **방식**: 그 직원의 색인이 `ready` 면 색인으로, 아니면(`building`) 엔진으로 찾는다(위 3단계 절의 규칙 · 한계 그대로 — 조건은 엔진 필터로, 낱말이 통째로 같을 때만). + 방식은 첫 쪽에서 정해지고 커서가 그 방식을 잇는다 — 쪽을 넘기는 중에 준비가 끝나도 그 검색은 처음 방식대로다. 색인 방식은 찾기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안 — 방금 온 메일도 찾아지게). +- **쿼리** (`GET /api/messages` — 3단계의 `mailboxId` · `q` · `cursor` · `limit` 에 더한다) + + | 이름 | 찾는 곳 | 검사 | + | --- | --- | --- | + | `q` | 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 · 첨부 이름 | 3단계와 같다 | + | `from` | 보낸 사람(이름 · 주소) | 넷 모두 `q` 와 같은 검사 — 앞뒤 공백을 지우고 공백뿐이면 없는 것, 1~200자(UTF-16), 낱말 8개, 길이를 먼저 본다 | + | `to` | 받는 사람과 참조 | | + | `subject` | 제목 | | + | `body` | 본문 | | + | `after` · `before` | 받은 시각이 `after` 이상 · `before` 미만 | ISO 8601 UTC(`Z` · 소수 초 3자리까지 · 있는 날짜) | + | `hasAttachment` | 진짜 첨부가 있는 메일만(목록의 `hasAttachment` 와 같은 판정) | `true` 만 받는다 | + + `q` 나 조건 하나라도 있으면 검색이다(검색 칸이 비어도 조건만으로). 메일함 범위는 3단계와 같고(`mailboxId` 가 없으면 휴지통 · 스팸메일함을 뺀 모두), `mailboxId` 도 검색도 없으면 400 `VAL_001`. + **`q` 와 조건 칸 넷(`from` · `to` · `subject` · `body`)을 합친 낱말은 8개까지**다(넘으면 400 `VAL_001` — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻기 때문에, 3단계에서 잰 비용(낱말 8개 · 조건 472개 · 0.16초) 안에 두려고. 화면은 보내기 전에 같은 수를 본다). + 검색 글의 NUL(U+0000)은 색인과 같이 U+FFFD 로 바꿔 찾는다. + 화면은 고른 날짜를 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시로 바꿔 보낸다(부재중과 같은 방식). +- **찾는 규칙 (색인 방식)** + - 부분 일치 — 낱말이 그 칸의 글 어디에 있어도 맞는다('계약' → 계약서 · 계약은 · 계약직, 제목 맨 앞의 [공지] · Re: 도). 영문 대소문자 · 전각 영숫자는 가리지 않는다(NFC). + - 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(3단계와 같다). 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 — 띄어쓰기 두 칸에는 맞지 않는다. + - **한 글자 낱말**: 두 글자 넘는 낱말과 함께면 모두 찾는다. 한 글자 낱말뿐이면 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾고 본문은 찾지 않는다(2026-11-05 사용자 확정 — 본문을 다 훑으면 메일이 많은 직원은 수 초. 화면은 결과 머리에 한 줄 알린다). + - **본문 강조**(목록 미리 보기 · 읽기 화면)는 본문을 찾은 두 글자 이상 낱말만이다. 한 글자 낱말뿐이면 본문에 강조하지 않고(제목 · 첨부 이름은 그대로), 두 글자 이상 낱말과 섞이면 그 낱말만 강조한다 — + 함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다. + - 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계). +- **응답 (목록)** + - 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. + 위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다. + 본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다). + ① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지. + ② 인용 안에서만 맞았고, 본문에서 맞은 말이 모두 검색어(`q`)의 낱말로 제목 · 보낸 사람 · 받는 사람과 참조 · 첨부 이름에서도 맞았으면 인용 밖의 첫 글 — 맞은 곳이 없으면 `ranges` 는 빈 배열, 인용 밖 글이 없으면 ③. + ③ 그 밖에는 줄 앞 `>` 표시(겹친 것 · 사이 공백)를 뺀 인용 조각을 그 인용 안에서만 — `ranges` 는 보이는 글 기준. + 내용 조건(`body`)의 낱말은 ②에 들지 않는다. `>` 없는 인용 꼴("-----Original Message-----" 등)과 인용 머리 줄("…님이 작성:")은 알아보지 않는다(알려진 한계). + - **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(색인 방식) — 한 요청이 확인하는 메일 수에 상한이 있어(3,000 — 1초 안에 답하려고) 그때까지 찾은 것과 이어 찾을 커서를 준다. 화면은 커서가 있으면 이어 묻는다(빈 쪽이 이어지면 몇 번까지만 — 화면 상수). + - `total`: 첫 쪽에서 셀 수 있으면 0~1,000, 1,000 을 넘거나 이 요청에서 다 세지 못했으면 `null`, 다음 쪽은 늘 `null`(엔진 방식은 3단계와 같다). + **화면은 `null` 이면 통 수를 보이지 않는다** — "1,000통 넘게"라고 쓰지 않는다(다 세지 못한 것일 수도 있다). + - 커서는 불투명한 문자열이다. 방식 · 메일함 · `q` · 모든 조건이 지문에 들어가 다른 검색에 쓰면 400 `VAL_001`. 3단계의 옛 커서는 그대로 통한다. +- **읽기 화면** (`GET /api/messages/{id}`) + - `images` 에 더해 `q` · `from` · `to` · `subject` · `body` 를 받는다(검사는 목록과 같다 — `from` · `to` 는 받기만 하고 강조하지 않는다). `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다(400 `VAL_001`) — 화면은 검색 주소의 조건 가운데 이 다섯만 넘긴다. + - 강조할 말이 있으면 `searchHighlight: { subject: Range[], attachments: { [첨부 id]: Range[] }(맞은 첨부만), textBody: Range[] }` 를 싣고, `subject` · 첨부 이름 · `textBody` 를 NFC 로 싣는다. 없으면 `null`. + - `htmlBody` 는 **정화한 뒤** 맞은 글 조각에 속성 없는 `` 를 넣는다 — 태그를 건너는 말 · 속성 · 주석 · `style` 안은 건드리지 않고, 한 통에 1,000개까지. + - 강조할 말: `q` 는 제목 · 본문 · 첨부 이름에, `subject` 는 제목에, `body` 는 본문에. `from` · `to` 는 강조하지 않는다(섞인 검색인지 정하는 데만 쓴다). 본문에는 목록과 같이 본문을 찾은 두 글자 이상 낱말만이다. 화면은 `` 를 노랑 `#FCE7A4` · 글자색 그대로로 그린다(디자인 README 14단계). + - **강조가 든 응답은 보이기만 한다** — 답장 · 전달의 원문 인용 · 인쇄 · 다른 화면의 메일 기억(목록 · 스토어)에는 검색 쿼리 없이 받은 응답을 쓴다(보내는 정화는 `mark` 를 지우지만, 인용 · 인쇄 모양이 검색에 따라 달라지지 않게). + - 한 글자 낱말뿐이라 본문을 찾지 않았다는 표시는 응답에 없다 — 화면은 서버와 같은 낱말 나누기(짝 — backend `shared/utils/search-text.ts` · 표본 표 `backend/test/search-text-samples.ts`)로 알아보고 결과 머리에 한 줄 알린다. + - 보낸 사람이 본문에 직접 쓴 ``(받은 메일 정화가 남긴다)도 같은 노랑으로 보여 검색 강조와 가르지 않는다(드물고 브라우저 기본도 노랑이다 — 알려진 한계). +- **클라이언트 — 화면 주소**(14단계 웹, 3단계 주소에 더한다): 조건도 검색어처럼 화면 주소에 싣는다(앞단 nginx 의 접근 로그는 쿼리를 남기지 않는다). + - 꼴: `/mail/search?q=…&in=<메일함 id | all>&from=…&to=…&subject=…&body=…&since=YYYY-MM-DD&until=YYYY-MM-DD&attachment=1`, 결과에서 연 메일은 `/mail/search/<메일 id>?…`(같은 쿼리). + 빈 칸은 싣지 않는다(검색어가 비면 `q` 도 뺀다 — 조건만으로 찾는다). `in` 이 비면 `all`. + - `q` · `from` · `to` · `subject` · `body`: 앞뒤 공백을 지우고 200자에서 자른다. 같은 이름이 여럿이면 첫 값, 모르는 이름은 무시한다. + - `since` · `until`: 브라우저 시간대의 날짜이고 **양 끝을 포함한다** — API 에는 `after` = `since` 날 0시, `before` = `until` 다음 날 0시(UTC ISO)로 보낸다. "최근 1주" 같은 기간은 고른 순간 날짜로 바꿔 싣는다(7일 전 · 달은 말일을 넘지 않는다). + 있는 날짜이고 1970-01-01 ~ 9999-12-30 이어야 한다(아니면 그 날짜만 버린다). `since` 가 `until` 보다 늦으면 기간을 통째로 버린다. 한쪽만 있어도 된다. + - `attachment`: `1` 일 때만 `hasAttachment=true` 다(다른 값은 무시). + - 검색어와 조건 칸 넷을 합친 낱말이 8개를 넘으면 목록을 묻지 않고 "찾을 말이 너무 많습니다"를 보인다(손으로 고친 주소 — 화면의 [찾기]는 보내기 전에 막는다). 그때 연 메일은 강조 쿼리 없이 받는다. + - 읽기 화면 API 에는 `q` · `from` · `to` · `subject` · `body` 다섯만 넘긴다([이미지 보기]도 같은 쿼리로). +- **클라이언트 — 이어 찾기**: `hasNext` 인데 쪽이 모자라면 한 번에(열 때 · [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]) 요청을 5번까지 보낸다(화면 상수 `MAIL_SEARCH_FILL_MAX_REQUESTS`) — 새로 20통이 차거나 `hasNext` 가 false 면 멈춘다. + 그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다. + 목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게). +- **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다. +- 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`). + +### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기) + +읽기 화면은 그 메일의 같은 대화를 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다(디자인 README 「14단계 — 검색 · 대화 묶기」의 `Thread14` · `ListThreads14`). 검색 결과는 묶지 않는다. + +- **대화의 기준(`basis`)** — 응답마다 싣는다. + - `index`(그 직원의 색인이 `ready`): 답장 관계(Message-ID · In-Reply-To · References)로 이어지고 **말머리를 뗀 제목이 같은** 메일이 한 대화다. + 말머리는 앞의 `Re` · `RE` · `Fw` · `FW` · `Fwd` · `AW` · `WG` · `회신` · `답장` · `전달`(뒤의 `[2]` 같은 수 · 전각 쌍점 `:` 도) + 쌍점과 대괄호 꼬리표(`[공지]` 등)이고 여러 겹을 뗀다(짝 — backend `shared/utils/subject-base.ts` · frontend `shared/utils/search-match.ts`). + Message-ID 가 없는 메일은 혼자다. 원본이 늦게 와도 이어지고, 한 번 이어진 대화는 나뉘지 않는다(가운데 메일을 지워도). 답장 관계가 있어도 제목이 다르면 묶지 않는다(엔진과 같다). + - `engine`(색인을 채우는 동안): 엔진의 스레드(`threadId`). 두 기준의 묶음은 조금 다를 수 있다 — 준비가 끝나면 묶음이 바뀔 수 있다. + - 기준은 첫 쪽에서 정해지고 커서가 잇는다(검색과 같다). 색인 방식은 읽기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안). 첫 쪽에서 색인을 읽지 못하면(DB 장애) 엔진으로 답한다. +- **대화에 드는 메일**: 모든 메일함(보낸편지함 · 임시보관함 포함)의 메일 — 휴지통 · 스팸메일함에만 든 메일은 뺀다. 보내는 중인 내부 초안은 빼고, 붙잡힌 메일(보내기 취소 · 예약 — `pendingSend`)은 넣는다. + +#### `GET /api/messages/{id}/conversation` — 같은 대화 + +- 쿼리 `cursor` · `limit`(1~100, 처음 100). 응답 `{ items: MessageSummary[], pageInfo, basis: 'index' | 'engine' }`. +- **오래된 차례**(받은 시각 — 같으면 id). 그 메일은 휴지통 · 스팸메일함에 있어도 늘 든다. 한 통뿐이면 그 메일 하나다(화면은 칸을 보이지 않는다). +- `total`: `index` 는 첫 쪽에서만(다음 쪽 `null`), `engine` 은 쪽마다(스레드가 1,000통을 넘으면 `null`). +- **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(`engine` — 한 요청이 커서 자리부터 1,000통까지만 훑는다. 검색과 같은 결 — 응답은 늘 커서 자리보다 앞으로 간다). + 화면은 커서가 있으면 이어 묻되, 빈 쪽이 이어지면 몇 번까지만 묻고 [더 보기]로 넘긴다(검색의 이어 찾기와 같은 결 — 휴지통에만 든 메일이 많은 큰 스레드). +- 커서는 그 메일 · 그 기준에서만 통한다 — 다른 메일 · 꾸민 커서는 400 `VAL_001`(그 메일이 색인에 없어도 커서를 먼저 본다). +- 404 `RES_001`: 없는 메일 · 쪽을 넘기는 중에 색인에서 사라진 메일. + 503 `MAIL_001`: 엔진 장애 · `engine` 기준이 한 요청의 시간(15초) 안에 1,000통 이하 스레드를 다 읽지 못하거나 더 큰 스레드의 첫 묶음도 읽지 못함. +- **클라이언트 — 읽기 화면의 같은 대화 칸**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 같은 대화") + - 한 번에 100통씩 받고, 쪽이 모자라면 한 번에 요청을 5번까지 이어 묻는다(화면 상수 `MAIL_CONVERSATION_FILL_MAX_REQUESTS` — 새로 100통이 차거나 `hasNext` 가 false 면 멈춘다). 남으면 [더 보기]. 화면의 시간 한도는 30초다. + - 칸 머리의 통 수: `total` → (다음 쪽이 없으면) 받은 수 → 모르면 수 없이 "같은 대화"(짐작한 수는 보이지 않는다). + - 처음 받기 · 다시 받기 · 펼친 본문은 알림 없이(`silent`) 칸 · 줄 안에 안내와 [다시 시도]를 둔다. 404 · 모양이 틀린 응답이면 칸이 없다(이 경로가 없는 옛 서버). [더 보기]만 인터셉터가 알린다. + - 다시 받는 때: 메일함들의 통 수(`totalCount`)가 바뀌었을 때(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다), 쓰기 화면에서 돌아올 때, 붙잡힌 메일이 보낼 시각 + 2초가 됐을 때. + 받기가 끝난 때부터 10초 안에 온 소식은 모아 한 번 받고, 받는 중에 온 소식은 끝난 뒤 한 번 더 받는다. [더 보기]로 늘린 대화는 저절로 다시 받지 않는다(붙잡힌 메일만 `GET /api/messages/{id}` 로 고친다). + 알려진 한계: 같은 메일함에서 한 통이 들어오며 한 통이 빠지면 통 수가 같아 그 소식으로는 다시 받지 않는다. + - 펼친 줄은 검색 쿼리 없이 `GET /api/messages/{id}` 로 받고, 받은 뒤에도 펼쳐 있으며 안 읽은 메일이면 `PATCH` 로 읽음으로 바꾼다(읽기 화면과 같은 길). 그 메일이 그사이 지워졌으면(404) 대화를 다시 받아 그 줄을 뺀다. + - 줄의 읽음(2026-11-07): 안 읽은 줄을 목록처럼 굵게 그린다. 이 탭이 바꾼 읽음은 줄에 바로 적고, 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 앞세운다(받은 값은 그 바꿈이 엔진에 닿기 전일 수 있다). 다른 탭 · 기기의 읽음은 다음 받기 때 보인다(읽음만 바뀐 소식으로는 다시 받지 않는다). + - 줄 차례: 받은 메일은 응답 차례 그대로, 붙잡힌 메일(`pendingSend` — 보내기 취소 · 예약)은 받은 줄 뒤에 `sendAt` 차례로 둔다. 줄의 미리 보기는 우리 원문 인용 머리 "> 원본 메일"부터 잘라 내고(남는 글이 없으면 제목), 펼친 줄은 첨부 목록 대신 첨부 수만 안내한다(2026-11-07). + +#### `GET /api/conversations` — 대화로 묶어 보기 + +```ts +interface ConversationSummary { + id: string // 대화 id — 불투명(기준마다 다르다) + latest: MessageSummary // 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · pendingSend + participants: Address[] // 대화 전체의 보낸 사람(주소가 있는 사람만) — 받은 차례, 주소마다 한 번, 10명까지 · 이름 100자까지 + messageCount: number // 대화 전체의 메일 수 — 위 "대화에 드는 메일"에 그 메일함의 메일을 더한 수(휴지통 · 스팸메일함을 묶어 볼 때) + messageIds: string[] // 그 메일함 안의 그 대화 메일 — 최신부터 500통까지 + pendingSendIds: string[] // messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약 — pendingSend 가 있는 메일). 없으면 [] + isRead: boolean // 그 메일함 안의 메일을 모두 읽었으면 true + isFlagged: boolean // 그 메일함 안에 중요 표시한 메일이 하나라도 있으면 true + hasAttachment: boolean // 그 메일함 안에 첨부가 있는 메일이 하나라도 있으면 true +} +``` + +- 쿼리 `mailboxId`(필수) · `cursor` · `limit`(1~100, 처음 20). 검색 쿼리(`q` · 조건)는 받지 않는다(400 `VAL_001`). +- 응답 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함 안의 마지막 받은 시각 최신부터. +- `total`: `index` 는 첫 쪽에서 그 메일함의 대화 수(다음 쪽 `null`), `engine` 은 엔진이 센 스레드 수(쪽마다). +- 커서는 그 메일함 · 그 기준에서만 통한다(다른 메일함 · 다른 목록 · 꾸민 커서는 400 `VAL_001`). 없는 메일함은 404 `RES_001`, 엔진 장애(메일함을 읽지 못함 포함)는 503 `MAIL_001`. +- **`engine` 기준의 상한**: 한 요청이 스레드마다 최신 500통 · 모두 2,000통까지 · 15초 안에서 읽는다(몫은 작은 스레드부터 다 주고 남는 것을 큰 스레드끼리 나눈다). + 시간이 끝나도 503 이 아니라 그 쪽의 줄을 읽은 만큼으로 채워 답한다(커서는 보통의 다음 쪽 커서다). + 상한에 걸린 줄(스레드마다 500 · 한 요청 2,000 · 15초 — 500통 이하 스레드도 한 쪽에 큰 스레드가 많으면 걸린다)은 `messageCount` 가 어림이다(읽지 못한 오래된 메일을 모두 센다 — 휴지통 · 스팸메일함에만 든 것도). + `participants` · `messageIds` · `isRead` · `isFlagged` · `hasAttachment` · `pendingSendIds` 는 읽은 최신 메일로만 정한다. +- 고르기 · 읽음 · 중요 · 이동 · 삭제는 그 줄의 `messageIds` 를 지금의 여러 통 API(`PATCH /api/messages` · `POST /api/message-deletions`)로 보낸다. + **`pendingSendIds` 는 빼고 보낸다** — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계 "보내기 취소 · 예약 발송" — 섞여 와도 서버가 `failedIds` · `skippedIds` 로 막는다). + 줄의 메일이 모두 붙잡힌 메일이면 그 줄은 고를 수 없다(목록의 고르기 칸과 같은 결). +- 500통이 넘는 대화는 `messageIds` 가 최신 500통이다 — 그 줄에 한 일은 그만큼에만 하고, 남은 메일은 다음에 다시 맞춘 목록의 같은 줄로 남는다(드문 경우). +- **알려진 한계**: 쪽을 넘기는 사이 늦게 온 원본으로 두 대화가 합쳐지면 줄이 겹치거나 빠질 수 있다 — 처음부터 다시 받으면 맞는다. + 마지막 메일이 그 메일함을 떠났는데 색인이 아직 맞추지 않았으면 그 줄이 한 번 빠진다. +- **클라이언트 — 목록을 대화로 묶어 보기**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기") + - 켜고 끄기: 목록 머리 [대화로 묶기]와 설정 › 메일 › 보내기 · 읽기의 한 줄이 같은 `groupByConversation` 을 `PATCH` 로 저장한다 — 화면을 먼저 바꾸고 끝내 실패하면 되돌린다(칸마다 저장은 한 번에 하나 — 아래 "보내기 · 읽기 설정"). + 메일함 목록만 묶는다 — 검색 결과 · 「예약됨」은 묶지 않는다. + - 첫 진입: 설정을 받기 전 · 받지 못했으면 이 브라우저가 그 직원에게서 마지막으로 안 값(localStorage — 직원마다, 처음이거나 읽지 못하면 끔)으로 연다. 받은 값이 다르면 바로 그 값으로 다시 연다 + (짐작이 틀리면 버릴 첫 쪽 요청이 하나 나간다). 받지 못한 동안에도 [대화로 묶기]를 누를 수 있고, 저장 응답(설정 전체)을 받은 설정으로 삼는다 — 실패하면 누르기 전 짐작으로 돌아간다. + 로그아웃 · 세션 만료는 짐작을 바꾸지 않고, 세션이 끝나면(제한된 세션 포함) 목록을 다시 부르지 않는다. + - 쪽은 20줄 · 받은 커서 그대로. 켜고 끔 · 메일함 바꿈 · 다른 목록으로 가면 앞 목록의 응답을 버리고 그 요청을 끊는다(`AbortSignal` — `engine` 기준은 한 요청이 15초까지). 끊긴 요청은 알리지 않는다. + - 새 메일 확인 · 실시간: 메일 목록 대신 불러온 줄 수만큼(최대 100줄) `silent` 로 처음 쪽부터 다시 받는다 — 요청 수는 메일 목록과 같다. + - 다른 탭 · PC 에서 바꾼 설정: 메일 화면은 탭이 다시 보일 때 설정을 마지막으로 받은 지 60초가 지났으면 `GET /api/mail-settings` 를 `silent` 로 다시 받는다 — 저장하는 중이면 받지 않고, 받는 사이 고른 값이 있으면 받은 값을 버리고, 세션이 끝났으면 받지 않는다. 받은 `groupByConversation` 이 다르면 그 값으로 목록을 다시 연다(2026-11-07). + - 줄에 하는 일: 할 일 메일 = `messageIds` − `pendingSendIds` − (마지막 메일이 붙잡힌 메일이면) 그 메일. 100통씩 나눠 보낸다. + - 화면을 먼저 바꾸는 것은 줄의 메일을 모두 바꿨을 때만 줄 전체(읽음 · 중요 해제)다 — 일부만이면 그대로 두고 다음 맞추기가 서버 값으로 고친다. 중요 해제는 붙잡힌 메일까지 바꿨을 때만이다(붙잡힌 메일의 중요 표시는 남는다). + - 줄의 별: 중요 표시가 하나라도 있으면 모두 풀고, 없으면 할 일 메일을 모두 표시한다. 붙잡힌 메일이 든 줄을 풀면 줄을 미리 풀지 않고 묶은 목록을 한 번 다시 받는다 — 그래도 중요 표시가 남으면 알린다. + - 읽기 화면의 별(한 통)이 실패하면 그 메일이 든 줄을 앞 값으로 되돌린다. + - 옮기기 · 삭제 뒤에는 묶은 목록을 한 번 다시 받는다(남은 줄의 마지막 메일 · 통 수를 맞추려고). 서버가 바꾸지 못한 메일(`failedIds` · `skippedIds`)이 든 줄은 앞 값으로 되돌린 뒤 다시 받는다. 안 읽은 수는 끝난 뒤 메일함 목록으로 맞춘다. + - 목록 머리의 대화 수는 `total` 이다 — `null` 이면 그 부분을 뺀다. + - 알려진 한계: 여러 통인 줄의 마지막 메일만 읽으면 다음 맞추기(실시간이면 곧, 아니면 1분)까지 그 줄이 굵은 채다 — 읽을 때마다 다시 받지 않는다(경로마다 1분 100회). + 마지막 메일이 아닌 붙잡힌 메일은 보낼 시각을 몰라, 풀린 뒤에도 다음 맞추기까지 그 메일만 든 줄을 고를 수 없다. + +#### 설정 + +- `groupByConversation`(아래 "보내기 · 읽기 설정") — 처음 `false`. 서버는 저장해 돌려주기만 하고, 묶은 목록을 부를지는 화면이 정한다. + +새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`). + ### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계 ```ts @@ -1134,7 +1303,7 @@ interface PendingSend { - **로그**: 취소 · 예약은 log(메일 id · 보낼 시각만) — 받는 사람 · 제목은 남기지 않는다. - **클라이언트**: [보내기]를 누르면 지금처럼 쓰기 화면을 닫고 떠난다. 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — `pendingSend.sendAt` 에 닫는다(마우스를 올려도 멈추지 않는다). 일부 거절의 실패 알림에도 [보내기 취소]를 둔다. [보내기 취소] → `DELETE` → 쓰기 화면을 `?draft=` 로 다시 연다(새 키) + 알림 "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.". - `MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’ 에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다. + `MAIL_014` 는 인터셉터 알림. 예약은 알림 "‘시각’에 보냅니다." + [예약됨 보기]. 세션이 바뀌면 알림을 닫는다 — 메일은 그대로 보내진다. ### 외부 이미지 보기 (`images=show` · `/api/mail-images`) — 9단계 @@ -1222,19 +1391,23 @@ interface MailBlockList { interface MailSettings { undoSendSeconds: 0 | 5 | 10 | 20 | 30 // 보내기 취소 — 0 이면 바로 보낸다. 처음 10 externalImages: 'ask' | 'trusted' | 'always' // 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기. 처음 'ask' + groupByConversation: boolean // 목록을 대화로 묶어 보기 — 처음 false (14단계 — 위 "대화 — 14단계") } ``` +- `groupByConversation` 은 서버가 저장해 돌려주기만 한다 — 묶은 목록(`GET /api/conversations`)을 부를지는 화면이 정한다. `PATCH` 는 boolean 만 받는다(`null` · `"true"` · `1` 은 400 `VAL_001`). + - 서버는 발송 때 `undoSendSeconds`, 메일을 읽을 때 `externalImages` 를 본다(직원마다 잠깐 기억해도 된다 — 그 서버에서 바꾸면 바로 맞춘다). 서버마다 60초 기억하고, 읽지 못하면(DB 장애) 처음 값(10초 · `ask`)으로 보고 warn 을 남긴다(구현하며 정함). - 화면(설정 › 메일 › 보내기 · 읽기)은 고르는 대로 `PATCH` 하고 "저장했습니다"를 보인다(저장 단추 없음). 칸마다 저장은 한 번에 하나다 — 끝났을 때 마지막으로 고른 값이 서버의 답과 다르면 그 값을 다시 보낸다(늦게 온 앞 응답 때문에 화면과 서버가 갈리지 않게 — 2026-10-29 리뷰). + 메일 화면은 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(14단계 — 위 "대화 — 14단계"의 묶어 보기 클라이언트 줄). "저장했습니다"는 기다리던 저장이 모두 끝나고 마지막이 성공했을 때 보인다. 끝내 실패하면 서버가 마지막으로 확인해 준 값으로 되돌리고 인터셉터가 알린다. ### 이 단계에서 하지 않는 것 탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로), -여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조, +여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"), 끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만). diff --git a/frontend/src/components/mail/ConversationListItem.vue b/frontend/src/components/mail/ConversationListItem.vue new file mode 100644 index 0000000..8dcdaed --- /dev/null +++ b/frontend/src/components/mail/ConversationListItem.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/frontend/src/components/mail/ConversationMessagePanel.vue b/frontend/src/components/mail/ConversationMessagePanel.vue new file mode 100644 index 0000000..3fce3e9 --- /dev/null +++ b/frontend/src/components/mail/ConversationMessagePanel.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/components/mail/ConversationMessageRow.vue b/frontend/src/components/mail/ConversationMessageRow.vue new file mode 100644 index 0000000..0c16ab9 --- /dev/null +++ b/frontend/src/components/mail/ConversationMessageRow.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/components/mail/ConversationTableRow.vue b/frontend/src/components/mail/ConversationTableRow.vue new file mode 100644 index 0000000..1c45e3c --- /dev/null +++ b/frontend/src/components/mail/ConversationTableRow.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/components/mail/HighlightedText.vue b/frontend/src/components/mail/HighlightedText.vue new file mode 100644 index 0000000..b5618b0 --- /dev/null +++ b/frontend/src/components/mail/HighlightedText.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/components/mail/ListReadNav.spec.ts b/frontend/src/components/mail/ListReadNav.spec.ts index 1359f17..f5f992b 100644 --- a/frontend/src/components/mail/ListReadNav.spec.ts +++ b/frontend/src/components/mail/ListReadNav.spec.ts @@ -158,3 +158,114 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => { expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`) }) }) + +describe('ListReadNav — 14단계 검색의 빈 쪽', () => { + let pinia: Pinia + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + }) + + afterEach(() => { + useToastStore().clear() + restoreApiAdapter() + }) + + it('[다음]은 빈 쪽에 멈추지 않는다 — 다음 메일이 나올 때까지 커서로 이어 묻고 그 메일로 간다', async () => { + const requests = installMockApi(({ params }) => { + if (params.cursor === 'c2') return ok({ items: [], pageInfo: { hasNext: true, nextCursor: 'c3', total: null } }) + return ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } }) + }) + const store = useMailStore() + store.listSource = { kind: 'search', q: '회의', scope: 'all' } + store.messages = [summary('m1'), summary('m2'), summary('m3')] + store.pageInfo = { hasNext: true, nextCursor: 'c2', total: null } + store.listStatus = 'ready' + const query = `q=${encodeURIComponent('회의')}&in=all` + const router = createTestRouter() + await router.push(`/mail/search?${query}`) + await router.push(`/mail/search/m3?${query}`) + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + + // 전체 수를 모르면 "n / 전체" 를 보이지 않는다 + expect(wrapper.find('.nav-position').exists()).toBe(false) + + await wrapper.get('button[aria-label="다음 메일"]').trigger('click') + await flushPromises() + + expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([ + 'c2', + 'c3', + ]) + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`) + }) + + it('뒤에서 결과를 이어 받는 중에 누른 [다음]은 그 일을 기다렸다가 넘어간다 — 기다리는 동안 꺼지고 aria-busy, 새로 묻지 않는다', async () => { + let resolveSecond: (value: ReturnType) => void = () => undefined + const second = new Promise>((done) => { + resolveSecond = done + }) + const requests = installMockApi(({ url, params }) => { + if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 }) + if (!params.cursor) { + return ok({ items: [summary('m1'), summary('m2'), summary('m3')], pageInfo: { hasNext: true, nextCursor: 'c2', total: null } }) + } + return second + }) + const store = useMailStore() + const opening = store.openSearch('회의', 'all') + await flushPromises() + expect(store.isLoadingMore).toBe(true) + const query = `q=${encodeURIComponent('회의')}&in=all` + const router = createTestRouter() + await router.push(`/mail/search?${query}`) + await router.push(`/mail/search/m3?${query}`) + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + const next = wrapper.get('button[aria-label="다음 메일"]') + // 이어 받는 중이면 끝에 있어도 [다음]을 누를 수 있다 + expect(next.attributes('disabled')).toBeUndefined() + + await next.trigger('click') + await flushPromises() + expect(next.attributes('disabled')).toBeDefined() + expect(next.attributes('aria-busy')).toBe('true') + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`) + + resolveSecond(ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } })) + await opening + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`) + expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([ + undefined, + 'c2', + ]) + }) + + it('목록을 보이는 채로 새로 고치는 동안 받아야 할 [다음]은 꺼지고 aria-busy — 끝나면 다시 누를 수 있다', async () => { + const store = useMailStore() + store.currentMailboxId = 'inbox' + store.messages = [summary('m1'), summary('m2'), summary('m3')] + store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 } + store.listStatus = 'ready' + store.isRefreshing = true + const router = createTestRouter() + await router.push('/mail/inbox') + await router.push('/mail/inbox/m3') + await router.isReady() + const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } }) + const next = wrapper.get('button[aria-label="다음 메일"]') + + expect(next.attributes('disabled')).toBeDefined() + expect(next.attributes('aria-busy')).toBe('true') + + store.isRefreshing = false + await flushPromises() + expect(next.attributes('disabled')).toBeUndefined() + expect(next.attributes('aria-busy')).toBe('false') + }) +}) diff --git a/frontend/src/components/mail/ListReadNav.vue b/frontend/src/components/mail/ListReadNav.vue index 4af15da..0ef43bb 100644 --- a/frontend/src/components/mail/ListReadNav.vue +++ b/frontend/src/components/mail/ListReadNav.vue @@ -2,6 +2,8 @@ // 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead) // 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯). // 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다. +// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수". +// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다 import { computed, ref } from 'vue' import { useRouter } from 'vue-router' @@ -32,45 +34,60 @@ const listHref = computed(() => { const source = mailStore.listSource return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href }) -/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */ -const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId)) -/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */ -const total = computed( - () => - mailStore.pageInfo?.total ?? - (mailStore.search ? null : mailStore.currentMailbox?.totalCount) ?? - mailStore.messages.length, -) +/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */ +const index = computed(() => mailStore.rowIndexOfMessage(props.messageId)) +/** + * 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수. + * 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다. + * 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다) + */ +const total = computed(() => { + const known = mailStore.pageInfo?.total ?? null + if (known !== null) return known + if (mailStore.search) return null + if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount + return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length +}) const position = computed(() => - index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`, + index.value === -1 || total.value === null ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`, ) const hasPrev = computed(() => index.value > 0) -/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */ +/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */ const hasNext = computed( () => - index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore), + index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore), +) +/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */ +const nextWaiting = computed( + () => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing, ) async function goPrev() { - const previous = mailStore.messages[index.value - 1] + const previous = mailStore.rowMessageIdAt(index.value - 1) const source = mailStore.listSource - if (previous && source) await showSiblingIn(source, previous.id) + if (previous && source) await showSiblingIn(source, previous) } +/** + * 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(14단계)이면 그 일이 끝나기를 기다리고, + * 그 안에 다음 메일이 없었으면 이번 누름의 차례로 한 번 더 받는다. 기다리는 동안 단추는 꺼지고 aria-busy + */ async function goNext() { - if (loadingNext.value || index.value === -1) return + if (loadingNext.value || nextWaiting.value || index.value === -1) return const current = index.value - if (!mailStore.messages[current + 1] && mailStore.hasMore) { + if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) { loadingNext.value = true try { + const waited = mailStore.isLoadingMore await mailStore.loadMore() + if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore() } finally { loadingNext.value = false } } - const next = mailStore.messages[current + 1] + const next = mailStore.rowMessageIdAt(current + 1) const source = mailStore.listSource - if (next && source) await showSiblingIn(source, next.id) + if (next && source) await showSiblingIn(source, next) } /** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */ @@ -80,7 +97,8 @@ function onBackToList(event: MouseEvent) { event.preventDefault() const source = mailStore.listSource if (!source) return - viewStore.setReturnFocus(props.messageId) + // 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다 + viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId) void backToSourceList(source) } @@ -115,8 +133,8 @@ function onBackToList(event: MouseEvent) { type="button" class="nav-button" aria-label="다음 메일" - :disabled="!hasNext || loadingNext" - :aria-busy="loadingNext" + :disabled="!hasNext || loadingNext || nextWaiting" + :aria-busy="loadingNext || nextWaiting" @click="goNext" > /api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts). // 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다 // 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다 +// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 , 글 본문은 textHighlights 의 위치로 (mail-html.ts) import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { SearchRange } from '@/shared/types/mail' import { buildMailDocument } from '@/shared/utils/mail-html' interface Props { @@ -29,6 +31,8 @@ interface Props { title?: string /** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */ foldLargeFiles?: boolean + /** 글 본문의 검색에서 맞은 곳 — 서버의 searchHighlight.textBody (14단계 — 글 본문을 보일 때만 쓴다) */ + textHighlights?: readonly SearchRange[] | null } interface Emits { @@ -47,6 +51,7 @@ const props = withDefaults(defineProps(), { imagesShown: false, title: '메일 본문', foldLargeFiles: false, + textHighlights: null, }) const emit = defineEmits() @@ -63,6 +68,7 @@ const srcdoc = computed(() => textBody: props.textBody, imagesShown: props.imagesShown, foldLargeFiles: props.foldLargeFiles, + textHighlights: props.textHighlights, }), ) diff --git a/frontend/src/components/mail/MailConversationToggle.vue b/frontend/src/components/mail/MailConversationToggle.vue new file mode 100644 index 0000000..75d5964 --- /dev/null +++ b/frontend/src/components/mail/MailConversationToggle.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue index 9f8595f..056bcfa 100644 --- a/frontend/src/components/mail/MailListFrame.vue +++ b/frontend/src/components/mail/MailListFrame.vue @@ -4,8 +4,11 @@ // 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다), // 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice) // - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다) +// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내). +// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore) // - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다) // - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다 +// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐) import { computed, nextTick, ref, watch } from 'vue' import AppIcon from '@/components/AppIcon.vue' @@ -14,8 +17,11 @@ import MailListHeader from '@/components/mail/MailListHeader.vue' import MailLoadError from '@/components/mail/MailLoadError.vue' import MailNewNotice from '@/components/mail/MailNewNotice.vue' import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue' +import MailSearchConditionLine from '@/components/mail/MailSearchConditionLine.vue' import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue' import MailSearchHeader from '@/components/mail/MailSearchHeader.vue' +import MailSearchMore from '@/components/mail/MailSearchMore.vue' +import MailSearchNotices from '@/components/mail/MailSearchNotices.vue' import MailSearchScope from '@/components/mail/MailSearchScope.vue' import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue' import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue' @@ -26,7 +32,8 @@ import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation' import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail' import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later' import { isImeComposing } from '@/shared/utils/keyboard-shortcut' -import { isScheduledSource } from '@/shared/utils/mail-list-source' +import { isScheduledSource, isSearchTooLong } from '@/shared/utils/mail-list-source' +import { hasSearchConditions, skipsBodySearch } from '@/shared/utils/mail-search' import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox' import { useMailStore } from '@/stores/mail.store' @@ -69,15 +76,39 @@ const retentionNotice = computed(() => { const role = mailbox.value?.role ?? null const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null if (text === null) return null - return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text + return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text }) +/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */ +const searchConditions = computed(() => + search.value && hasSearchConditions(search.value.conditions) ? (search.value.conditions ?? null) : null, +) +/** 검색어와 조건의 낱말이 한도를 넘는 주소 — 찾지 않았다, 결과 없음 자리에 그 까닭을 보인다 (14단계) */ +const searchTooLong = computed(() => isSearchTooLong(search.value)) +/** 지난 메일의 색인을 만드는 중 — 결과 머리 아래 "검색 준비 중" 줄 · 결과 없음의 한계 안내 (14단계) */ +const indexing = computed(() => mailStore.searchIndexStatus === 'building') +/** 한 글자 낱말뿐이라 본문을 찾지 않았다 — 색인 방식(준비가 끝남)일 때만 알린다 (14단계) */ +const singleChar = computed( + () => + Boolean(search.value) && + !searchTooLong.value && + mailStore.searchIndexStatus === 'ready' && + skipsBodySearch(search.value?.q ?? '', search.value?.conditions), +) +/** 더 받는 중 · 보이는 채로 새로 고치는 중 — [메일 더 보기] · [더 찾기]를 막는다 (새로 고치는 동안 옛 커서로 붙이지 않게) */ +const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing) +/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */ +const searchPending = computed( + () => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore, +) /** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */ const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로')) function retry() { const current = search.value - if (current) void mailStore.openSearch(current.q, current.scope, { force: true }) + const source = mailStore.listSource + if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions }) else if (scheduled.value) void mailStore.openScheduled() + else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId) else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId) } @@ -107,6 +138,18 @@ function onKeydown(event: KeyboardEvent) { mailStore.clearChecked() } +// 조건 줄의 [조건 지우기] · 결과를 기다리던 [더 찾기]는 누르면 그 자리가 사라진다 — 초점을 잃었으면 목록 칸으로 (14단계) +watch( + [() => searchConditions.value !== null, searchPending], + ([hasConditions, pending], [hadConditions, wasPending]) => { + if ((hadConditions && !hasConditions) || (wasPending && !pending)) { + const active = document.activeElement + if (active === null || active === document.body) focusSection() + } + }, + { flush: 'post' }, +) + // 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로 watch( () => mailStore.checkedCount, @@ -159,7 +202,17 @@ watch( /> {{ retentionNotice }}

- + -