feat(backend): 비밀번호 변경 · 메일함 관리 · 부재중 자동 응답 · 스팸 표시 · 원문 API

- PUT /api/auth/password: 직원 본인 자격으로 엔진의 x:AccountPassword/set(게이트웨이 · 관리자 권한 없이), 로그인과 같은 실패 카운터 · 진행 중 잠금.
  바뀌면 리프레시 쿠키의 세션만 남기고 끊는다 — 결과를 모르는 실패에도 끊는다. 엔진이 바꾸기만 거절하면 세지 않는다
- 메일함 만들기 · 이름 바꾸기 · 지우기(안의 메일은 휴지통으로, onDestroyRemoveEmails false), 모두 읽음 · 휴지통 / 스팸메일함 비우기(조건부 지우기)
  — 500통씩 · 한 요청 25초 상한(엔진 호출마다 남은 시간), 같은 직원 · 같은 메일함은 하나씩(REQ_001), 진행 없는 차례가 이어지면 멈춘다
- GET · PUT /api/mail-vacation(켤 때만 제목 · 내용 필수, 제어 문자 검사, 초 단위), PATCH /api/messages(/{id}) 의 isJunk,
  GET /api/messages/{id}/source(message/rfc822)
- 에러 코드 AUTH_005 · MAIL_009 추가와 클라이언트 사전 다시 생성, 공용 길이 검사 데코레이터(JS 문자열 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 06:13:36 +09:00
parent cbfccaa499
commit 05fc3909dd
44 changed files with 6476 additions and 88 deletions
+28
View File
@@ -0,0 +1,28 @@
import { toWholeSecondIso } from './format';
describe('toWholeSecondIso', () => {
it('소수 초를 버리고 UTC ISO 문자열로 돌려준다 — 올리지 않는다', () => {
expect(toWholeSecondIso('2026-11-02T15:00:00.999Z')).toBe(
'2026-11-02T15:00:00.000Z',
);
expect(toWholeSecondIso('2026-11-02T15:00:00.5Z')).toBe(
'2026-11-02T15:00:00.000Z',
);
expect(toWholeSecondIso('2026-11-02T15:00:00Z')).toBe(
'2026-11-02T15:00:00.000Z',
);
expect(toWholeSecondIso('2026-11-02T15:00:01.000Z')).toBe(
'2026-11-02T15:00:01.000Z',
);
});
it('1970년 이전 시각도 앞쪽으로 버린다 (음수 시각에서 다음 초로 올라가지 않게)', () => {
expect(toWholeSecondIso('1969-12-31T23:59:59.500Z')).toBe(
'1969-12-31T23:59:59.000Z',
);
});
it('시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽 몫)', () => {
expect(toWholeSecondIso('내일')).toBe('내일');
});
});
+14
View File
@@ -11,3 +11,17 @@ export function formatDate(input: Date | string): string {
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/** 1초 (밀리초) */
const SECOND_MS = 1_000;
/**
* ISO 8601 시각을 초 단위로 맞춘다 — 소수 초를 버리고 UTC ISO 문자열('…:00.000Z')로 돌려준다.
* 초 단위로만 받는 곳(메일 엔진의 UTCDate 등)에 보낼 값과, 그 값으로 하는 비교를 같게 하려고 쓴다.
* 시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽이 먼저 한다)
*/
export function toWholeSecondIso(iso: string): string {
const time = Date.parse(iso);
if (Number.isNaN(time)) return iso;
return new Date(Math.floor(time / SECOND_MS) * SECOND_MS).toISOString();
}