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:
@@ -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;
|
||||
|
||||
@@ -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절)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 안에 끝나지 않아 먼저 답합니다',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user