chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
23 changed files with 1969 additions and 123 deletions
Showing only changes of commit 19c20eb617 - Show all commits
@@ -54,6 +54,11 @@ describe('에러 코드 레지스트리', () => {
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
});
it('받는 사람을 엔진이 모두 받지 않은 발송 — 5xx 가 섞이면 400 MAIL_010, 모두 4xx(지금은 받을 수 없음)면 503 MAIL_011', () => {
expect(ERROR_CODES.MAIL_010.status).toBe(HttpStatus.BAD_REQUEST);
expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => 'clientOnly' in definition)
+11
View File
@@ -137,6 +137,17 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.CONFLICT,
message: '같은 이름의 메일함이 이미 있습니다.',
},
// 발송했지만 엔진이 받는 사람을 모두 거절했다 — 그중 하나라도 5xx(없는 주소 · 거절). 아무에게도 가지 않아 보낸 사본을 지웠다 (계약 2절 "받는 사람별 결과")
MAIL_010: {
status: HttpStatus.BAD_REQUEST,
message: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
},
// 발송했지만 엔진이 받는 사람을 모두 4xx(지금은 받을 수 없음 — 한도 등)로 거절했다. 보낸 사본을 지웠다 — 같은 키로 다시 보내면 된다
MAIL_011: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message:
'받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -17,8 +17,11 @@ import type {
MessageDetail,
MessagesPatchResult,
MessageSummary,
RecipientRejectionReason,
RecipientSuggestion,
RejectedRecipient,
SavedDraft,
SentMessage,
} from '@/modules/mail/mail.types';
/**
@@ -357,9 +360,42 @@ export class MessagePageDto {
pageInfo!: PageInfoDto;
}
export class SentMessageDto {
const REJECTION_REASONS: RecipientRejectionReason[] = [
'not-found',
'refused',
'temporary',
];
/** 계약 2절 "받는 사람별 결과"의 RejectedRecipient — 엔진이 받지 않은 사람 */
export class RejectedRecipientDto implements RejectedRecipient {
@ApiProperty({
description:
'요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두, 같은 주소는 한 번)',
example: 'nobody@arasystem.it.kr',
})
email!: string;
@ApiProperty({
description:
'not-found = 없는 주소(SMTP 확장 상태 5.1.x) · refused = 받는 쪽이 거절(그 밖의 5xx) · temporary = 지금은 받을 수 없음(4xx — 한도 등)',
enum: REJECTION_REASONS,
example: 'not-found',
})
reason!: RecipientRejectionReason;
}
/** 계약 2절 "받는 사람별 결과"의 SentMessage — POST /api/messages 의 201 */
export class SentMessageDto implements SentMessage {
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
id!: string;
@ApiProperty({
description:
'엔진이 받지 않은 사람 — 없으면 빈 배열. 다른 사람에게는 보냈다(보낸 사본은 보낸편지함에 남는다). ' +
'아무도 받지 않았으면 이 응답 대신 400 MAIL_010 · 503 MAIL_011 이다. 결과를 읽지 못하면 빈 배열(받은 것으로 본다)',
type: [RejectedRecipientDto],
})
rejectedRecipients!: RejectedRecipientDto[];
}
/** 계약 2절 "새 메일 확인"의 MailState */
@@ -3,7 +3,9 @@ import { validate } from 'class-validator';
import {
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 { MailVacationDto, isUtcDateTime } from './mail-vacation.dto';
@@ -70,26 +72,31 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual([]);
});
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
expect(
await constraintsOf({
...enabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH),
}),
).toEqual([]);
expect(
await constraintsOf({
...enabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
}),
).toEqual(['maxCodeUnits']);
expect(
await constraintsOf({
...enabled,
textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x`,
}),
).toEqual(['maxCodeUnits']);
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
for (const isEnabled of [false, true]) {
expect(
await constraintsOf({
...enabled,
isEnabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF),
}),
).toEqual([]);
expect(
await constraintsOf({
...enabled,
isEnabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
}),
).toEqual(['maxCodeUnits']);
expect(
await constraintsOf({
...enabled,
isEnabled,
textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF / 2)}x`,
}),
).toEqual(['maxCodeUnits']);
}
expect(await constraintsOf({ ...enabled, subject: null })).toEqual(
expect.arrayContaining(['isString']),
);
@@ -98,6 +105,52 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual(['whitelistValidation']);
});
it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => {
const off = { ...enabled, isEnabled: false };
for (const [subjectLength, textLength] of [
[MAX_VACATION_SUBJECT_LENGTH + 1, MAX_VACATION_TEXT_LENGTH + 1],
[MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, MAX_VACATION_TEXT_LENGTH_WHEN_OFF],
]) {
expect(
await constraintsOf({
...off,
subject: 'x'.repeat(subjectLength),
textBody: 'x'.repeat(textLength),
}),
).toEqual([]);
}
});
it('켤 때의 한도까지 쓴 어떤 글도 엔진의 UTF-8 바이트 한도(제목 511 · 내용 2,047) 안이다 — 한글 · 이모지 · 서로게이트 반쪽 모두', async () => {
// Stalwart 0.16.27 이 넘으면 invalidProperties 로 거절한다 (계약 2절 "길이 한도의 근거", 2026-10-28 확인)
const ENGINE_SUBJECT_MAX_BYTES = 511;
const ENGINE_TEXT_MAX_BYTES = 2_047;
const fill = (unit: string, length: number) =>
unit.repeat(Math.floor(length / unit.length));
// 한 자(UTF-16 코드 단위)가 UTF-8 로 가장 큰 것은 3바이트다 — BMP 글자(한글), 짝 없는 서로게이트(U+FFFD 로 바뀐다)
for (const unit of ['가', '😀', '\ud83d', 'a']) {
const subject = fill(unit, MAX_VACATION_SUBJECT_LENGTH);
const textBody = fill(unit, MAX_VACATION_TEXT_LENGTH);
expect(Buffer.byteLength(subject, 'utf8')).toBeLessThanOrEqual(
ENGINE_SUBJECT_MAX_BYTES,
);
expect(Buffer.byteLength(textBody, 'utf8')).toBeLessThanOrEqual(
ENGINE_TEXT_MAX_BYTES,
);
}
// 한글로 한도까지 채운 값은 형식 검사를 지난다 (4단계의 200자 · 4,000자는 한글이면 엔진이 거절했다)
expect(
await constraintsOf({
...enabled,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
}),
).toEqual([]);
});
it('제목에는 제어 문자(C0 — 탭 · 줄바꿈 포함, DEL, C1)를 쓸 수 없다 — 범위 바로 바깥 문자는 받는다', async () => {
for (const subject of [
'부재\n중',
@@ -11,7 +11,9 @@ import {
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
MULTI_LINE_TEXT_PATTERN,
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
@@ -45,11 +47,13 @@ const unlessNull = (_object: object, value: unknown) => value !== null;
/**
* 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답").
* 켤 때 제목 · 내용이 비었는지, 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). 서식 본문은 받지 않는다.
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
*/
export class MailVacationDto implements MailVacation {
@ApiProperty({
description: '켜짐 — 켜면 제목 · 내용이 1자 이상이어야 한다',
description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`,
example: true,
})
@IsBoolean({ message: '부재중 자동 응답을 켤지 다시 선택해 주세요.' })
@@ -77,13 +81,15 @@ export class MailVacationDto implements MailVacation {
toDate!: string | null;
@ApiProperty({
description: `자동 응답 제목 — ${MAX_VACATION_SUBJECT_LENGTH}자까지(JS 문자열 길이), 켤 때는 1자 이상 (끈 채로는 빈 문자열도 저장된다)`,
maxLength: MAX_VACATION_SUBJECT_LENGTH,
description:
`자동 응답 제목 — 켤 때는 1~${MAX_VACATION_SUBJECT_LENGTH}자(엔진의 바이트 한도 안), 꺼 둔 채로는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지 ` +
'(JS 문자열 길이 — 끈 채로는 빈 문자열도 저장된다)',
maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
example: '부재중입니다',
})
@IsString({ message: '자동 응답 제목 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH, {
message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH}자 이하로 입력해 주세요.`,
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, {
message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '자동 응답 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
@@ -91,13 +97,15 @@ export class MailVacationDto implements MailVacation {
subject!: string;
@ApiProperty({
description: `자동 응답 내용 — 글자만(서식 없음), ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자까지(JS 문자열 길이), 켤 때는 1자 이상. 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH,
description:
`자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
})
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH, {
message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, {
message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
@Matches(MULTI_LINE_TEXT_PATTERN, {
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
@@ -0,0 +1,269 @@
import type { RecipientFields } from '@/modules/mail/mail.types';
import {
DELIVERED_TO_ALL,
classifySmtpReply,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
toRejectedRecipients,
} from './delivery-status';
import type { JmapEmailSubmission } from './jmap.types';
/** 개발 엔진(Stalwart 0.16.27)이 돌려준 모양 그대로 (2026-10-28) */
const REFUSED_NOT_FOUND = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
displayed: 'unknown',
};
const REFUSED_RATE_LIMIT = {
delivered: 'no',
smtpReply: '452 4.4.5 Rate limit exceeded, try again later.',
displayed: 'unknown',
};
const QUEUED = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
displayed: 'unknown',
};
const only = (email: string): RecipientFields => ({
to: [{ name: null, email }],
cc: [],
bcc: [],
});
const submission = (
deliveryStatus: JmapEmailSubmission['deliveryStatus'],
): JmapEmailSubmission => ({ id: 's1', deliveryStatus });
describe('받는 사람별 발송 결과 (delivery-status)', () => {
describe('SMTP 응답 → 까닭 (classifySmtpReply)', () => {
it.each([
['550 5.1.1 User unknown', 'not-found', '550 5.1.1'],
['550 5.1.2 Mailbox does not exist.', 'not-found', '550 5.1.2'],
['550-5.1.1 The email account does not exist', 'not-found', '550 5.1.1'],
['5.1.1 User unknown', 'not-found', '5.1.1'],
['554 5.7.1 Relay access denied', 'refused', '554 5.7.1'],
['550 Mailbox unavailable', 'refused', '550'],
[
'452 4.4.5 Rate limit exceeded, try again later.',
'temporary',
'452 4.4.5',
],
['421 4.7.0 Try again later', 'temporary', '421 4.7.0'],
['451 4.1.8 Sender domain not resolving', 'temporary', '451 4.1.8'],
])('"%s" 는 %s — 로그에는 상태 코드 %s 만', (reply, reason, smtpStatus) => {
expect(classifySmtpReply(reply)).toEqual({ reason, smtpStatus });
});
it('기본 코드와 확장 상태가 어긋나면 기본 코드로 나눈다 — 5xx 에 4.x.x 는 거절, 4xx 에 5.1.x 는 지금은 받을 수 없음', () => {
expect(classifySmtpReply('550 4.4.5 odd')).toEqual({
reason: 'refused',
smtpStatus: '550 4.4.5',
});
expect(classifySmtpReply('450 5.1.1 odd')).toEqual({
reason: 'temporary',
smtpStatus: '450 5.1.1',
});
});
it('읽을 수 없는 응답 · 응답 없음은 거절(refused) — 상태 코드는 null, 응답 글 안의 숫자는 읽지 않는다', () => {
for (const reply of [
undefined,
null,
'',
'Mailbox does not exist',
'see 4.4.5 later',
'5501 nope',
]) {
expect(classifySmtpReply(reply)).toEqual({
reason: 'refused',
smtpStatus: null,
});
}
});
});
describe('요청의 받는 사람과 맞추기 (readDeliveryResult)', () => {
it('delivered 가 "no" 인 사람만 받지 않은 사람이다 — 대기열(250 Queued) · 결과 없음은 받은 것', () => {
const recipients: RecipientFields = {
to: [{ name: '없는 사람', email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'doyun.lee@arasystem.test' }],
bcc: [{ name: null, email: 'outside@example.com' }],
};
expect(
readDeliveryResult(recipients, [
submission({
'nobody@arasystem.test': REFUSED_NOT_FOUND,
'doyun.lee@arasystem.test': QUEUED,
}),
]),
).toEqual({
rejected: [
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
],
isEveryoneRejected: false,
});
});
it('주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다', () => {
const result = readDeliveryResult(only('NoBody@AraSystem.Test'), [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]);
expect(result.rejected).toEqual([
expect.objectContaining({ email: 'NoBody@AraSystem.Test' }),
]);
expect(result.isEveryoneRejected).toBe(true);
});
it('같은 주소가 받는 사람 · 참조 · 숨은참조에 여러 번 있어도 한 번 — 처음 쓴 모양으로', () => {
const result = readDeliveryResult(
{
to: [{ name: null, email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'NOBODY@arasystem.test' }],
bcc: [{ name: null, email: 'nobody@arasystem.test' }],
},
[submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND })],
);
expect(result.rejected.map(({ email }) => email)).toEqual([
'nobody@arasystem.test',
]);
expect(result.isEveryoneRejected).toBe(true);
});
it('받은 사람이 하나도 없어야 모두 거절이다 — 결과가 없는 사람이 있으면 그 사람은 받은 것', () => {
const two: RecipientFields = {
to: [{ name: null, email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'busy@arasystem.test' }],
bcc: [],
};
expect(
readDeliveryResult(two, [
submission({
'nobody@arasystem.test': REFUSED_NOT_FOUND,
'busy@arasystem.test': REFUSED_RATE_LIMIT,
}),
]).isEveryoneRejected,
).toBe(true);
expect(
readDeliveryResult(two, [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]),
).toMatchObject({
rejected: [{ email: 'nobody@arasystem.test' }],
isEveryoneRejected: false,
});
});
it('엔진이 결과를 모르면(deliveryStatus null · 제출 없음) 받지 않은 사람이 없다', () => {
const recipients = only('nobody@arasystem.test');
for (const submissions of [
[],
[submission(null)],
[submission(undefined)],
[submission({ 'nobody@arasystem.test': null })],
]) {
expect(readDeliveryResult(recipients, submissions)).toEqual({
rejected: [],
isEveryoneRejected: false,
});
}
});
it('제출이 여럿이면 한 번이라도 거절되지 않은 사람은 받은 것이다', () => {
const result = readDeliveryResult(only('nobody@arasystem.test'), [
submission({ 'nobody@arasystem.test': REFUSED_RATE_LIMIT }),
{ id: 's2', deliveryStatus: { 'nobody@arasystem.test': QUEUED } },
]);
expect(result).toEqual({ rejected: [], isEveryoneRejected: false });
});
it('받는 사람이 없으면 모두 거절이 아니다 (보낼 수 없는 요청은 엔진이 제출 전에 거절한다)', () => {
expect(
readDeliveryResult({ to: [], cc: [], bcc: [] }, [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]).isEveryoneRejected,
).toBe(false);
});
});
it('받지 않은 사람이 없는 결과(DELIVERED_TO_ALL)는 여러 요청이 함께 쓰므로 얼려 둔다 — 배열까지', () => {
expect(DELIVERED_TO_ALL).toEqual({
rejected: [],
isEveryoneRejected: false,
});
expect(Object.isFrozen(DELIVERED_TO_ALL)).toBe(true);
expect(Object.isFrozen(DELIVERED_TO_ALL.rejected)).toBe(true);
});
it('응답에는 주소와 까닭만 싣는다 — SMTP 상태 코드는 로그 전용', () => {
expect(
toRejectedRecipients([
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
]),
).toEqual([{ email: 'nobody@arasystem.test', reason: 'not-found' }]);
});
it('모두 거절의 코드 — 5xx 가 하나라도 있으면 MAIL_010, 모두 4xx 면 MAIL_011', () => {
const temporary = {
email: 'busy@arasystem.test',
reason: 'temporary' as const,
smtpStatus: '452 4.4.5',
};
const notFound = {
email: 'nobody@arasystem.test',
reason: 'not-found' as const,
smtpStatus: '550 5.1.2',
};
const refused = {
email: 'blocked@example.com',
reason: 'refused' as const,
smtpStatus: null,
};
expect(everyoneRejectedCode([temporary])).toBe('MAIL_011');
expect(everyoneRejectedCode([temporary, temporary])).toBe('MAIL_011');
expect(everyoneRejectedCode([temporary, notFound])).toBe('MAIL_010');
expect(everyoneRejectedCode([refused])).toBe('MAIL_010');
});
it('로그에는 가린 주소와 SMTP 상태 코드만 — 응답 글은 남기지 않고, 10명을 넘으면 "외 N명"', () => {
const line = describeRejections([
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
{ email: 'odd@example.com', reason: 'refused', smtpStatus: null },
]);
expect(line).toBe(
'받지 않은 사람 2명: n***@arasystem.test(550 5.1.2), o***@example.com(상태 코드 없음)',
);
expect(line).not.toMatch(/nobody|Mailbox/);
const many = Array.from({ length: 12 }, (_, index) => ({
email: `r${index}@arasystem.test`,
reason: 'temporary' as const,
smtpStatus: '452 4.4.5',
}));
const long = describeRejections(many);
expect(long).toMatch(/^받지 않은 사람 12명: /);
expect(long.match(/r\*\*\*@arasystem\.test/g)).toHaveLength(10);
expect(long).toMatch(/ 외 2명$/);
});
});
@@ -0,0 +1,165 @@
import type { ErrorCode } from '@/common/errors/error-codes';
import type {
RecipientFields,
RecipientRejectionReason,
RejectedRecipient,
} from '@/modules/mail/mail.types';
import { maskEmail } from '@/shared/utils/mask';
import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
/**
* 받는 사람별 발송 결과 (계약 2절 "받는 사람별 결과") — 순수 함수.
*
* 엔진은 제출(EmailSubmission/set)을 만들면서 받는 사람마다 그 자리에서 SMTP 로 묻고, 결과를 제출 기록의 deliveryStatus 에
* 남긴다(키는 받는 사람 주소). 거절된 사람은 `delivered: "no"` 와 SMTP 응답으로만 남고 엔진이 **다시 보내지도 반송하지도 않는다**
* (Stalwart 0.16.27 — 2026-10-28 확인):
* - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도)
* - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다.
* 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다
* 판단은 deliveryStatus 로만 한다 — undoStatus 는 모두 거절이면 final, 하나라도 대기열에 들어가면 pending 일 뿐이다.
*/
/** 받지 않은 사람 하나 — 로그에 남길 SMTP 상태 코드를 함께 둔다 (응답에는 email · reason 만 — toRejectedRecipients) */
export interface RecipientRejection extends RejectedRecipient {
/** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 null */
readonly smtpStatus: string | null;
}
/** 한 번 보낸 결과 */
export interface DeliveryResult {
/** 받지 않은 사람 — 요청의 받는 사람 · 참조 · 숨은참조 순서, 같은 주소(대소문자 무시)는 한 번 */
readonly rejected: readonly RecipientRejection[];
/** 받은 사람이 아무도 없다 — 보내지 않은 것으로 다룬다 (MAIL_010 · MAIL_011) */
readonly isEveryoneRejected: boolean;
}
/** 받지 않은 사람이 없다 — 결과를 읽지 못했을 때도 이것으로 본다 (제출은 이미 되었다). 여러 요청이 함께 쓰는 값이라 얼려 둔다 */
export const DELIVERED_TO_ALL: DeliveryResult = Object.freeze({
rejected: Object.freeze([]),
isEveryoneRejected: false,
});
/**
* SMTP 응답 맨 앞의 상태 — 기본 코드(`550`)와 확장 상태(`5.1.2` — RFC 3463), 둘 다 없을 수 있다.
* 여러 줄 응답의 이음표(`550-5.1.1`)도 받는다. 맨 앞만 보므로 응답 글 안의 숫자(판 번호 등)는 읽지 않는다
*/
const SMTP_STATUS =
/^\s*(?:([2-5]\d{2})(?:[ -]+|$))?(?:([245])\.(\d{1,3})\.(\d{1,3})(?=[\s.,;:]|$))?/;
/** 확장 상태의 주제가 "주소"(RFC 3463 의 X.1.Y)면 없는 주소 · 잘못된 주소다 */
const ADDRESS_STATUS_SUBJECT = '1';
/** 로그 한 줄에 적는 받지 않은 사람 수 — 넘으면 "외 N명" */
const LOGGED_REJECTIONS_MAX = 10;
/** 주소를 맞출 때의 열쇠 — 대소문자와 앞뒤 공백을 무시한다 */
function addressKey(email: string): string {
return email.trim().toLowerCase();
}
/**
* 받지 않은 SMTP 응답의 까닭 — 4xx 는 temporary, 확장 상태 5.1.x 는 not-found, 그 밖(5xx · 읽을 수 없는 응답)은 refused.
* 기본 코드가 없으면 확장 상태의 첫 자리로 본다 (`delivered: "no"` 라 어느 쪽이든 이 사람에게는 가지 않는다)
*/
export function classifySmtpReply(reply: string | null | undefined): {
readonly reason: RecipientRejectionReason;
readonly smtpStatus: string | null;
} {
const match = SMTP_STATUS.exec(reply ?? '');
const basic: string | undefined = match?.[1];
const severity: string | undefined = match?.[2];
const enhanced = severity
? `${severity}.${match?.[3]}.${match?.[4]}`
: undefined;
const smtpStatus = [basic, enhanced].filter(Boolean).join(' ') || null;
const kind = basic?.charAt(0) ?? severity;
if (kind === '4') return { reason: 'temporary', smtpStatus };
if (
kind === '5' &&
severity === '5' &&
match?.[3] === ADDRESS_STATUS_SUBJECT
) {
return { reason: 'not-found', smtpStatus };
}
return { reason: 'refused', smtpStatus };
}
/**
* 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
* `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다.
* 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다.
*/
export function readDeliveryResult(
recipients: RecipientFields,
submissions: readonly JmapEmailSubmission[],
): DeliveryResult {
const refusals = new Map<string, JmapDeliveryStatus>();
const accepted = new Set<string>();
for (const submission of submissions) {
for (const [address, status] of Object.entries(
submission.deliveryStatus ?? {},
)) {
const key = addressKey(address);
if (status?.delivered !== 'no') {
accepted.add(key);
} else if (!refusals.has(key)) {
refusals.set(key, status);
}
}
}
const seen = new Set<string>();
const rejected: RecipientRejection[] = [];
for (const { email } of [
...recipients.to,
...recipients.cc,
...recipients.bcc,
]) {
const key = addressKey(email);
if (seen.has(key)) continue;
seen.add(key);
const refusal = accepted.has(key) ? undefined : refusals.get(key);
if (refusal) {
rejected.push({ email, ...classifySmtpReply(refusal.smtpReply) });
}
}
return {
rejected,
isEveryoneRejected: seen.size > 0 && rejected.length === seen.size,
};
}
/** 응답에 싣는 모양 (계약의 RejectedRecipient) — SMTP 상태 코드는 로그 전용이라 뺀다 */
export function toRejectedRecipients(
rejected: readonly RecipientRejection[],
): RejectedRecipient[] {
return rejected.map(({ email, reason }) => ({ email, reason }));
}
/** 모두 거절됐을 때의 코드 — 거절 가운데 5xx(없는 주소 · 거절)가 하나라도 있으면 MAIL_010, 모두 4xx 면 MAIL_011 */
export function everyoneRejectedCode(
rejected: readonly RecipientRejection[],
): Extract<ErrorCode, 'MAIL_010' | 'MAIL_011'> {
return rejected.some(({ reason }) => reason !== 'temporary')
? 'MAIL_010'
: 'MAIL_011';
}
/**
* 로그에 남길 받지 않은 사람 — 가린 주소와 SMTP 상태 코드만, LOGGED_REJECTIONS_MAX 명까지
* (예: `받지 않은 사람 2명: n***@arasystem.it.kr(550 5.1.2), b***@example.com(452 4.4.5)`)
*/
export function describeRejections(
rejected: readonly RecipientRejection[],
): string {
const listed = rejected
.slice(0, LOGGED_REJECTIONS_MAX)
.map(
({ email, smtpStatus }) =>
`${maskEmail(email)}(${smtpStatus ?? '상태 코드 없음'})`,
);
const more = rejected.length - listed.length;
return `받지 않은 사람 ${rejected.length}명: ${listed.join(', ')}${more > 0 ? ` 외 ${more}명` : ''}`;
}
@@ -10,6 +10,7 @@ import type {
import { SseParser } from '@/shared/utils/sse';
import type { JmapEventStream } from './jmap.client';
import { PUSH_STATE_EVENT, isMailStateChange } from './jmap-push';
import { describeFailure } from './jmap.errors';
/**
* 실시간 알림의 메일 엔진 연결 하나 (계약 2절 "실시간 알림") — 직원 한 명에 하나, 그 직원의 모든 클라이언트 연결이 나눠 쓴다(MailEventsService).
@@ -112,24 +113,6 @@ function isRateLimited(error: unknown): boolean {
return error instanceof BusinessException && error.code === 'REQ_001';
}
/**
* 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다).
* 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김)
*/
function describeFailure(error: unknown): string {
if (error instanceof BusinessException) {
const reason: unknown = error.context?.reason;
return typeof reason === 'string' ? `${error.code}/${reason}` : error.code;
}
if (!(error instanceof Error)) return 'unknown';
const { cause } = error as { cause?: unknown };
const causeCode =
cause instanceof Error
? ((cause as NodeJS.ErrnoException).code ?? cause.name)
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */
function timer(callback: () => void, ms: number): NodeJS.Timeout {
const handle = setTimeout(callback, ms);
@@ -32,6 +32,7 @@ import type { JmapInvocation } from './jmap.types';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const SEOJUN = 'seojun.park@arasystem.test';
const MB = 1024 * 1024;
@@ -2193,7 +2194,7 @@ describe('JmapMailProvider', () => {
const outgoing: OutgoingMessage = {
to: [{ name: '이도윤', email: DOYUN }],
cc: [],
bcc: [{ name: null, email: 'seojun.park@arasystem.test' }],
bcc: [{ name: null, email: SEOJUN }],
subject: 'Re: 메일 서버 이전 일정 공유',
body: '도윤님, 확인했습니다.',
attachments: [
@@ -2238,6 +2239,34 @@ describe('JmapMailProvider', () => {
['Email/set', { updated: { maaaaad: null } }, 's'],
];
/** 받는 사람별 결과 (EmailSubmission/get — 제출 다음에 따로 읽는다). 개발 엔진이 돌려준 모양 그대로 (2026-10-28) */
const deliveryOf = (
deliveryStatus: Record<string, Record<string, string>>,
): JmapInvocation[] => [
[
'EmailSubmission/get',
{ state: 'sb1', list: [{ id: 'b', deliveryStatus }], notFound: [] },
'd',
],
];
const QUEUED = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
displayed: 'unknown',
};
const MAILBOX_MISSING = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
displayed: 'unknown',
};
const RATE_LIMITED = {
delivered: 'no',
smtpReply: '452 4.4.5 Rate limit exceeded, try again later.',
displayed: 'unknown',
};
/** 받는 사람 · 숨은참조 모두 대기열에 들어갔다 (받은 것) */
const queued = deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED });
/** 첨부 크기 확인 응답 (Blob/get) */
const blobSizes = (size: number): JmapInvocation[] => [
['Blob/get', { list: [{ id: 'blob-xlsx', size }], notFound: [] }, 'b'],
@@ -2245,10 +2274,11 @@ describe('JmapMailProvider', () => {
const fits = blobSizes(1000);
it('초안 생성 → 같은 요청에서 제출 → 보낸편지함 이동 (검증된 형태)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await expect(provider.sendMessage(ARA, outgoing)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
const draft = (argsOf(recorded[2], 0).create as Record<string, unknown>)
@@ -2293,7 +2323,7 @@ describe('JmapMailProvider', () => {
});
it('서식 본문이 있으면 textBody + htmlBody 로 만든다 — 엔진이 multipart/alternative 로 보낸다', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
const html =
'<div style="font-size:14px"><p><strong>확인</strong>했습니다.</p></div>';
@@ -2312,7 +2342,7 @@ describe('JmapMailProvider', () => {
});
it('서식 본문이 없으면 htmlBody 를 만들지 않는다 (텍스트 메일)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, outgoing);
@@ -2325,7 +2355,7 @@ describe('JmapMailProvider', () => {
});
it('중복 방지 Message-ID 와 내용 지문을 초안에 싣는다 (지문은 사용자 헤더)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, {
...outgoing,
@@ -2341,7 +2371,7 @@ describe('JmapMailProvider', () => {
});
it('첨부 크기는 Blob/get 으로 잰다 (blob 기능은 이 호출에만, 해석 못 하는 id 는 MAIL_004)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, outgoing);
@@ -2359,11 +2389,12 @@ describe('JmapMailProvider', () => {
});
it('첨부가 없으면 크기를 재지 않는다', async () => {
const { provider, recorded } = fakeClient([prepared, sent]);
const { provider, recorded } = fakeClient([prepared, sent, queued]);
await provider.sendMessage(ARA, { ...outgoing, attachments: [] });
expect(recorded).toHaveLength(2);
// 준비 · 보내기 · 받는 사람별 결과
expect(recorded).toHaveLength(3);
});
it('첨부 합계가 설정 한도(25MB)를 넘으면 메일을 만들지 않고 413 FILE_002 — 같은 첨부 두 번은 두 번 센다', async () => {
@@ -2547,6 +2578,7 @@ describe('JmapMailProvider', () => {
],
fits,
sent,
queued,
]);
const warn = jest
.spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
@@ -2561,6 +2593,299 @@ describe('JmapMailProvider', () => {
expect(warn).toHaveBeenCalled();
});
/** 계약 2절 "받는 사람별 결과" (6단계 앞 수정) — 엔진이 그 자리에서 거절한 받는 사람 */
describe('받는 사람별 결과', () => {
// 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
afterEach(() => jest.restoreAllMocks());
const textOnly: OutgoingMessage = { ...outgoing, attachments: [] };
/** 보낸 사본 지우기 응답 */
const discarded: JmapInvocation[] = [
['Email/set', { destroyed: ['maaaaad'] }, 'x'],
];
const everyoneMissing = deliveryOf({
[DOYUN]: MAILBOX_MISSING,
[SEOJUN]: MAILBOX_MISSING,
});
const spyLogger = (provider: JmapMailProvider, level: 'log' | 'warn') =>
jest
.spyOn(
Reflect.get(provider, 'logger') as Record<
'log' | 'warn',
(message: string) => void
>,
level,
)
.mockImplementation(() => undefined);
const logged = (spy: jest.SpyInstance) =>
spy.mock.calls.map(([message]) => String(message)).join('\n');
/** 엔진 호출마다 시간이 흐르게 한다 — n 번째 호출이 끝나면 delays[n - 1] 만큼 */
const elapseOnCalls = (
fake: ReturnType<typeof fakeClient>,
delays: readonly number[],
clock: { now: number },
) => {
const answer = fake.client.call.getMockImplementation();
if (!answer) throw new Error('호출 대역이 없습니다');
fake.client.call.mockImplementation((...args) => {
const reply = answer(...args);
clock.now += delays[fake.recorded.length - 1] ?? 0;
return reply;
});
jest.spyOn(Date, 'now').mockImplementation(() => clock.now);
};
it('제출 다음에 따로 읽는다 — 제출 id 로 deliveryStatus 만, 짧은 제한 시간(5초)으로 (같은 요청 안의 참조는 엔진이 풀지 못한다)', async () => {
const { provider, recorded } = fakeClient([prepared, sent, queued]);
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
expect(recorded[1].calls.map(([name]) => name)).toEqual([
'Email/set',
'EmailSubmission/set',
]);
expect(recorded[2].calls).toEqual([
[
'EmailSubmission/get',
{ accountId: 'd', ids: ['b'], properties: ['deliveryStatus'] },
'd',
],
]);
expect(recorded[2].options).toEqual({ timeoutMs: 5_000 });
expect(recorded).toHaveLength(3);
});
it('일부만 받지 않았으면 보낸 것이다 — 받지 않은 사람만 돌려주고 보낸 사본은 그대로, log 에는 가린 주소와 상태 코드만', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: QUEUED }),
]);
const log = spyLogger(provider, 'log');
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [{ email: DOYUN, reason: 'not-found' }],
});
// 지우지 않았다
expect(recorded).toHaveLength(3);
expect(logged(log)).toContain('받지 않은 사람 1명');
expect(logged(log)).toContain('d***@arasystem.test(550 5.1.2)');
expect(logged(log)).not.toMatch(/doyun|Mailbox does not exist/);
});
it('분류 표 — 5.1.1 · 5.1.2 는 not-found, 5.7.1 은 refused, 452 4.4.5 는 temporary, 250(대기열) · 결과 없음은 받은 것, 대소문자가 다른 주소도 요청에 쓴 그대로', async () => {
const many: OutgoingMessage = {
...textOnly,
to: [
{ name: null, email: 'a@arasystem.test' },
{ name: null, email: 'b@arasystem.test' },
],
cc: [
{ name: null, email: 'c@example.com' },
{ name: null, email: 'd@example.com' },
],
bcc: [
{ name: null, email: 'e@example.com' },
{ name: null, email: 'f@example.com' },
{ name: '김지', email: 'G.Kim@AraSystem.Test' },
],
};
const { provider } = fakeClient([
prepared,
sent,
deliveryOf({
'a@arasystem.test': {
delivered: 'no',
smtpReply: '550 5.1.1 User unknown',
},
'b@arasystem.test': MAILBOX_MISSING,
'c@example.com': {
delivered: 'no',
smtpReply: '554 5.7.1 Relay access denied',
},
'd@example.com': RATE_LIMITED,
'e@example.com': QUEUED,
'g.kim@arasystem.test': MAILBOX_MISSING,
}),
]);
spyLogger(provider, 'log');
await expect(provider.sendMessage(ARA, many)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [
{ email: 'a@arasystem.test', reason: 'not-found' },
{ email: 'b@arasystem.test', reason: 'not-found' },
{ email: 'c@example.com', reason: 'refused' },
{ email: 'd@example.com', reason: 'temporary' },
{ email: 'G.Kim@AraSystem.Test', reason: 'not-found' },
],
});
});
it('아무도 받지 않았으면(5xx 가 섞임) 이 요청이 만든 보낸 사본을 조건 없이 지우고 400 MAIL_010 — warn 에 코드 · 가린 주소 · 상태 코드만', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: RATE_LIMITED }),
discarded,
]);
const warn = spyLogger(provider, 'warn');
const error = await provider
.sendMessage(ARA, textOnly)
.catch((caught: unknown) => caught as BusinessException);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).code).toBe('MAIL_010');
expect((error as BusinessException).context).toEqual({
reason: 'all-recipients-rejected',
rejected: 2,
copyDiscarded: true,
});
// 방금 만든 메일이라 조건(ifInState) 없이 — 만들기와 다른 요청으로
expect(recorded[3].calls).toEqual([
['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
]);
expect(recorded).toHaveLength(4);
const warned = logged(warn);
expect(warned).toContain('[MAIL_010]');
expect(warned).toContain('d***@arasystem.test(550 5.1.2)');
expect(warned).toContain('s***@arasystem.test(452 4.4.5)');
expect(warned).not.toMatch(/doyun|seojun|Mailbox does not exist|Rate/);
});
it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 보낸 사본을 지우는 것은 같다', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: RATE_LIMITED, [SEOJUN]: RATE_LIMITED }),
discarded,
]);
const warn = spyLogger(provider, 'warn');
expect(await codeOf(provider.sendMessage(ARA, textOnly))).toBe(
'MAIL_011',
);
expect(argsOf(recorded[3])).toEqual({
accountId: 'd',
destroy: ['maaaaad'],
});
expect(logged(warn)).toContain('[MAIL_011]');
});
it('같은 주소가 받는 사람과 숨은참조에 함께 있어도 한 사람이다 — 그 사람이 받지 않았으면 모두 거절', async () => {
const { provider } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING }),
discarded,
]);
spyLogger(provider, 'warn');
expect(
await codeOf(
provider.sendMessage(ARA, {
...textOnly,
bcc: [{ name: null, email: DOYUN.toUpperCase() }],
}),
),
).toBe('MAIL_010');
});
it('보낸 사본을 지우지 못해도 MAIL_010 그대로 — 경고만 (보낸편지함에 한 통이 남는다)', async () => {
const { provider } = fakeClient([
prepared,
sent,
everyoneMissing,
[
[
'Email/set',
{ notDestroyed: { maaaaad: { type: 'forbidden' } } },
'x',
],
],
]);
const warn = spyLogger(provider, 'warn');
const error = await provider
.sendMessage(ARA, textOnly)
.catch((caught: unknown) => caught as BusinessException);
expect((error as BusinessException).code).toBe('MAIL_010');
expect((error as BusinessException).context).toMatchObject({
copyDiscarded: false,
});
expect(logged(warn)).toContain(
'받는 사람이 모두 받지 않은 보낸 사본을 지우지 못했습니다 — 보낸편지함에 한 통이 남습니다',
);
});
it('결과를 읽지 못하면(메서드 오류 · 호출 실패 · 제출 id 없음) 받은 것으로 본다 — 빈 배열, 경고 (제출은 이미 되었다)', async () => {
const methodError: JmapInvocation[] = [
['error', { type: 'serverFail' }, 'd'],
];
const withoutId: JmapInvocation[] = [
sent[0],
['EmailSubmission/set', { created: { send: {} } }, 's'],
sent[2],
];
for (const [responses, calls] of [
[[prepared, sent, methodError], 3],
// 준비한 응답이 없으면 대역의 호출이 실패한다
[[prepared, sent], 3],
[[prepared, withoutId], 2],
] as const) {
const { provider, recorded } = fakeClient([...responses]);
const warn = spyLogger(provider, 'warn');
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
expect(recorded).toHaveLength(calls);
expect(logged(warn)).toContain(
'받는 사람별 발송 결과를 읽지 못해 모두 받은 것으로 봅니다',
);
}
});
it('결과 읽기는 발송 전체 시간(55초) 안에서 보낸 사본을 지울 3초를 남기고 — 지우기는 남은 시간으로', async () => {
const fake = fakeClient([prepared, sent, everyoneMissing, discarded]);
// 준비에 20초, 보내기(초안 + 제출)에 31초가 걸렸다 — 남은 시간은 4초
elapseOnCalls(fake, [20_000, 31_000], { now: 1_000_000 });
spyLogger(fake.provider, 'warn');
expect(await codeOf(fake.provider.sendMessage(ARA, textOnly))).toBe(
'MAIL_010',
);
// 보내기 = 55 - 20 = 35초, 결과 읽기 = 4 - 3 = 1초, 지우기 = 남은 4초
expect(fake.recorded[1].options).toMatchObject({ timeoutMs: 35_000 });
expect(fake.recorded[2].options).toEqual({ timeoutMs: 1_000 });
expect(fake.recorded[3].options).toEqual({ timeoutMs: 4_000 });
});
it('결과를 읽을 시간이 남지 않았으면 묻지 않고 받은 것으로 본다 — 경고', async () => {
const fake = fakeClient([prepared, sent]);
// 준비에 20초, 보내기에 33초 — 남은 2초에서 지울 시간 3초를 남기면 읽을 시간이 없다
elapseOnCalls(fake, [20_000, 33_000], { now: 1_000_000 });
const warn = spyLogger(fake.provider, 'warn');
await expect(fake.provider.sendMessage(ARA, textOnly)).resolves.toEqual(
{ id: 'maaaaad', rejectedRecipients: [] },
);
expect(fake.recorded).toHaveLength(2);
expect(logged(warn)).toContain('reason=no-time');
});
});
describe('서명 그림 (inlineImages — multipart/related)', () => {
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const html =
@@ -2599,6 +2924,7 @@ describe('JmapMailProvider', () => {
prepared,
fits,
sent,
queued,
]);
client.upload.mockResolvedValue({
blobId: 'blob-sig',
@@ -2651,7 +2977,11 @@ describe('JmapMailProvider', () => {
});
it('첨부가 없으면 related 가 맨 위다 — 그림은 첨부 합계에 넣지 않는다 (Blob/get 없음)', async () => {
const { provider, recorded, client } = fakeClient([prepared, sent]);
const { provider, recorded, client } = fakeClient([
prepared,
sent,
queued,
]);
client.upload.mockResolvedValue({
blobId: 'blob-sig',
type: 'image/png',
@@ -2660,7 +2990,7 @@ describe('JmapMailProvider', () => {
await provider.sendMessage(ARA, { ...withImage, attachments: [] });
expect(recorded).toHaveLength(2);
expect(recorded).toHaveLength(3);
const draft = (
argsOf(recorded[1], 0).create as Record<
string,
@@ -2674,7 +3004,7 @@ describe('JmapMailProvider', () => {
});
it('서명 그림은 한꺼번에, 짧은 제한 시간으로 올린다 — 일반 업로드의 120초를 쓰지 않는다', async () => {
const { provider, client } = fakeClient([prepared, fits, sent]);
const { provider, client } = fakeClient([prepared, fits, sent, queued]);
let inFlight = 0;
let maxInFlight = 0;
client.upload.mockImplementation(async () => {
@@ -2733,6 +3063,7 @@ describe('JmapMailProvider', () => {
prepared,
fits,
sent,
queued,
]);
client.upload.mockImplementation(() => {
now += 25_000;
@@ -2773,7 +3104,7 @@ describe('JmapMailProvider', () => {
['Email/get', { list }, 'g'],
];
it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다', async () => {
it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다 — 받는 사람별 결과는 읽지 않는다 (지문이 맞을 때 따로)', async () => {
const { provider, recorded } = fakeClient([
mailboxes,
recent([
@@ -2808,6 +3139,7 @@ describe('JmapMailProvider', () => {
'header:X-Ara-Payload-Hash:asText',
],
});
expect(recorded).toHaveLength(2);
});
it('없으면 null', async () => {
@@ -2882,6 +3214,191 @@ describe('JmapMailProvider', () => {
});
});
/** 계약 2절 "받는 사람별 결과" — 같은 키의 재시도가 처음 결과를 돌려줄 때 */
describe('이미 보낸 메일의 받지 않은 사람 (getRejectedRecipients)', () => {
/** 이 요청(같은 키의 재시도)의 받는 사람 */
const RECIPIENTS = {
to: [{ name: '이도윤', email: 'DoYun.Lee@AraSystem.Test' }],
cc: [],
bcc: [{ name: null, email: SEOJUN }],
};
/** 처음 발송의 제출 기록 — 찾기(query)와 읽기(get)를 한 요청으로 */
const submissionsOf = (
deliveryStatus: Record<string, Record<string, string>>,
): JmapInvocation[] => [
['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
['EmailSubmission/get', { list: [{ id: 'sb1', deliveryStatus }] }, 'g'],
];
const QUEUED_REPLY = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
};
const MISSING_REPLY = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
};
it('그 메일의 제출을 찾아(emailIds) 같은 요청에서 결과를 읽고, 이 요청의 받는 사람과 맞춘다 — 요청에 쓴 주소 그대로', async () => {
const { provider, recorded } = fakeClient([
submissionsOf({ [DOYUN]: MISSING_REPLY, [SEOJUN]: QUEUED_REPLY }),
]);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([
{ email: 'DoYun.Lee@AraSystem.Test', reason: 'not-found' },
]);
expect(recorded[0].calls).toEqual([
[
'EmailSubmission/query',
{ accountId: 'd', filter: { emailIds: ['sent1'] } },
'q',
],
[
'EmailSubmission/get',
{
accountId: 'd',
'#ids': {
resultOf: 'q',
name: 'EmailSubmission/query',
path: '/ids',
},
properties: ['deliveryStatus'],
},
'g',
],
]);
});
it('모두 받았으면 빈 배열', async () => {
const { provider } = fakeClient([
submissionsOf({ [DOYUN]: QUEUED_REPLY, [SEOJUN]: QUEUED_REPLY }),
]);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([]);
});
it('제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다, 읽지 못한 것은 경고)', async () => {
const missing: JmapInvocation[] = [
['EmailSubmission/query', { ids: [] }, 'q'],
['EmailSubmission/get', { list: [], notFound: [] }, 'g'],
];
const unresolved: JmapInvocation[] = [
['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
['error', { type: 'invalidResultReference' }, 'g'],
];
const cases: [JmapInvocation[] | null, string | null][] = [
[missing, null],
[unresolved, 'reason=MAIL_001/invalidResultReference'],
// 준비한 응답이 없으면 대역의 호출이 실패한다
[null, 'reason=Error'],
];
for (const [reading, warned] of cases) {
const { provider } = fakeClient(reading ? [reading] : []);
const warn = jest
.spyOn(
Reflect.get(provider, 'logger') as { warn: () => void },
'warn',
)
.mockImplementation(() => undefined);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([]);
if (warned) {
expect(warn).toHaveBeenCalledWith(expect.stringContaining(warned));
} else {
expect(warn).not.toHaveBeenCalled();
}
}
});
it('모두 거절된 사본을 지우지 못한 채 남았으면 — 같은 키 · 같은 내용의 재시도는 그 사본을 찾고(지문이 같다) 받는 사람 전원을 받지 않은 사람으로 돌려준다', async () => {
const outgoing: OutgoingMessage = {
to: [{ name: '이도윤', email: DOYUN }],
cc: [],
bcc: [],
subject: '제목',
body: '본문',
attachments: [],
messageId: 'ara.0123abcd@arasystem.test',
fingerprint: 'v2:feedface',
};
const { provider } = fakeClient([
// 처음 발송 — 모두 거절, 보낸 사본은 지우지 못했다
[
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
[
'Identity/get',
{ list: [{ id: 'b', name: '김아라', email: ARA }] },
'i',
],
],
[
['Email/set', { created: { draft: { id: 'maaaaad' } } }, 'e'],
['EmailSubmission/set', { created: { send: { id: 'b' } } }, 's'],
['Email/set', { updated: { maaaaad: null } }, 's'],
],
[
[
'EmailSubmission/get',
{ list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
'd',
],
],
[['error', { type: 'serverFail' }, 'x']],
// 같은 키의 재시도 — 보낸편지함에 남은 사본을 찾는다
[['Mailbox/get', { list: stalwartMailboxes }, 'm']],
[
['Email/query', { ids: ['maaaaad'] }, 'q'],
[
'Email/get',
{
list: [
{
id: 'maaaaad',
messageId: ['ara.0123abcd@arasystem.test'],
keywords: { $seen: true },
'header:X-Ara-Payload-Hash:asText': 'v2:feedface',
},
],
},
'g',
],
],
[
['EmailSubmission/query', { ids: ['b'] }, 'q'],
[
'EmailSubmission/get',
{ list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
'g',
],
],
]);
jest
.spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
.mockImplementation(() => undefined);
const first = await provider
.sendMessage(ARA, outgoing)
.catch((caught: unknown) => caught as BusinessException);
expect((first as BusinessException).code).toBe('MAIL_010');
expect((first as BusinessException).context).toMatchObject({
copyDiscarded: false,
});
await expect(
provider.findSentMessage(ARA, 'ara.0123abcd@arasystem.test'),
).resolves.toEqual({ id: 'maaaaad', fingerprint: 'v2:feedface' });
await expect(
provider.getRejectedRecipients(ARA, 'maaaaad', outgoing),
).resolves.toEqual([{ email: DOYUN, reason: 'not-found' }]);
});
});
/** 계약 2절 "임시 저장" — 사용자의 임시 저장본 */
describe('임시 저장 (saveDraft)', () => {
const XLSX =
@@ -41,7 +41,10 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
SavedDraft,
SentMessage,
SentMessageRecord,
UploadedFile,
} from '@/modules/mail/mail.types';
@@ -55,6 +58,14 @@ import {
type JmapCallOptions,
type JmapSession,
} from './jmap.client';
import {
DELIVERED_TO_ALL,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
toRejectedRecipients,
type DeliveryResult,
} from './delivery-status';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import {
JmapMailWatch,
@@ -63,6 +74,7 @@ import {
} from './jmap-mail-watch';
import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
@@ -92,6 +104,7 @@ import type {
JmapEmail,
JmapEmailFilter,
JmapEmailFilterCondition,
JmapEmailSubmission,
JmapError,
JmapGetResult,
JmapIdentity,
@@ -208,6 +221,15 @@ const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
const SEND_TOTAL_BUDGET_MS = 55_000;
/** 보내기에 남은 시간이 이보다 적으면 보내기 시작하지 않는다 — 보내다 끊기면 나갔는지 알 수 없다 */
const MIN_SEND_WINDOW_MS = 10_000;
/**
* 제출한 뒤 받는 사람별 결과(EmailSubmission/get 의 deliveryStatus)를 읽는 제한 시간 — 가벼운 조회 하나다 (계약 2절 "받는 사람별 결과").
* 발송 전체 시간 안에서, 모두 거절됐을 때 보낸 사본을 지울 시간(SEND_DISCARD_RESERVE_MS)을 남기고 쓴다
*/
const SEND_RESULT_TIMEOUT_MS = 5_000;
/** 결과 읽기에 쓸 시간이 이보다 적으면 읽지 않고 받은 것으로 본다 — 제출은 이미 되었다 */
const SEND_RESULT_MIN_WINDOW_MS = 1_000;
/** 받는 사람이 모두 거절했을 때 보낸 사본을 지울 시간 — 결과 읽기가 발송 전체 시간에서 이만큼 남겨 둔다 */
const SEND_DISCARD_RESERVE_MS = 3_000;
/** 사용자 초안을 조건부로 지운 결과 */
type DraftDestroyOutcome =
@@ -1787,7 +1809,7 @@ export class JmapMailProvider implements MailProvider {
async sendMessage(
user: string,
message: OutgoingMessage,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const startedAt = Date.now();
const budgetLeft = () => SEND_TOTAL_BUDGET_MS - (Date.now() - startedAt);
@@ -1939,7 +1961,8 @@ export class JmapMailProvider implements MailProvider {
const submitted = submission.ok
? (submission.args as JmapSetResult)
: undefined;
if (!submitted?.created?.send) {
const send = submitted?.created?.send;
if (!send) {
// 보내지 못한 초안이 임시보관함에 남지 않게 지운다
await this.discardDraft(user, draftId);
throw jmapFailure(
@@ -1949,7 +1972,51 @@ export class JmapMailProvider implements MailProvider {
}
this.warnIfNotMovedToSent(responses, user);
return { id: draftId };
// 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 (계약 2절 "받는 사람별 결과")
const delivery = await this.readSendResult(
user,
message,
send.id,
Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS),
);
if (delivery.isEveryoneRejected) {
// 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고
// (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다.
// 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다
const code = everyoneRejectedCode(delivery.rejected);
this.logger.warn(
`[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
);
const discarded = await this.discardQuietly(
user,
draftId,
'받는 사람이 모두 받지 않은 보낸 사본',
{
remains: '보낸편지함에 한 통이 남습니다',
timeoutMs: Math.max(
1,
Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()),
),
},
);
throw new BusinessException(code, {
context: {
reason: 'all-recipients-rejected',
rejected: delivery.rejected.length,
copyDiscarded: discarded,
},
});
}
if (delivery.rejected.length > 0) {
this.logger.log(
`${traceTag()} 엔진이 받는 사람 일부를 받지 않았습니다 — 나머지에게는 보냈습니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
);
}
return {
id: draftId,
rejectedRecipients: toRejectedRecipients(delivery.rejected),
};
}
async findSentMessage(
@@ -2030,6 +2097,52 @@ export class JmapMailProvider implements MailProvider {
};
}
/**
* 이미 보낸 메일의 받지 않은 사람 — 같은 키의 재시도가 처음 결과를 돌려줄 때 (계약 2절 "받는 사람별 결과").
* 그 메일의 제출 기록을 찾아(EmailSubmission/query 의 emailIds) 같은 요청에서 결과를 읽고(get — #ids 참조, 발송 요청과 달리
* 엔진이 덧붙이는 응답이 없다) 이 요청의 받는 사람과 맞춘다. 제출 기록이 없거나 읽지 못하면 빈 배열 (읽지 못한 것은 경고).
* 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다
*/
async getRejectedRecipients(
user: string,
sentId: string,
recipients: RecipientFields,
): Promise<RejectedRecipient[]> {
try {
const responses = await this.jmap.call(user, (accountId) => [
[
'EmailSubmission/query',
{ accountId, filter: { emailIds: [sentId] } },
'q',
],
[
'EmailSubmission/get',
{
accountId,
'#ids': {
resultOf: 'q',
name: 'EmailSubmission/query',
path: '/ids',
},
properties: ['deliveryStatus'],
},
'g',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
'g',
'EmailSubmission/get',
);
return toRejectedRecipients(
readDeliveryResult(recipients, list).rejected,
);
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
return [];
}
}
async uploadAttachment(
user: string,
file: UploadedFile,
@@ -2946,6 +3059,58 @@ export class JmapMailProvider implements MailProvider {
);
}
/**
* 방금 한 제출의 받는 사람별 결과 (계약 2절 "받는 사람별 결과") — 제출 응답(created.send)에는 없어 제출 다음에 따로 묻는다.
* 발송 요청 안에서 참조(#ids)로 읽지 않는 이유: onSuccessUpdateEmail 이 같은 호출 id 로 Email/set 응답을 덧붙여
* 엔진이 참조를 풀지 못한다 (invalidResultReference — Stalwart 0.16.27, 2026-10-28 확인).
* 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고).
* @param timeoutMs 쓸 수 있는 시간 — SEND_RESULT_MIN_WINDOW_MS 보다 적으면 묻지 않는다
*/
private async readSendResult(
user: string,
recipients: RecipientFields,
submissionId: string | undefined,
timeoutMs: number,
): Promise<DeliveryResult> {
if (!submissionId || timeoutMs < SEND_RESULT_MIN_WINDOW_MS) {
this.warnDeliveryUnknown(
user,
submissionId ? `no-time(${Math.max(0, timeoutMs)}ms)` : 'no-id',
);
return DELIVERED_TO_ALL;
}
try {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'EmailSubmission/get',
{ accountId, ids: [submissionId], properties: ['deliveryStatus'] },
'd',
],
],
{ timeoutMs },
);
const { list = [] } = expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
'd',
'EmailSubmission/get',
);
return readDeliveryResult(recipients, list);
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
return DELIVERED_TO_ALL;
}
}
/** 받는 사람별 결과를 모른다 — 경고만 (메일은 이미 제출했다, 받은 것으로 본다) */
private warnDeliveryUnknown(user: string, reason: string): void {
this.logger.warn(
`[MAIL_001]${traceTag()} 받는 사람별 발송 결과를 읽지 못해 모두 받은 것으로 봅니다 — 제출은 되었습니다: user=${maskEmail(user)}, reason=${reason}`,
);
}
/** 이 메일로 만든 제출(EmailSubmission)이 있는가 */
private async wasSubmitted(user: string, emailId: string): Promise<boolean> {
const responses = await this.jmap.call(user, (accountId) => [
@@ -3100,7 +3265,7 @@ export class JmapMailProvider implements MailProvider {
outcome = await this.destroyUserDraft(user, oldId, draftsId, null);
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 옛 임시 저장본을 지우지 못해 두 통이 남을 수 있습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} 옛 임시 저장본을 지우지 못해 두 통이 남을 수 있습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
return;
}
@@ -3198,23 +3363,38 @@ export class JmapMailProvider implements MailProvider {
);
}
/** 메일을 지운다 — 실패해도 던지지 않고 경고만 남긴다 (보정용) */
/**
* 이 요청이 방금 만든 메일을 지운다 — 실패해도 던지지 않고 경고만 남긴다 (보정용).
* 조건 없이 지운다 — 방금 만든 메일이라 사용자가 아직 모른다 (사용자 메일의 영구 삭제는 조건부 지우기 — EmailSetChanges)
* @param what 로그 문장의 대상 — '방금 만든 임시 저장본' 등
* @param options.remains 지우지 못했을 때 남는 곳 (로그 문장) · options.timeoutMs 제한 시간 (기본 10초)
* @returns 지웠거나 이미 없으면 true
*/
private async discardQuietly(
user: string,
id: string,
what: string,
): Promise<void> {
options: { readonly remains?: string; readonly timeoutMs?: number } = {},
): Promise<boolean> {
try {
const responses = await this.jmap.call(user, (accountId) => [
['Email/set', { accountId, destroy: [id] }, 'x'],
]);
const responses = await this.jmap.call(
user,
(accountId) => [['Email/set', { accountId, destroy: [id] }, 'x']],
options.timeoutMs === undefined
? undefined
: { timeoutMs: options.timeoutMs },
);
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
const failure = firstSetError(set.notDestroyed);
if (failure && failure.type !== 'notFound') throw new Error(failure.type);
if (failure && failure.type !== NOT_FOUND) {
throw jmapFailure(failure, { method: 'Email/set' });
}
return true;
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} ${what}을 지우지 못했습니다 — 임시보관함에 한 통이 더 남습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} ${what}을 지우지 못했습니다 — ${options.remains ?? '임시보관함에 한 통이 더 남습니다'}: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
return false;
}
}
@@ -3294,10 +3474,11 @@ export class JmapMailProvider implements MailProvider {
['Email/set', { accountId, destroy: [draftId] }, 'x'],
]);
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
if (firstSetError(set.notDestroyed)) throw new Error('notDestroyed');
const failure = firstSetError(set.notDestroyed);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
}
}
@@ -1,4 +1,6 @@
import { BusinessException } from '@/common/errors/business.exception';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
@@ -101,3 +103,47 @@ describe('JMAP 오류 매핑', () => {
expect(isMissingAttachmentBlob(undefined)).toBe(false);
});
});
describe('로그용 실패 요약 (describeFailure)', () => {
it('코드가 있는 실패는 코드와 사유 — HTTP 상태 · JMAP 오류 종류가 있으면 함께 (설명 문구 · 주소는 넣지 않는다)', () => {
expect(
describeFailure(
new BusinessException('MAIL_001', { context: { reason: 'network' } }),
),
).toBe('MAIL_001/network');
expect(
describeFailure(
new BusinessException('MAIL_001', {
context: { reason: 'http-error', call: 'api', status: 502 },
}),
),
).toBe('MAIL_001/http-error/502');
// 메서드 오류 · SetError (jmapFailure) — 사유 없이 종류만 있다
expect(
describeFailure(jmapFailure({ type: 'serverFail' }, { method: 'x' })),
).toBe('MAIL_001/serverFail');
expect(
describeFailure(
jmapFailure(
{
type: 'forbidden',
description: 'someone@example.net may not be destroyed',
},
{ method: 'Email/set' },
),
),
).toBe('AUTH_003/forbidden');
expect(describeFailure(new BusinessException('REQ_001'))).toBe('REQ_001');
});
it('코드가 없는 실패는 오류 이름과 원인 코드 — 오류가 아니면 unknown', () => {
const socket = Object.assign(new Error('other side closed'), {
code: 'UND_ERR_SOCKET',
});
expect(
describeFailure(new TypeError('terminated', { cause: socket })),
).toBe('TypeError/UND_ERR_SOCKET');
expect(describeFailure(new Error('준비한 응답이 없습니다'))).toBe('Error');
expect(describeFailure('문자열')).toBe('unknown');
});
});
@@ -72,3 +72,28 @@ export function jmapFailure(
},
});
}
/**
* 로그용 실패 요약 — 코드와 원인만 (엔진의 설명 문구 · 주소는 넣지 않는다).
* 코드가 있는 실패는 `코드/사유/HTTP 상태/JMAP 오류 종류` 가운데 있는 것 — 예: MAIL_001/timeout · MAIL_001/http-error/502 ·
* MAIL_001/serverFail(메서드 오류 · SetError — jmapFailure 의 jmapType) · AUTH_003/forbidden.
* 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김)
*/
export function describeFailure(error: unknown): string {
if (error instanceof BusinessException) {
const { reason, status, jmapType } = error.context ?? {};
return [error.code, reason, status, jmapType]
.filter(
(part): part is string | number =>
(typeof part === 'string' && part !== '') || typeof part === 'number',
)
.join('/');
}
if (!(error instanceof Error)) return 'unknown';
const { cause } = error as { cause?: unknown };
const causeCode =
cause instanceof Error
? ((cause as NodeJS.ErrnoException).code ?? cause.name)
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
@@ -117,6 +117,26 @@ export interface JmapIdentity {
email?: string;
}
/**
* RFC 8621 §7 EmailSubmission 의 받는 사람 하나의 결과 (deliveryStatus 의 값).
* Stalwart 0.16.27 예 (2026-10-28 확인): 거절 `{ delivered: "no", smtpReply: "550 5.1.2 Mailbox does not exist." }`,
* 대기열에 들어감 `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }`
*/
export interface JmapDeliveryStatus {
/** 받는 쪽(엔진의 SMTP)이 마지막으로 한 응답 — 글에 주소가 들어 있을 수 있어 로그에는 상태 코드만 남긴다 */
smtpReply?: string | null;
/** queued · yes · no · unknown — "no" 면 이 사람에게 가지 않는다 */
delivered?: string | null;
displayed?: string | null;
}
/** RFC 8621 §7 EmailSubmission 중 이 어댑터가 읽는 부분 */
export interface JmapEmailSubmission {
id: string;
/** 받는 사람 주소 → 결과 (엔진이 모르면 null) */
deliveryStatus?: Record<string, JmapDeliveryStatus | null> | null;
}
export interface JmapGetResult<T> {
/** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */
state?: string;
+12 -3
View File
@@ -64,9 +64,18 @@ export const MAX_BULK_MESSAGE_IDS = 100;
*/
export const MAX_MAILBOX_NAME_LENGTH = 64;
/** 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"). 켤 때는 둘 다 1자 이상 */
export const MAX_VACATION_SUBJECT_LENGTH = 200;
export const MAX_VACATION_TEXT_LENGTH = 4_000;
/**
* 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거"). 켤 때는 둘 다 1자 이상.
* - 켤 때: 150자 · 600자 — 서비스가 본다(400 VAL_001). 엔진은 UTF-8 **바이트**로 막는다 — 제목 511바이트 · 내용 2,047바이트
* (Stalwart 0.16.27, 2026-10-28 확인 — 넘으면 invalidProperties). JS 문자열 한 자(UTF-16 코드 단위)는 많아야 3바이트라
* (한글 3 · 이모지는 2자에 4바이트) 150자 · 600자면 어떤 글이어도 넘지 않는다
* - 꺼 둔 채로: 4단계 한도 200자 · 4,000자까지 — 요청 검증이 늘 본다(..._WHEN_OFF). 4단계에 엔진 한도 안으로 저장해 둔 긴 글
* (영문 내용 1,000자 등)이 켜져 있어도 끌 수 있게 — 화면은 꺼 두면 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 한다
*/
export const MAX_VACATION_SUBJECT_LENGTH = 150;
export const MAX_VACATION_TEXT_LENGTH = 600;
export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200;
export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000;
/**
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
+17 -2
View File
@@ -24,7 +24,10 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
SavedDraft,
SentMessage,
SentMessageRecord,
UploadedFile,
} from './mail.types';
@@ -172,11 +175,13 @@ export interface MailProvider {
): Promise<MessageDeletionResult>;
/**
* @returns 보낸편지함에 남은 메일 id
* @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다)
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
* 받는 사람을 엔진이 모두 받지 않았으면 보내지 않은 것으로 다룬다 — 이 요청이 만든 보낸 사본을 지우고
* MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음)
*/
sendMessage(user: string, message: OutgoingMessage): Promise<{ id: string }>;
sendMessage(user: string, message: OutgoingMessage): Promise<SentMessage>;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
@@ -187,6 +192,16 @@ export interface MailProvider {
messageId: string,
): Promise<SentMessageRecord | null>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
*/
getRejectedRecipients(
user: string,
sentId: string,
recipients: RecipientFields,
): Promise<RejectedRecipient[]>;
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
/** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */
+231 -21
View File
@@ -11,11 +11,17 @@ import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { DRAFT_CLEANUP_WAIT_MS, MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { DraftSource, MessageDetailSource } from './mail.types';
import type {
DraftSource,
MessageDetailSource,
SentMessage,
} from './mail.types';
import type { RecipientSuggestionService } from './recipient-suggestions.service';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
@@ -46,9 +52,12 @@ function mockOf<K extends keyof MailProvider>(): ProviderMocks[K] {
return jest.fn();
}
/** 대역 엔진의 발송 결과 — 모두 받았다 */
const SENT = { id: 'sent-1', rejectedRecipients: [] };
function fakeProvider(): ProviderMocks {
const sendMessage = mockOf<'sendMessage'>();
sendMessage.mockResolvedValue({ id: 'sent-1' });
sendMessage.mockResolvedValue(SENT);
return {
verifyCredentials: mockOf<'verifyCredentials'>(),
@@ -76,6 +85,9 @@ function fakeProvider(): ProviderMocks {
deleteFromTrash: mockOf<'deleteFromTrash'>(),
sendMessage,
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
getRejectedRecipients: mockOf<'getRejectedRecipients'>().mockResolvedValue(
[],
),
uploadAttachment: mockOf<'uploadAttachment'>(),
openAttachment: mockOf<'openAttachment'>(),
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
@@ -709,6 +721,58 @@ describe('MailService', () => {
]);
});
it(`켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 — 넘으면 VAL_001(엔진의 바이트 한도), 꺼 둔 채로는 저장한다 (계약 2절 "길이 한도의 근거")`, async () => {
for (const long of [
{ subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
{ textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
// JS 문자열 길이로 센다 — 이모지 하나는 2자
{ textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x` },
]) {
expect(
await failureOf(() =>
service.updateVacation(user, { ...vacation, ...long }),
),
).toEqual({
code: 'VAL_001',
reason: 'vacation-enabled-text-too-long',
});
}
expect(provider.setVacation).not.toHaveBeenCalled();
// 로그 사유에는 길이만 (제목 · 내용 없이)
const error = await service
.updateVacation(user, {
...vacation,
subject: 's'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
})
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).context).toEqual({
reason: 'vacation-enabled-text-too-long',
subjectLength: MAX_VACATION_SUBJECT_LENGTH + 1,
textLength: vacation.textBody.length,
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
);
// 한도까지는 켜도 받는다
const full = {
...vacation,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
await expect(service.updateVacation(user, full)).resolves.toEqual(full);
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
const off = {
...vacation,
isEnabled: false,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(1_000),
};
await expect(service.updateVacation(user, off)).resolves.toEqual(off);
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
it('날짜는 초 단위로 맞춰 넘기고, 순서도 맞춘 값으로 본다 — 같은 초 안의 두 시각은 VAL_001', async () => {
expect(
await failureOf(() =>
@@ -1544,19 +1608,21 @@ describe('MailService', () => {
),
).toBe('MAIL_007');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
expect(provider.findSentMessage).toHaveBeenCalledTimes(1);
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
});
it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => {
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
const messageId = idempotentMessageId(user.email, IDEMPOTENCY_KEY);
expect(provider.findSentMessage).toHaveBeenCalledWith(
user.email,
messageId,
);
// 처음 보내는 키라 받는 사람별 결과를 다시 만들 것이 없다
expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.messageId).toBe(messageId);
// 지문 키는 이 요청의 Idempotency-Key — 서버 비밀이 아니다 (v2)
@@ -1579,10 +1645,68 @@ describe('MailService', () => {
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
await expect(send()).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('처음 결과를 돌려줄 때 받지 않은 사람은 그 메일의 제출 기록으로 다시 만든다 — 이 요청의 받는 사람과 맞추고, 지문은 응답에 싣지 않는다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
const rejectedRecipients = [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.getRejectedRecipients.mockResolvedValue(rejectedRecipients);
const result = await send();
expect(result).toEqual({ id: 'sent-1', rejectedRecipients });
expect(result).not.toHaveProperty('fingerprint');
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.getRejectedRecipients).toHaveBeenCalledWith(
user.email,
'sent-1',
expect.objectContaining({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
cc: [],
bcc: [],
}),
);
});
it('지우지 못해 남은 모두 거절 사본 — 같은 키 · 같은 내용의 재시도는 그것을 찾아 201 + 받는 사람 전원을 받지 않은 사람으로 (다시 보내지 않는다, 이어 쓴 저장본은 지운다)', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
const everyone = [
{ email: 'doyun.lee@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.getRejectedRecipients.mockResolvedValue(everyone);
await expect(
service.sendMessage(
user,
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1', rejectedRecipients: everyone });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('내용이 달라 MAIL_005 로 끝나면 처음 발송의 결과를 읽지 않는다 (제출 기록을 헛되이 읽지 않게)', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(await codeOf(() => send(sendDto({ subject: '고친 제목' })))).toBe(
'MAIL_005',
);
expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
});
it('지문에는 정화한 서식 본문이 들어간다 — 정화 뒤가 같으면 같은 내용, 서식이 바뀌면 MAIL_005', async () => {
@@ -1593,12 +1717,15 @@ describe('MailService', () => {
expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
// 정화로 지워지는 부분만 다르면 같은 메일이다
await expect(
send(sendDto({ bodyHtml: `${html}<script>x()</script>` })),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
// 서식이 바뀌거나(굵게 → 기울임) 서식 본문을 뺀 텍스트 메일은 다른 내용이다
for (const changed of [
sendDto({ bodyHtml: '<p><em>확인</em>했습니다.</p>' }),
@@ -1612,7 +1739,10 @@ describe('MailService', () => {
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
for (const changed of [
sendDto({ subject: '고친 제목' }),
@@ -1635,7 +1765,7 @@ describe('MailService', () => {
});
it('같은 키의 발송이 처리 중이면 기다리지 않고 429 REQ_001 — 끝나면 다시 받는다', async () => {
let finish!: (value: { id: string }) => void;
let finish!: (value: SentMessage) => void;
provider.sendMessage.mockImplementationOnce(
() => new Promise((resolve) => (finish = resolve)),
);
@@ -1648,11 +1778,14 @@ describe('MailService', () => {
// 다른 키·다른 사용자는 막지 않는다
await expect(
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
finish({ id: 'sent-first' });
await expect(first).resolves.toEqual({ id: 'sent-first' });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
finish({ id: 'sent-first', rejectedRecipients: [] });
await expect(first).resolves.toEqual({
id: 'sent-first',
rejectedRecipients: [],
});
await expect(send()).resolves.toEqual(SENT);
});
it('엔진 장애(MAIL_001)로 끝나면 엔진이 아직 처리 중일 수 있어 30초 동안 같은 키를 REQ_001 로 막는다', async () => {
@@ -1666,7 +1799,7 @@ describe('MailService', () => {
expect(await codeOf(() => send())).toBe('REQ_001');
jest.advanceTimersByTime(30_000);
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
} finally {
jest.useRealTimers();
}
@@ -1678,16 +1811,44 @@ describe('MailService', () => {
);
expect(await codeOf(() => send())).toBe('MAIL_004');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
});
it.each(['MAIL_010', 'MAIL_011'] as const)(
'받는 사람 모두 거절(%s)은 같은 키를 잠가 두지 않는다 — 아무것도 나가지 않았다, 곧바로 같은 키로 다시 보내면 새로 보낸다',
async (code) => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
provider.sendMessage.mockRejectedValueOnce(
new BusinessException(code, {
context: { reason: 'all-recipients-rejected' },
}),
);
expect(await codeOf(() => send())).toBe(code);
// 시간을 넘기지 않아도 처리 중(REQ_001)이 아니다 — 보낸 사본은 어댑터가 지웠다 (찾아도 없다)
await expect(
send(
sendDto({
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
}),
),
).resolves.toEqual(SENT);
expect(provider.sendMessage).toHaveBeenCalledTimes(2);
expect(provider.findSentMessage).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
},
);
it('이미 보낸 메일을 찾는 중 엔진 장애면 잠그지 않는다 (보낸 것이 없다)', async () => {
provider.findSentMessage.mockRejectedValueOnce(
new BusinessException('MAIL_001'),
);
expect(await codeOf(() => send())).toBe('MAIL_001');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
});
});
@@ -2027,7 +2188,7 @@ describe('MailService', () => {
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
await expect(
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
@@ -2059,7 +2220,7 @@ describe('MailService', () => {
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
@@ -2088,7 +2249,7 @@ describe('MailService', () => {
user,
sendDto({ subject: '비밀 제목', draftId: 'draft-1' }),
),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
const warned = warnSpy.mock.calls
.map(([message]) => String(message))
@@ -2111,6 +2272,55 @@ describe('MailService', () => {
expect(suggestions.invalidate).not.toHaveBeenCalled();
});
it.each(['MAIL_010', 'MAIL_011'] as const)(
'받는 사람을 엔진이 모두 받지 않았으면(%s) 보내지 않은 것이다 — 이어 쓴 저장본을 지우지 않는다 (키가 있어도 없어도)',
async (code) => {
provider.sendMessage.mockRejectedValue(
new BusinessException(code, {
context: { reason: 'all-recipients-rejected' },
}),
);
for (const key of [undefined, IDEMPOTENCY_KEY]) {
expect(
await codeOf(() =>
service.sendMessage(user, sendDto({ draftId: 'draft-1' }), key),
),
).toBe(code);
}
expect(provider.deleteDraft).not.toHaveBeenCalled();
expect(suggestions.invalidate).not.toHaveBeenCalled();
},
);
it('일부만 받지 않았으면 보낸 것이다 — 그 결과를 그대로 돌려주고 이어 쓴 저장본을 지운다, 발송 로그에는 수만', async () => {
const partial = {
id: 'sent-1',
rejectedRecipients: [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
],
};
provider.sendMessage.mockResolvedValue(partial);
await expect(
service.sendMessage(
user,
sendDto({
draftId: 'draft-1',
cc: [{ name: null, email: 'nobody@arasystem.test' }],
}),
),
).resolves.toEqual(partial);
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
const logged = logSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('받는 사람 2명');
expect(logged).toContain('받지 않은 사람 1명');
expect(logged).not.toContain('nobody');
});
it(`초안 지우기는 ${DRAFT_CLEANUP_WAIT_MS}ms 까지만 기다린다 — 넘으면 먼저 답하고, 뒤에서 끝난 결과는 로그로만 남긴다`, async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
@@ -2130,7 +2340,7 @@ describe('MailService', () => {
await new Promise((resolve) => setImmediate(resolve));
jest.advanceTimersByTime(DRAFT_CLEANUP_WAIT_MS);
await expect(sending).resolves.toEqual({ id: 'sent-1' });
await expect(sending).resolves.toEqual(SENT);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'보낸 메일의 임시 저장본 지우기가 5000ms 안에 끝나지 않아 먼저 답합니다',
+48 -10
View File
@@ -58,6 +58,8 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
@@ -85,6 +87,7 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
SavedDraft,
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { RecipientSuggestionService } from './recipient-suggestions.service';
@@ -117,6 +120,12 @@ function toAddress(recipient: RecipientDto): MailAddress {
return { name: name || null, email: recipient.email.trim() };
}
/** 발송 로그 끝에 붙이는 받지 않은 사람 수 — 없으면 빈 문자열 (주소는 남기지 않는다) */
function rejectedSuffix(result: SentMessage): string {
const count = result.rejectedRecipients.length;
return count > 0 ? `, 받지 않은 사람 ${count}명` : '';
}
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
@@ -363,8 +372,10 @@ export class MailService {
* 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다.
* - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다).
* 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게
* - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 —
* 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거")
* - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
*/
async updateVacation(
user: AuthenticatedUser,
@@ -385,6 +396,20 @@ export class MailService {
context: { reason: 'vacation-enabled-without-text' },
});
}
// 길이는 JS 문자열 길이 — 요청 검증(MaxCodeUnits)과 같게 센다. 로그에는 길이만 (제목 · 내용 없이)
if (
vacation.isEnabled &&
(vacation.subject.length > MAX_VACATION_SUBJECT_LENGTH ||
vacation.textBody.length > MAX_VACATION_TEXT_LENGTH)
) {
throw new BusinessException('VAL_001', {
context: {
reason: 'vacation-enabled-text-too-long',
subjectLength: vacation.subject.length,
textLength: vacation.textBody.length,
},
});
}
if (
vacation.fromDate !== null &&
vacation.toDate !== null &&
@@ -562,11 +587,14 @@ export class MailService {
/**
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 결과(id · 받지 않은 사람)를 돌려준다
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
*
* 받는 사람별 결과 (계약 2절 "받는 사람별 결과"): 일부만 받지 않았으면 보낸 것이다 — 201 에 rejectedRecipients 를 싣는다.
* 모두 받지 않았으면 어댑터가 보낸 사본을 지우고 MAIL_010 · MAIL_011 로 던진다 — 보내지 않은 것이라 아래 "보낸 뒤"를 하지 않는다.
*
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
*/
@@ -574,7 +602,7 @@ export class MailService {
user: AuthenticatedUser,
dto: SendMessageDto,
idempotencyKey?: string,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const parsed = parseIdempotencyKey(idempotencyKey);
if (!parsed.ok) {
throw new BusinessException('VAL_001', {
@@ -661,7 +689,7 @@ export class MailService {
user: AuthenticatedUser,
message: OutgoingMessage,
key: string,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const lockKey = `${user.email.toLowerCase()}\n${key}`;
if (this.sendsInProgress.has(lockKey)) {
throw new BusinessException('REQ_001', {
@@ -693,16 +721,26 @@ export class MailService {
},
});
}
// 처음 발송의 받는 사람별 결과 — 같은 내용일 때만 읽는다 (제출 기록을 이 요청의 받는 사람과 맞춘다, 읽지 못하면 빈 배열)
const result: SentMessage = {
id: previous.id,
rejectedRecipients: await this.provider.getRejectedRecipients(
user.email,
previous.id,
message,
),
};
this.logger.log(
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}`,
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`,
);
return { id: previous.id };
return result;
}
try {
return await this.deliver(user, { ...message, messageId, fingerprint });
} catch (error) {
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다.
// 받는 사람 모두 거절(MAIL_010 · MAIL_011)은 막지 않는다 — 아무것도 나가지 않았고 보낸 사본도 지웠다 (다시 보내면 새로 보낸다)
holdLock =
error instanceof BusinessException && error.code === 'MAIL_001';
throw error;
@@ -944,14 +982,14 @@ export class MailService {
private async deliver(
user: AuthenticatedUser,
message: OutgoingMessage,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const result = await this.provider.sendMessage(user.email, message);
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md)
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
this.logger.log(
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}`,
);
return result;
}
+31 -1
View File
@@ -338,6 +338,9 @@ export interface OutgoingMessage {
readonly fingerprint?: string;
}
/** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */
export type RecipientFields = Pick<OutgoingMessage, 'to' | 'cc' | 'bcc'>;
/**
* 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/api/mail-signature/images/{id}` 주소 그대로 둔다.
* 발송 중복 방지용 Message-ID·지문은 넣지 않는다 (계약 2절 "임시 저장").
@@ -416,7 +419,34 @@ export interface RecipientSuggestion {
readonly source: 'directory' | 'recent';
}
/** 같은 Message-ID 로 이미 발송한 메일 */
/**
* 엔진이 받지 않은 까닭 (계약 2절 "받는 사람별 결과") — SMTP 응답으로 나눈다.
* not-found 없는 주소(확장 상태 5.1.x) · refused 받는 쪽이 거절(그 밖의 5xx) · temporary 지금은 받을 수 없음(4xx — 한도 등)
*/
export type RecipientRejectionReason = 'not-found' | 'refused' | 'temporary';
/** 엔진이 받지 않은 받는 사람 하나 */
export interface RejectedRecipient {
/** 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조) */
readonly email: string;
readonly reason: RecipientRejectionReason;
}
/**
* 발송 결과 (POST /api/messages 의 201 SentMessage — 계약 2절 "받는 사람별 결과").
* 받은 사람이 아무도 없으면 결과가 아니라 실패다 — 어댑터가 보낸 사본을 지우고 MAIL_010 · MAIL_011 로 던진다
*/
export interface SentMessage {
/** 보낸편지함의 메일 id */
readonly id: string;
/** 엔진이 받지 않은 사람 — 없으면 빈 배열. 같은 주소는 한 번만, 요청의 받는 사람 · 참조 · 숨은참조 순서 */
readonly rejectedRecipients: RejectedRecipient[];
}
/**
* 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다
* (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게)
*/
export interface SentMessageRecord {
/** 보낸편지함의 메일 id */
readonly id: string;
+10 -2
View File
@@ -37,7 +37,13 @@ import {
RenameMailboxDto,
} from './dto/mailbox.dto';
import { MailIdPipe } from './mail-id.pipe';
import { MAX_MAILBOX_NAME_LENGTH } from './mail.constants';
import {
MAX_MAILBOX_NAME_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from './mail.constants';
import { MailService } from './mail.service';
/** 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 안내 — 계약 2절 "메일함 관리" */
@@ -208,6 +214,8 @@ export class MailVacationController {
summary: '부재중 자동 응답 저장 (전체 교체)',
description:
'다섯 필드를 모두 보낸다(날짜는 null 가능). 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상(저장은 쓴 그대로), ' +
`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지(엔진의 바이트 한도 안). ` +
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 엔진이 저장한 값을 다시 읽어 돌려준다.',
@@ -215,7 +223,7 @@ export class MailVacationController {
@ApiStandardResponse(MailVacationDto, { description: '저장한 설정' })
@ApiErrorResponse(
'VAL_001',
'형식 · 길이(제목 200자 · 내용 4,000자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드',
`형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드`,
)
update(@CurrentUser() user: AuthenticatedUser, @Body() dto: MailVacationDto) {
return this.mail.updateVacation(user, dto);
+14 -3
View File
@@ -198,8 +198,11 @@ export class MessageController {
summary:
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
description:
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id, rejectedRecipients } — 받지 않은 사람은 처음 발송의 제출 기록으로 다시 만든다)를 돌려준다. ' +
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
'엔진은 받는 사람마다 그 자리에서 받을지 답한다 — 일부만 받지 않았으면 보낸 것이다(201 에 rejectedRecipients, draftId 는 지운다). ' +
'아무도 받지 않았으면 보내지 않은 것으로 다룬다 — 보낸 사본을 지우고 draftId 는 그대로 둔다. 거절 가운데 5xx(없는 주소 · 거절)가 하나라도 있으면 400 MAIL_010, ' +
'모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 어느 쪽이든 같은 키로 다시 보내면 새로 보낸다. ' +
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
'보내는 사람 자신의 서명 그림(<img src="/api/mail-signature/images/{id}">)만 남겨 메일 안에 함께 싣는다(multipart/related, cid:). ' +
'draftId(이어 쓴 임시 저장본)가 있으면 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 그 초안을 지운다(지우지 못해도 발송은 성공). ' +
@@ -217,7 +220,7 @@ export class MessageController {
@ApiStandardResponse(SentMessageDto, {
status: HttpStatus.CREATED,
description:
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id',
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id 와 엔진이 받지 않은 사람(없으면 빈 배열)',
})
@ApiErrorResponse(
'MAIL_002',
@@ -227,6 +230,10 @@ export class MessageController {
'MAIL_007',
`MAIL_007 본문이 너무 김 (400) — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이), bodyHtml 의 정화 전 비용·정화 뒤 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. 메일은 나가지 않았다`,
)
@ApiErrorResponse(
'MAIL_010',
'MAIL_010 받는 사람을 엔진이 모두 받지 않았다 — 그중 5xx(없는 주소 · 거절)가 있다 (400). 보낸 사본을 지웠다 — 주소를 고쳐 같은 키로 다시 보내면 새로 보낸다',
)
@ApiErrorResponse(
'MAIL_005',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
@@ -241,7 +248,11 @@ export class MessageController {
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
'MAIL_001 메일 엔진 장애 (503) — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
)
@ApiErrorResponse(
'MAIL_011',
'MAIL_011 받는 사람을 엔진이 모두 지금은 받지 않았다 — 모두 4xx(한도 등) (503). 보낸 사본을 지웠다 — 잠시 뒤 같은 키로 다시 보내면 새로 보낸다',
)
send(
@CurrentUser() user: AuthenticatedUser,
+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 머리말).
@@ -22,6 +22,8 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
'MAIL_009': '같은 이름의 메일함이 이미 있습니다.',
'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -22,6 +22,8 @@ export const ERROR_CODE_MESSAGES = {
MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
MAIL_009: '같은 이름의 메일함이 이미 있습니다.',
MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES