generated from ttipo/project-base
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:
@@ -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 머리말).
|
||||
|
||||
Reference in New Issue
Block a user