fix(backend): 발송의 받는 사람별 결과 · 부재중 길이 한도

- 제출 다음에 EmailSubmission/get 으로 받는 사람별 결과를 읽는다(jmap/delivery-status.ts) — 일부 거절은 201 { id, rejectedRecipients },
  모두 거절이면 이 요청이 만든 보낸 사본을 지우고 400 MAIL_010(5xx 하나라도) · 503 MAIL_011(모두 4xx).
  이어 쓴 저장본은 지우지 않고 같은 키 잠금도 붙잡지 않는다 — 같은 키로 다시 보내면 새로 보낸다
- 같은 키 재시도는 처음 메일의 제출 기록으로 결과를 다시 만든다. 결과 읽기는 발송 55초 안에서 최대 5초, 읽지 못하면 받은 것으로 본다(warn)
- 로그는 가린 주소와 SMTP 상태 코드만
- 부재중 자동 응답: 켤 때 제목 150 · 내용 600, 꺼 둔 채로는 200 · 4,000
- 에러 코드 MAIL_010 · MAIL_011 과 생성 사전(frontend · flutter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:11:25 +09:00
parent ac418e3aa9
commit 19c20eb617
23 changed files with 1969 additions and 123 deletions
+209 -7
View File
@@ -1,7 +1,7 @@
import type { NestExpressApplication } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { basename, join } from 'path';
@@ -22,6 +22,10 @@ import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
@@ -44,7 +48,9 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
RecipientRejectionReason,
SavedDraft,
SentMessage,
} from '@/modules/mail/mail.types';
import {
SIGNATURE_IMAGES_PER_SIGNATURE,
@@ -146,6 +152,17 @@ const SLOW_MAILBOX = 'slowbox';
/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */
const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test';
/**
* 대역 메일 엔진이 발송 때 받지 않는 받는 사람과 그 까닭 (계약 2절 "받는 사람별 결과").
* SMTP 응답의 분류 · 모두 거절일 때 보낸 사본 지우기는 jmap-mail.provider.spec.ts · delivery-status.spec.ts 가 본다
*/
const NOBODY = 'nobody@e2e.arasystem.test';
const BUSY = 'busy@e2e.arasystem.test';
const ENGINE_REJECTIONS = new Map<string, RecipientRejectionReason>([
[NOBODY, 'not-found'],
[BUSY, 'temporary'],
]);
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -297,6 +314,8 @@ describe('응답 계약 (e2e)', () => {
const deletedDrafts: string[] = [];
/** 대역 엔진이 받은 발송 */
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 받는 사람을 모두 받지 않은 발송 — 보내지 않은 것이라 sentMessages 에 넣지 않는다 */
const refusedSends: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
const correspondents = new Map<string, Correspondence[]>();
@@ -379,11 +398,37 @@ describe('응답 계약 (e2e)', () => {
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
},
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다.
// 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx)
findSentMessage: () => Promise.resolve(null),
sendMessage: (user: string, message: OutgoingMessage) => {
getRejectedRecipients: () => Promise.resolve([]),
sendMessage: (
user: string,
message: OutgoingMessage,
): Promise<SentMessage> => {
const recipients = [...message.to, ...message.cc, ...message.bcc];
const rejectedRecipients = recipients.flatMap(({ email }) => {
const reason = ENGINE_REJECTIONS.get(email);
return reason ? [{ email, reason }] : [];
});
if (rejectedRecipients.length === recipients.length) {
refusedSends.push({ user, message });
const code = rejectedRecipients.some(
({ reason }) => reason !== 'temporary',
)
? 'MAIL_010'
: 'MAIL_011';
return Promise.reject(
new BusinessException(code, {
context: { reason: 'e2e-all-recipients-rejected' },
}),
);
}
sentMessages.push({ user, message });
return Promise.resolve({ id: `sent${sentMessages.length}` });
return Promise.resolve({
id: `sent${sentMessages.length}`,
rejectedRecipients,
});
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
saveDraft: (
@@ -2393,7 +2438,7 @@ describe('응답 계약 (e2e)', () => {
expect(dataOf<unknown>(read)).toEqual(vacation);
});
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
const before = vacationSaves.length;
const withoutToDate = Object.fromEntries(
Object.entries(vacation).filter(([key]) => key !== 'toDate'),
@@ -2407,8 +2452,18 @@ describe('응답 계약 (e2e)', () => {
{ ...vacation, fromDate: '2026-11-02' },
{ ...vacation, fromDate: '2026-11-02T15:00:00+09:00' },
withoutToDate,
{ ...vacation, subject: 'x'.repeat(201) },
{ ...vacation, textBody: 'x'.repeat(4_001) },
{ ...vacation, subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
{ ...vacation, textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
{
...vacation,
isEnabled: false,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
},
{
...vacation,
isEnabled: false,
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
},
{ ...vacation, htmlBody: '<p>휴가</p>' },
{ ...vacation, subject: ' ' },
{ ...vacation, textBody: ' \n\t ' },
@@ -2427,6 +2482,44 @@ describe('응답 계약 (e2e)', () => {
expect(vacationSaves).toHaveLength(before);
});
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지는 한글로 채워도 저장한다 (엔진의 UTF-8 바이트 한도 안 — 계약 2절 "길이 한도의 근거")`, async () => {
const full = {
...vacation,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
const res = await put(full).expect(200);
expect(dataOf<unknown>(res)).toEqual(full);
});
it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => {
const longOff = {
...vacation,
isEnabled: false,
textBody: 'x'.repeat(1_000),
};
const res = await put(longOff).expect(200);
expect(dataOf<unknown>(res)).toEqual(longOff);
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff });
const before = vacationSaves.length;
for (const enabling of [
{ ...longOff, isEnabled: true },
{
...longOff,
isEnabled: true,
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
},
]) {
const rejected = await put(enabling).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
}
expect(vacationSaves).toHaveLength(before);
});
it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => {
const off = {
...vacation,
@@ -2646,6 +2739,115 @@ describe('응답 계약 (e2e)', () => {
);
});
/**
* 받는 사람별 발송 결과 — 6단계 앞 수정 (계약 2절 "받는 사람별 결과").
* 엔진 쪽 판단(SMTP 응답 분류 · 모두 거절일 때 보낸 사본 지우기)은 대역 엔진이 흉내 낸다 (ENGINE_REJECTIONS) —
* 여기서는 HTTP 형식 · 이어 쓴 저장본 · 같은 키 잠금을 본다
*/
describe('받는 사람별 발송 결과 (POST /api/messages — 6단계 앞 수정)', () => {
const ME = 'delivery.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const address = (email: string) => ({ name: null, email });
const send = (payload: object, key?: string) => {
const sending = request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME));
return (key ? sending.set('Idempotency-Key', key) : sending).send({
subject: '제목',
body: '본문',
...payload,
});
};
/** 이어 쓸 저장본을 하나 만든다 */
const savedDraft = async () =>
dataOf<SavedDraft>(
await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '쓰는 중', body: '본문' })
.expect(201),
).id;
it('모두 받았으면 201 { id, rejectedRecipients: [] }', async () => {
const res = await send({ to: [DOYUN] }).expect(201);
expect(body(res)).toEqual({
success: true,
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
},
});
});
it('일부만 받지 않았으면 보낸 것이다 — 201 에 받지 않은 사람(요청에 쓴 주소 · 까닭), 이어 쓴 저장본은 지운다', async () => {
const draftId = await savedDraft();
const res = await send({
to: [DOYUN],
cc: [address(NOBODY)],
bcc: [address(BUSY)],
draftId,
}).expect(201);
expect(dataOf<unknown>(res)).toEqual({
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [
{ email: NOBODY, reason: 'not-found' },
{ email: BUSY, reason: 'temporary' },
],
});
expect(deletedDrafts).toContain(draftId);
});
it('아무도 받지 않았으면(5xx 가 섞임) 400 MAIL_010 — 표준 실패 JSON, 이어 쓴 저장본은 그대로 둔다', async () => {
const draftId = await savedDraft();
const sentBefore = sentMessages.length;
const res = await send({
to: [address(NOBODY)],
cc: [address(BUSY)],
draftId,
}).expect(400);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('MAIL_010');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
expect(sentMessages).toHaveLength(sentBefore);
expect(deletedDrafts).not.toContain(draftId);
expect(drafts.has(draftId)).toBe(true);
});
it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 이어 쓴 저장본은 그대로 둔다', async () => {
const draftId = await savedDraft();
const res = await send({ to: [address(BUSY)], draftId }).expect(503);
expect(body(res).error?.code).toBe('MAIL_011');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
expect(deletedDrafts).not.toContain(draftId);
});
it('모두 받지 않은 키는 잠가 두지 않는다 — 곧바로 같은 키로 다시 보내면 새로 보낸다 (429 · 409 가 아니다)', async () => {
for (const [rejected, status] of [
[NOBODY, 400],
[BUSY, 503],
] as const) {
const key = randomUUID();
const refusedBefore = refusedSends.length;
await send({ to: [address(rejected)] }, key).expect(status);
// 같은 내용으로 다시 — 처리 중(REQ_001)이 아니라 엔진에 다시 묻는다
await send({ to: [address(rejected)] }, key).expect(status);
expect(refusedSends).toHaveLength(refusedBefore + 2);
// 주소를 고쳐 같은 키로 — 보낸 사본이 없으니 내용이 달라도 MAIL_005 가 아니다
await send({ to: [DOYUN] }, key).expect(201);
}
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).