feat(backend): 새 메일 확인·전체 메일함 검색(한국어 조사)·여러 통 수정·휴지통 영구 삭제 API

- GET /api/mail-state: 메일함·메일 상태를 합친 불투명 값 — 화면이 1분마다 바뀐 것이 있는지만 묻는다
- GET /api/messages: mailboxId 를 선택으로(없으면 휴지통·스팸메일함을 뺀 전체). 엔진은 낱말이 통째로 같을 때만 맞으므로
  q 를 낱말·구절로 나눠 낱말마다 조사를 붙인 형태를 OR 로 찾는다 (jmap/search-terms.ts)
- PATCH /api/messages: 읽음·중요·이동을 100통까지 엔진 요청 한 번에. 없는 메일함이면 아무것도 바꾸지 않고 404
- POST /api/message-deletions: 휴지통에 있는 메일만, 확인한 상태 그대로일 때만(ifInState) 지운다 — 그사이 되돌린 메일을 지우지 않게
- 로그의 cursor 도 길이만 남긴다 — 커서에 검색어의 지문이 들어 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 23:56:09 +09:00
parent 7585b3bfac
commit 090ee361a3
25 changed files with 2870 additions and 107 deletions
+26 -5
View File
@@ -1,12 +1,12 @@
import { maskEmail, maskUrlForLog } from './mask';
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
it('검색어(q)와 커서(cursor)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
expect(
maskUrlForLog(
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
),
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>');
// + 는 공백 · 주소 형태의 검색어
expect(
maskUrlForLog(
@@ -15,22 +15,43 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
).toBe('/api/messages?mailboxId=a&q=<26자>');
});
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
it('커서는 검색어의 해시(범위 해시)를 담으므로 검색 없는 목록의 커서도 가린다 — 메일함 id 는 그대로', () => {
expect(
maskUrlForLog(
'/api/messages?mailboxId=a&limit=20&cursor=eyJ2IjoxLCJhIjoibTIiLCJwIjoyLCJzIjoiaDBLUHhTS0FQVEVHWG52TyJ9',
),
).toBe('/api/messages?mailboxId=a&limit=20&cursor=<60자>');
});
it('q · cursor 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
expect(
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
expect(
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
expect(
maskUrlForLog(
'/api/x?CURSOR=abc&cursor[]=abcd&%63ursor=ab&cursor%5B0%5D=a&cursor=x&cursor=yz',
),
).toBe(
'/api/x?CURSOR=<3자>&cursor[]=<4자>&%63ursor=<2자>&cursor%5B0%5D=<1자>&cursor=<1자>&cursor=<2자>',
);
});
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
it('q · cursor 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
'/api/messages?mailboxId=a&limit=20',
);
// q 로 시작하는 다른 이름은 가리지 않는다
// q · cursor 로 시작하거나 끝나는 다른 이름은 가리지 않는다
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
expect(maskUrlForLog('/api/x?cursors=1&nextCursor=2&mycursor=3')).toBe(
'/api/x?cursors=1&nextCursor=2&mycursor=3',
);
expect(maskUrlForLog('/api/x?cursor=&cursor')).toBe(
'/api/x?cursor=<0자>&cursor=<0자>',
);
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
// 값 안의 = 도 값이다
+7 -4
View File
@@ -9,10 +9,13 @@
const MASK = '***';
/**
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
* 값을 남기지 않는 쿼리 이름 — 검색어(q)와 목록 커서(cursor).
* - q: 메일 검색어 · 받는 사람 추천의 거르는 글자 — 사람 이름·주소가 들어간다 (계약 2절 "검색" · "로그에는 개수만")
* - cursor: 다른 목록의 커서를 막으려고 검색어의 해시(범위 해시)를 담는다 — 로그를 읽는 사람이 후보 검색어를 대입해 맞춰 볼 수 있다
* (modules/mail/jmap/jmap-cursor.ts · recipient-suggestion-cursor.ts)
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
*/
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
@@ -39,10 +42,10 @@ export function maskEmail(email: string): string {
}
/**
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>'
*/
export function maskUrlForLog(url: string): string {
const queryStart = url.indexOf('?');