feat(backend): 임시 저장·서명(그림)·받는 사람 추천 API와 MAIL_008

- /api/drafts: 발송과 같은 준비(정화·한도)로 초안을 만들고, 바꿔 저장은 새로 만들어 다시 읽은 뒤 옛것을 지운다.
  만들기와 지우기를 한 요청에 넣지 않는다 — 엔진이 만들기가 실패해도 지우기를 해서 저장본이 사라졌다.
  옛 초안이 이미 없으면 409 MAIL_008(새 코드), 초안이 아닌 id 는 404 RES_001 로 아무것도 지우지 않는다.
- 사용자 메일의 영구 삭제(교체한 옛 초안 · 보낸 뒤 draftId · 휴지통 영구 삭제)는 대상인지 확인한 상태 그대로일 때만
  ifInState 로 지운다 — 확인과 지우기 사이에 다른 탭이 옮긴 메일까지 지우지 않게 (Stalwart 0.16.27 에서 확인)
- /api/mail-signature: 서명(DB)과 그림(업로드 볼륨). 그림은 확장자·MIME·매직 바이트·크기·가로세로를 보고,
  보낼 때 multipart/related 의 cid 그림으로 싣는다. 서명 저장은 사용자별로 잠가 차례로 한다
- /api/recipient-suggestions: 로그인한 직원 기록(mail_directory)과 내 메일함의 최근 주소로 추천, 사용자별 10분 캐시
- 본문 그림은 첨부 표시에서 뺀다(hasAttachment 직접 계산 · isInline), 예외 필터 로그의 검색어(q)는 길이만 남긴다
- 마이그레이션 AddMailSignaturesAndDirectory: 새 표 3개만 만든다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:22:45 +09:00
parent 1868665592
commit a79f0791d0
71 changed files with 9068 additions and 157 deletions
+39 -1
View File
@@ -1,4 +1,42 @@
import { maskEmail } from './mask';
import { maskEmail, maskUrlForLog } from './mask';
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
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');
// + 는 공백 · 주소 형태의 검색어
expect(
maskUrlForLog(
'/api/messages?mailboxId=a&q=ara.kim%40arasystem.it.kr+%EC%9D%BC%EC%A0%95',
),
).toBe('/api/messages?mailboxId=a&q=<26자>');
});
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
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자>');
});
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 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 로 시작하는 다른 이름은 가리지 않는다
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
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자>');
// 값 안의 = 도 값이다
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
});
});
describe('maskEmail', () => {
it('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
+40
View File
@@ -8,6 +8,21 @@
/** 가린 부분을 표시하는 문자열 — 원래 길이를 드러내지 않도록 고정 길이로 쓴다 */
const MASK = '***';
/**
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
*/
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
try {
return decodeURIComponent(part.replace(/\+/g, ' '));
} catch {
return part;
}
}
/**
* 이메일 주소를 로그용으로 가린다. 첫 글자와 도메인만 남긴다.
*
@@ -22,3 +37,28 @@ export function maskEmail(email: string): string {
const [first] = Array.from(email.slice(0, at));
return `${first}${MASK}@${email.slice(at + 1)}`;
}
/**
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
*/
export function maskUrlForLog(url: string): string {
const queryStart = url.indexOf('?');
if (queryStart < 0) return url;
const query = url
.slice(queryStart + 1)
.split('&')
.map((pair) => {
const separator = pair.indexOf('=');
const name = separator < 0 ? pair : pair.slice(0, separator);
if (!REDACTED_QUERY_NAME.test(decodeQueryPart(name))) return pair;
const value =
separator < 0 ? '' : decodeQueryPart(pair.slice(separator + 1));
return `${name}=<${value.length}자>`;
})
.join('&');
return `${url.slice(0, queryStart)}?${query}`;
}
+32
View File
@@ -0,0 +1,32 @@
import { settledWithin } from './settle';
describe('settledWithin (정해진 시간까지만 기다리기)', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('시간 안에 끝나면 true — 실패로 끝나도 끝난 것이다', async () => {
await expect(settledWithin(Promise.resolve('done'), 1_000)).resolves.toBe(
true,
);
const failed = Promise.reject(new Error('engine down'));
await expect(settledWithin(failed, 1_000)).resolves.toBe(true);
});
it('시간이 지나면 false — 작업은 멈추지 않고 뒤에서 끝난다', async () => {
let finish!: () => void;
let finished = false;
const work = new Promise<void>((resolve) => {
finish = resolve;
}).then(() => {
finished = true;
});
const waited = settledWithin(work, 5_000);
jest.advanceTimersByTime(5_000);
await expect(waited).resolves.toBe(false);
finish();
await work;
expect(finished).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* 정해진 시간까지만 기다린다 — 그 안에 끝나면 true, 아니면 false (작업은 멈추지 않고 뒤에서 계속된다).
* 작업이 실패해도 "끝난 것"으로 본다 — 실패 처리(로그 등)는 작업 쪽이 한다. 이 함수는 결과를 바꾸지 않는다.
* 기다림 타이머는 프로세스를 붙잡지 않는다(unref) — 종료가 늦어지지 않게.
*/
export function settledWithin(
work: Promise<unknown>,
ms: number,
): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), ms);
timer.unref();
const finish = () => {
clearTimeout(timer);
resolve(true);
};
work.then(finish, finish);
});
}