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
+29 -1
View File
@@ -1,4 +1,32 @@
import { isSendMessageRequest } from './app.setup';
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
for (const [method, url] of [
['POST', '/api/drafts'],
['POST', '/API/Drafts/'],
['PUT', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/?x=1'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(true);
}
});
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
for (const [method, url] of [
['GET', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts'],
['POST', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/attachments'],
['PATCH', '/api/drafts/faaaaabi'],
['POST', '/api/draftsx'],
['PUT', '/drafts/faaaaabi'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(false);
}
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
});
});
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {