diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index dd40df6..a419aa1 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -54,6 +54,11 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT); }); + it('받는 사람을 엔진이 모두 받지 않은 발송 — 5xx 가 섞이면 400 MAIL_010, 모두 4xx(지금은 받을 수 없음)면 503 MAIL_011', () => { + expect(ERROR_CODES.MAIL_010.status).toBe(HttpStatus.BAD_REQUEST); + expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE); + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index d372698..3e04738 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -137,6 +137,17 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.CONFLICT, message: '같은 이름의 메일함이 이미 있습니다.', }, + // 발송했지만 엔진이 받는 사람을 모두 거절했다 — 그중 하나라도 5xx(없는 주소 · 거절). 아무에게도 가지 않아 보낸 사본을 지웠다 (계약 2절 "받는 사람별 결과") + MAIL_010: { + status: HttpStatus.BAD_REQUEST, + message: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', + }, + // 발송했지만 엔진이 받는 사람을 모두 4xx(지금은 받을 수 없음 — 한도 등)로 거절했다. 보낸 사본을 지웠다 — 같은 키로 다시 보내면 된다 + MAIL_011: { + status: HttpStatus.SERVICE_UNAVAILABLE, + message: + '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index f1d2a9f..d5b4fef 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -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 */ diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts index baf773f..8005290 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts @@ -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중', diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts index 418335d..540d57d 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts @@ -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: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.', diff --git a/backend/src/modules/mail/jmap/delivery-status.spec.ts b/backend/src/modules/mail/jmap/delivery-status.spec.ts new file mode 100644 index 0000000..de20a9c --- /dev/null +++ b/backend/src/modules/mail/jmap/delivery-status.spec.ts @@ -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명$/); + }); +}); diff --git a/backend/src/modules/mail/jmap/delivery-status.ts b/backend/src/modules/mail/jmap/delivery-status.ts new file mode 100644 index 0000000..b19e977 --- /dev/null +++ b/backend/src/modules/mail/jmap/delivery-status.ts @@ -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(); + const accepted = new Set(); + 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(); + 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 { + 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}명` : ''}`; +} diff --git a/backend/src/modules/mail/jmap/jmap-mail-watch.ts b/backend/src/modules/mail/jmap/jmap-mail-watch.ts index cdeb701..81aec6a 100644 --- a/backend/src/modules/mail/jmap/jmap-mail-watch.ts +++ b/backend/src/modules/mail/jmap/jmap-mail-watch.ts @@ -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); diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 8511031..6a13447 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -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>, + ): 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) @@ -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 = '

확인했습니다.

'; @@ -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, + 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>, + ): 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 = diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index e580e21..52ea7a1 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -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 = 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 { 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 { + 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>( + 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 { + 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>( + 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 { 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 { + options: { readonly remains?: string; readonly timeoutMs?: number } = {}, + ): Promise { 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(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(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)}`, ); } } diff --git a/backend/src/modules/mail/jmap/jmap.errors.spec.ts b/backend/src/modules/mail/jmap/jmap.errors.spec.ts index d2194b6..319e56a 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap.errors.ts b/backend/src/modules/mail/jmap/jmap.errors.ts index 50f7a01..16813a2 100644 --- a/backend/src/modules/mail/jmap/jmap.errors.ts +++ b/backend/src/modules/mail/jmap/jmap.errors.ts @@ -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; +} diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index 941b400..216ec52 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -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 | null; +} + export interface JmapGetResult { /** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */ state?: string; diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 2006c7a..57034b3 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -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절) diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index d83909c..1a5bf46 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -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; /** - * @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; /** * 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key). @@ -187,6 +192,16 @@ export interface MailProvider { messageId: string, ): Promise; + /** + * 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을 + * 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다) + */ + getRejectedRecipients( + user: string, + sentId: string, + recipients: RecipientFields, + ): Promise; + uploadAttachment(user: string, file: UploadedFile): Promise; /** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */ diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 3331c79..53ab81f 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -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(): 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}` })), - ).resolves.toEqual({ id: 'sent-1' }); + ).resolves.toEqual(SENT); // 서식이 바뀌거나(굵게 → 기울임) 서식 본문을 뺀 텍스트 메일은 다른 내용이다 for (const changed of [ sendDto({ bodyHtml: '

확인했습니다.

' }), @@ -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 안에 끝나지 않아 먼저 답합니다', diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index a2b5a13..0f2f70b 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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 { 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 { 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 { 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; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 443c6ea..2716bc9 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -338,6 +338,9 @@ export interface OutgoingMessage { readonly fingerprint?: string; } +/** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */ +export type RecipientFields = Pick; + /** * 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/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; diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 4ed6089..600d14c 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -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); diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index b8337b3..9fc539b 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -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 로 보낸다. ' + '보내는 사람 자신의 서명 그림()만 남겨 메일 안에 함께 싣는다(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, diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index d84f641..5aaacda 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,7 +1,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { createHash } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import { existsSync, unlinkSync } from 'fs'; import { get as httpGet, type IncomingMessage, type Server } from 'http'; import { basename, join } from 'path'; @@ -22,6 +22,10 @@ import { MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, MAX_SUBJECT_LENGTH, + MAX_VACATION_SUBJECT_LENGTH, + MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, + MAX_VACATION_TEXT_LENGTH, + MAX_VACATION_TEXT_LENGTH_WHEN_OFF, } from '@/modules/mail/mail.constants'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; @@ -44,7 +48,9 @@ import type { MessageUpdate, OutgoingMessage, PasswordChangeOutcome, + RecipientRejectionReason, SavedDraft, + SentMessage, } from '@/modules/mail/mail.types'; import { SIGNATURE_IMAGES_PER_SIGNATURE, @@ -146,6 +152,17 @@ const SLOW_MAILBOX = 'slowbox'; /** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */ const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test'; +/** + * 대역 메일 엔진이 발송 때 받지 않는 받는 사람과 그 까닭 (계약 2절 "받는 사람별 결과"). + * SMTP 응답의 분류 · 모두 거절일 때 보낸 사본 지우기는 jmap-mail.provider.spec.ts · delivery-status.spec.ts 가 본다 + */ +const NOBODY = 'nobody@e2e.arasystem.test'; +const BUSY = 'busy@e2e.arasystem.test'; +const ENGINE_REJECTIONS = new Map([ + [NOBODY, 'not-found'], + [BUSY, 'temporary'], +]); + /** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */ const NEVER_CONFIGURED_VACATION: MailVacation = { isEnabled: false, @@ -297,6 +314,8 @@ describe('응답 계약 (e2e)', () => { const deletedDrafts: string[] = []; /** 대역 엔진이 받은 발송 */ const sentMessages: { user: string; message: OutgoingMessage }[] = []; + /** 대역 엔진이 받는 사람을 모두 받지 않은 발송 — 보내지 않은 것이라 sentMessages 에 넣지 않는다 */ + const refusedSends: { user: string; message: OutgoingMessage }[] = []; /** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */ const correspondents = new Map(); @@ -379,11 +398,37 @@ describe('응답 계약 (e2e)', () => { if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate; return password === CORRECT_PASSWORD ? email : null; }, - // 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다 + // 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다. + // 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx) findSentMessage: () => Promise.resolve(null), - sendMessage: (user: string, message: OutgoingMessage) => { + getRejectedRecipients: () => Promise.resolve([]), + sendMessage: ( + user: string, + message: OutgoingMessage, + ): Promise => { + const recipients = [...message.to, ...message.cc, ...message.bcc]; + const rejectedRecipients = recipients.flatMap(({ email }) => { + const reason = ENGINE_REJECTIONS.get(email); + return reason ? [{ email, reason }] : []; + }); + if (rejectedRecipients.length === recipients.length) { + refusedSends.push({ user, message }); + const code = rejectedRecipients.some( + ({ reason }) => reason !== 'temporary', + ) + ? 'MAIL_010' + : 'MAIL_011'; + return Promise.reject( + new BusinessException(code, { + context: { reason: 'e2e-all-recipients-rejected' }, + }), + ); + } sentMessages.push({ user, message }); - return Promise.resolve({ id: `sent${sentMessages.length}` }); + return Promise.resolve({ + id: `sent${sentMessages.length}`, + rejectedRecipients, + }); }, // 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts) saveDraft: ( @@ -2393,7 +2438,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(read)).toEqual(vacation); }); - it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { + it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { const before = vacationSaves.length; const withoutToDate = Object.fromEntries( Object.entries(vacation).filter(([key]) => key !== 'toDate'), @@ -2407,8 +2452,18 @@ describe('응답 계약 (e2e)', () => { { ...vacation, fromDate: '2026-11-02' }, { ...vacation, fromDate: '2026-11-02T15:00:00+09:00' }, withoutToDate, - { ...vacation, subject: 'x'.repeat(201) }, - { ...vacation, textBody: 'x'.repeat(4_001) }, + { ...vacation, subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) }, + { ...vacation, textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) }, + { + ...vacation, + isEnabled: false, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1), + }, + { + ...vacation, + isEnabled: false, + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1), + }, { ...vacation, htmlBody: '

휴가

' }, { ...vacation, subject: ' ' }, { ...vacation, textBody: ' \n\t ' }, @@ -2427,6 +2482,44 @@ describe('응답 계약 (e2e)', () => { expect(vacationSaves).toHaveLength(before); }); + it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지는 한글로 채워도 저장한다 (엔진의 UTF-8 바이트 한도 안 — 계약 2절 "길이 한도의 근거")`, async () => { + const full = { + ...vacation, + subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH), + textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH), + }; + + const res = await put(full).expect(200); + + expect(dataOf(res)).toEqual(full); + }); + + it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => { + const longOff = { + ...vacation, + isEnabled: false, + textBody: 'x'.repeat(1_000), + }; + + const res = await put(longOff).expect(200); + expect(dataOf(res)).toEqual(longOff); + expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff }); + + const before = vacationSaves.length; + for (const enabling of [ + { ...longOff, isEnabled: true }, + { + ...longOff, + isEnabled: true, + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1), + }, + ]) { + const rejected = await put(enabling).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(vacationSaves).toHaveLength(before); + }); + it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => { const off = { ...vacation, @@ -2646,6 +2739,115 @@ describe('응답 계약 (e2e)', () => { ); }); + /** + * 받는 사람별 발송 결과 — 6단계 앞 수정 (계약 2절 "받는 사람별 결과"). + * 엔진 쪽 판단(SMTP 응답 분류 · 모두 거절일 때 보낸 사본 지우기)은 대역 엔진이 흉내 낸다 (ENGINE_REJECTIONS) — + * 여기서는 HTTP 형식 · 이어 쓴 저장본 · 같은 키 잠금을 본다 + */ + describe('받는 사람별 발송 결과 (POST /api/messages — 6단계 앞 수정)', () => { + const ME = 'delivery.me@e2e.arasystem.test'; + const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }; + const address = (email: string) => ({ name: null, email }); + + const send = (payload: object, key?: string) => { + const sending = request(server) + .post('/api/messages') + .set('Cookie', cookieOf(ME)); + return (key ? sending.set('Idempotency-Key', key) : sending).send({ + subject: '제목', + body: '본문', + ...payload, + }); + }; + /** 이어 쓸 저장본을 하나 만든다 */ + const savedDraft = async () => + dataOf( + await request(server) + .post('/api/drafts') + .set('Cookie', cookieOf(ME)) + .send({ to: [DOYUN], subject: '쓰는 중', body: '본문' }) + .expect(201), + ).id; + + it('모두 받았으면 201 { id, rejectedRecipients: [] }', async () => { + const res = await send({ to: [DOYUN] }).expect(201); + + expect(body(res)).toEqual({ + success: true, + data: { + id: expect.stringMatching(/^sent\d+$/) as string, + rejectedRecipients: [], + }, + }); + }); + + it('일부만 받지 않았으면 보낸 것이다 — 201 에 받지 않은 사람(요청에 쓴 주소 · 까닭), 이어 쓴 저장본은 지운다', async () => { + const draftId = await savedDraft(); + + const res = await send({ + to: [DOYUN], + cc: [address(NOBODY)], + bcc: [address(BUSY)], + draftId, + }).expect(201); + + expect(dataOf(res)).toEqual({ + id: expect.stringMatching(/^sent\d+$/) as string, + rejectedRecipients: [ + { email: NOBODY, reason: 'not-found' }, + { email: BUSY, reason: 'temporary' }, + ], + }); + expect(deletedDrafts).toContain(draftId); + }); + + it('아무도 받지 않았으면(5xx 가 섞임) 400 MAIL_010 — 표준 실패 JSON, 이어 쓴 저장본은 그대로 둔다', async () => { + const draftId = await savedDraft(); + const sentBefore = sentMessages.length; + + const res = await send({ + to: [address(NOBODY)], + cc: [address(BUSY)], + draftId, + }).expect(400); + + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe('MAIL_010'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + expect(sentMessages).toHaveLength(sentBefore); + expect(deletedDrafts).not.toContain(draftId); + expect(drafts.has(draftId)).toBe(true); + }); + + it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 이어 쓴 저장본은 그대로 둔다', async () => { + const draftId = await savedDraft(); + + const res = await send({ to: [address(BUSY)], draftId }).expect(503); + + expect(body(res).error?.code).toBe('MAIL_011'); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + expect(deletedDrafts).not.toContain(draftId); + }); + + it('모두 받지 않은 키는 잠가 두지 않는다 — 곧바로 같은 키로 다시 보내면 새로 보낸다 (429 · 409 가 아니다)', async () => { + for (const [rejected, status] of [ + [NOBODY, 400], + [BUSY, 503], + ] as const) { + const key = randomUUID(); + const refusedBefore = refusedSends.length; + + await send({ to: [address(rejected)] }, key).expect(status); + // 같은 내용으로 다시 — 처리 중(REQ_001)이 아니라 엔진에 다시 묻는다 + await send({ to: [address(rejected)] }, key).expect(status); + expect(refusedSends).toHaveLength(refusedBefore + 2); + + // 주소를 고쳐 같은 키로 — 보낸 사본이 없으니 내용이 달라도 MAIL_005 가 아니다 + await send({ to: [DOYUN] }, key).expect(201); + } + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index e47c441..0b656b6 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -22,6 +22,8 @@ const Map kErrorCodeMessages = { 'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', 'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', 'MAIL_009': '같은 이름의 메일함이 이미 있습니다.', + 'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', + 'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 5e30e5c..6f4d425 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -22,6 +22,8 @@ export const ERROR_CODE_MESSAGES = { MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.', MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.', MAIL_009: '같은 이름의 메일함이 이미 있습니다.', + MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.', + MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES