feat(backend): 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기

- PATCH /api/mailboxes/{id} 에 parentId — 맨 위(null) · 자기 하위로는 VAL_001 · 새 상위에 같은 이름(대소문자 무시)은 MAIL_009 · 역할 메일함 막기
- DELETE /api/mailboxes/{id}?withChildren=true — 나무를 깊은 것부터 지금 지우기와 같은 차례로(휴지통으로 → destroy)
- POST /api/mail-block-runs — 받은편지함에서 차단한 보낸 사람의 메일을 스팸메일함으로(Sieve 차단 규칙과 같은 판정,
  필터 적용과 같은 cursor · 잠금, 도는 동안 스팸메일함 잠금)
- POST /api/scheduled-messages/{id}/release — 예약 제출을 취소하고 원래 envelope 으로 다시 제출(보내기 취소 설정만큼 붙잡음).
  결과를 모르는 실패면 다시 내지 않고 503 — 같은 요청이 이어 낸다(두 통이 나가지 않게), 엔진이 분명히 거절했을 때만 임시보관함으로

엔진 사실은 개발 엔진(Stalwart 0.16.27)으로 확인했다: parentId 바꾸기 · 고리 거절, destroy 는 적은 차례대로,
from 검색은 비슷한 주소 · 표시 이름의 주소까지 넓게 잡힘, 취소 뒤 다시 제출하면 받는 사람에게 한 통.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:02:40 +09:00
parent 099faeaafc
commit 1ca210dec4
36 changed files with 5055 additions and 315 deletions
@@ -97,6 +97,19 @@ export class FileSharingRepository {
await this.links.update({ id }, { sentEmailId });
}
/** 보낸 때를 앞당긴다 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만, 한 문장으로 (true = 바꿨다) */
async moveSentAtEarlier(id: string, sentAt: Date): Promise<boolean> {
const result = await this.links
.createQueryBuilder()
.update(ShareLinkEntity)
.set({ sentAt })
.where('id = :id', { id })
.andWhere('canceled_at IS NULL')
.andWhere('sent_at > :sentAt', { sentAt })
.execute();
return (result.affected ?? 0) > 0;
}
/** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */
async cancel(
id: string,
@@ -91,6 +91,16 @@ class FakeRepository {
return Promise.resolve();
}
/** 실제 표처럼 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만 (조건부 UPDATE 한 문장) */
moveSentAtEarlier(id: string, sentAt: Date) {
const link = this.records.find((item) => item.link.id === id)?.link;
if (!link || link.canceledAt !== null || link.sentAt <= sentAt) {
return Promise.resolve(false);
}
link.sentAt = sentAt;
return Promise.resolve(true);
}
cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) {
const link = this.records.find((item) => item.link.id === id)?.link;
if (!link || link.canceledAt !== null) return Promise.resolve(false);
@@ -314,6 +324,34 @@ describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계)
expect(await codeOf(() => service.publicLink(token))).toBe('RES_001');
});
it('예약 메일을 지금 보냈으면(메일 15단계) 보낸 때를 앞당긴다 — 끝나는 때는 그대로(덩이가 적은 날짜), 취소한 링크 · 더 늦은 시각은 바꾸지 않는다', async () => {
const { service, repository } = setup();
const scheduled = new Date(Date.now() + 5 * 24 * 60 * 60_000);
const created = await service.createMailLink({
ownerEmail: ARA,
senderName: '김아라',
files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }],
sentAt: scheduled,
messageId: 'ara.release@arasystem.test',
});
const { link } = created.record;
const expiresAt = link.expiresAt.getTime();
const now = new Date();
await expect(service.markReleased(link.id, now)).resolves.toBe(true);
expect(link.sentAt).toEqual(now);
expect(link.expiresAt.getTime()).toBe(expiresAt);
// 다시 와도(응답을 받지 못한 재시도) 더 늦은 시각으로 되돌리지 않는다
await expect(service.markReleased(link.id, scheduled)).resolves.toBe(false);
expect(link.sentAt).toEqual(now);
await service.cancel(created.record, 'send-canceled');
await expect(
service.markReleased(link.id, new Date(now.getTime() - 1_000)),
).resolves.toBe(false);
expect(repository.records).toHaveLength(1);
});
describe('파일 응답 (Range · HEAD · 횟수)', () => {
it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
const { service } = setup();
@@ -199,6 +199,15 @@ export class FileSharingService implements BeforeApplicationShutdown {
await this.repository.markSent(linkId, sentEmailId);
}
/**
* 예약한 메일을 지금 보냈다(메일 15단계 — 계약 2절 "보내기 취소 · 예약 발송") — 보낸 때를 실제로 보낸 때로 앞당긴다(보낸 링크 목록 · 공개 화면의 보낸 때).
* **끝나는 때는 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 그 날짜를 이미 적었다. 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다(파일 기간도 그대로라 다시 세지 않는다).
* 취소한 링크 · 이미 그보다 앞선 보낸 때는 바꾸지 않는다 (true = 바꿨다)
*/
async markReleased(linkId: string, sentAt: Date): Promise<boolean> {
return this.repository.moveSentAtEarlier(linkId, sentAt);
}
/**
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다.
* 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때")
@@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength, ValidateIf } from 'class-validator';
import {
MAIL_FILTER_RUN_CURSOR_PATTERN,
MAX_EMAIL_LENGTH,
MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
} from '@/modules/mail/mail.constants';
import type {
MailBlockRunRequest,
MailFilterRun,
} from '@/modules/mail/mail.types';
import { BlockAddress } from './mail-block.dto';
/**
* 차단할 때 받은편지함 정리 — POST /api/mail-block-runs 의 본문과 응답 (계약 2절 "보낸 사람 차단" — 15단계).
* 보낸 사람은 차단하기와 같은 검사(주소 · `@도메인` 모양 · 254자 — mail-blocks.ts), 차단 목록에 있는지는 서비스가 본다(없으면 404 RES_001).
* cursor 는 이미 받은 메일에 필터 적용(dto/mail-filter-run.dto.ts)과 같은 검사다
*/
/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
/** POST /api/mail-block-runs — 정리할 보낸 사람(차단 목록의 값)과 이어 할 자리 */
export class MailBlockRunRequestDto implements MailBlockRunRequest {
@ApiProperty({
description:
'차단 목록의 값 그대로 — 소문자 주소("kim@spam.example") 또는 도메인("@spam.example"). 차단한 뒤에만 부른다 — 차단 목록에 없으면 404 RES_001',
example: '@spam.example',
maxLength: MAX_EMAIL_LENGTH,
})
@BlockAddress()
sender!: string;
@ApiProperty({
description:
'이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' +
'다른 보낸 사람의 cursor · 깨진 cursor 는 400 VAL_001',
nullable: true,
type: String,
maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
example: null,
})
@ValidateIf(unlessNull)
@IsString({ message: '이어 할 자리를 다시 받아 주세요.' })
@MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, {
message: '이어 할 자리를 다시 받아 주세요.',
})
@Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, {
message: '이어 할 자리를 다시 받아 주세요.',
})
cursor!: string | null;
}
/** POST /api/mail-block-runs 의 응답 — 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 */
export class MailBlockRunDto implements MailFilterRun {
@ApiProperty({
description: '이번 요청에서 확인한 받은편지함 메일 수',
example: 500,
})
scanned!: number;
@ApiProperty({
description:
'그중 그 보낸 사람의 메일이라 스팸메일함으로 옮긴 수 (스팸 표시도 붙였다). 보내기를 기다리는 메일은 옮기지 않아 세지 않는다',
example: 37,
})
matched!: number;
@ApiProperty({
description:
'아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값',
example: 4_300,
})
remaining!: number;
@ApiProperty({
description:
'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)',
nullable: true,
type: String,
example:
'eyJ2IjoyLCJiIjoxNzkzMjY0NDAxLCJhIjoic2VhYWFhZXIiLCJ0IjoxNzkzMjY0MzQwLCJyIjoiZDJWM2R0ejdOMmdCVVRyZyJ9',
})
cursor!: string | null;
}
@@ -32,7 +32,8 @@ function IsBlockTarget() {
);
}
function BlockAddress(): PropertyDecorator {
/** 차단 대상 칸 — 차단하기(POST /api/mail-blocks) · 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 같이 쓴다 */
export function BlockAddress(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }),
@@ -4,8 +4,9 @@ import { validate } from 'class-validator';
import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants';
import {
CreateMailboxDto,
MailboxDeleteQueryDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
UpdateMailboxDto,
} from './mailbox.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */
@@ -29,7 +30,7 @@ async function check<T extends object>(
describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
it.each([
['만들기', CreateMailboxDto],
['이름 바꾸기', RenameMailboxDto],
['바꾸기', UpdateMailboxDto],
] as const)(
`%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`,
async (_name, type) => {
@@ -65,7 +66,7 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
it.each([
['만들기', CreateMailboxDto],
['이름 바꾸기', RenameMailboxDto],
['바꾸기', UpdateMailboxDto],
] as const)(
'%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류',
async (_name, type) => {
@@ -87,31 +88,72 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
expect((await check(type, { name: 7 })).constraints).toEqual(
expect.arrayContaining(['isString']),
);
expect((await check(type, {})).constraints).toEqual(
expect((await check(type, { name: null })).constraints).toEqual(
expect.arrayContaining(['isString']),
);
},
);
it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => {
it('만들기는 이름이 없으면 형식 오류 — 바꾸기는 이름을 보내지 않아도 된다(빈 본문은 서비스가 VAL_001)', async () => {
expect((await check(CreateMailboxDto, {})).constraints).toEqual(
expect.arrayContaining(['isString']),
);
expect((await check(UpdateMailboxDto, {})).constraints).toEqual([]);
});
it.each([
['만들기', CreateMailboxDto, { name: '거래처' }],
['바꾸기(옮기기 — 15단계)', UpdateMailboxDto, {}],
] as const)(
'%s의 상위는 없거나 null(맨 위) · 메일함 id 형식',
async (_name, type, base) => {
expect((await check(type, base)).constraints).toEqual([]);
expect(
(await check(type, { ...base, parentId: null })).constraints,
).toEqual([]);
expect(
(await check(type, { ...base, parentId: 'a' })).constraints,
).toEqual([]);
for (const parentId of ['../a', '', 'a b', 7]) {
expect((await check(type, { ...base, parentId })).constraints).toEqual([
'matches',
]);
}
},
);
it('바꾸기는 이름 · 상위를 함께 받고, 보낸 값을 그대로 둔다 — null 상위는 null(맨 위), 보내지 않은 칸은 undefined', async () => {
const both = await check(UpdateMailboxDto, {
name: ' 2025 ',
parentId: 'u1',
});
expect(both.constraints).toEqual([]);
expect(both.dto).toMatchObject({ name: '2025', parentId: 'u1' });
const top = await check(UpdateMailboxDto, { parentId: null });
expect(top.constraints).toEqual([]);
expect(top.dto.parentId).toBeNull();
expect(top.dto.name).toBeUndefined();
expect(
(await check(CreateMailboxDto, { name: '거래처' })).constraints,
).toEqual([]);
(await check(UpdateMailboxDto, { name: '거래처', role: 'inbox' }))
.constraints,
).toEqual(['whitelistValidation']);
});
it('지우기의 쿼리는 withChildren=true 만 받는다 (15단계 — 하위 메일함까지)', async () => {
expect((await check(MailboxDeleteQueryDto, {})).constraints).toEqual([]);
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: null }))
(await check(MailboxDeleteQueryDto, { withChildren: 'true' }))
.constraints,
).toEqual([]);
for (const withChildren of ['false', 'TRUE', '1', '', true]) {
expect(
(await check(MailboxDeleteQueryDto, { withChildren })).constraints,
).toEqual(['isIn']);
}
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: 'a' }))
.constraints,
).toEqual([]);
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: '../a' }))
.constraints,
).toEqual(['matches']);
expect(
(await check(RenameMailboxDto, { name: '거래처', parentId: 'a' }))
.constraints,
(await check(MailboxDeleteQueryDto, { recursive: 'true' })).constraints,
).toEqual(['whitelistValidation']);
});
+70 -13
View File
@@ -1,6 +1,13 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Equals, IsOptional, IsString, Matches } from 'class-validator';
import {
Equals,
IsIn,
IsOptional,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
import {
MaxCodeUnits,
@@ -16,15 +23,32 @@ import {
/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */
const NO_SLASH_PATTERN = /^[^/]*$/;
/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */
function MailboxName() {
/** 메일함 이름 칸의 설명 */
const MAILBOX_NAME_DESCRIPTION = `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`;
/** 보낸 칸만 검사한다 — 없으면(undefined) 건너뛰고, null 은 검사해 형식 오류로 막는다 (이름 · 상위 가운데 바꿀 것만 보내는 PATCH) */
const unlessAbsent = (_object: object, value: unknown) => value !== undefined;
/**
* 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 바꾸기가 같이 쓴다.
* `optional` 이면(바꾸기 — 15단계) 보내지 않아도 된다 — 보낸 값은 같은 검사를 한다(null 도 형식 오류)
*/
function MailboxName({ optional = false }: { optional?: boolean } = {}) {
return applyDecorators(
ApiProperty({
description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
}),
optional
? ApiPropertyOptional({
description: `${MAILBOX_NAME_DESCRIPTION}. 보내지 않으면 이름은 그대로`,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
})
: ApiProperty({
description: MAILBOX_NAME_DESCRIPTION,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
}),
...(optional ? [ValidateIf(unlessAbsent)] : []),
TrimString(),
IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }),
@@ -59,10 +83,43 @@ export class CreateMailboxDto {
parentId?: string | null;
}
/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */
export class RenameMailboxDto {
@MailboxName()
name!: string;
/**
* PATCH /api/mailboxes/{id} — 이름 · 상위(옮기기 — 15단계) 가운데 바꿀 것만. 둘 다 없으면(빈 본문) 서비스가 400 VAL_001.
* 상위의 자리 규칙(만들기와 같다) · 자기 자신 · 하위 아래로 옮기기는 어댑터가 본다
*/
export class UpdateMailboxDto {
@MailboxName({ optional: true })
name?: string;
@ApiPropertyOptional({
description:
'새 상위 메일함 id — null 이면 맨 위, 보내지 않으면 상위는 그대로. 만들기와 같은 자리만(받은편지함 · 보관함 · 내 메일함 아래 — 그 밖 400 VAL_001, 없는 id 는 404 RES_001). ' +
'자기 자신 · 자기 하위 아래로는 400 VAL_001. 하위 메일함과 안의 메일은 함께 간다',
nullable: true,
type: String,
example: 'a',
})
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '옮길 곳을 다시 선택해 주세요.' })
parentId?: string | null;
}
/** 하위 메일함까지 지우기의 값 — 이것만 받는다 (15단계) */
export const WITH_CHILDREN = 'true';
/** DELETE /api/mailboxes/{id} 의 쿼리 — withChildren=true 면 하위 메일함까지 지운다 (15단계). 모르는 쿼리는 400 VAL_001 */
export class MailboxDeleteQueryDto {
@ApiPropertyOptional({
description:
'true 면 하위 메일함이 있어도 그 메일함과 하위 모두를 지운다(깊은 것부터 — 안의 메일은 휴지통으로). 없으면 하위가 있을 때 400 VAL_001(옛 화면이 모르고 하위를 지우지 않게). 다른 값은 400 VAL_001',
enum: [WITH_CHILDREN],
example: WITH_CHILDREN,
})
@IsOptional()
@IsIn([WITH_CHILDREN], {
message: '하위 메일함 지우기 값이 올바르지 않습니다.',
})
withChildren?: typeof WITH_CHILDREN;
}
/** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */
@@ -1,7 +1,9 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
import {
decodeBlockRunCursor,
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
MAX_FILTER_RUN_SECONDS,
type FilterRunPosition,
@@ -172,3 +174,62 @@ describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미
).toEqual({ before: NINE, anchor: null, anchorTime: null });
});
});
describe('차단할 때 받은편지함 정리의 cursor (15단계 — 계약 2절 "보낸 사람 차단")', () => {
const position: FilterRunPosition = {
before: NINE + 1,
anchor: 'seaaaaer',
anchorTime: NINE - 60,
};
it('자리는 필터 적용과 같은 모양으로 되돌린다 — 지문은 그 보낸 사람', () => {
for (const sender of ['kim@spam.example', '@spam.example']) {
expect(
decodeBlockRunCursor(encodeBlockRunCursor(position, sender), sender),
).toEqual(position);
}
expect(encodeBlockRunCursor(position, '@spam.example')).toMatch(
/^[A-Za-z0-9_-]+$/,
);
});
it('다른 보낸 사람의 cursor · 필터 적용의 cursor 는 VAL_001 — 거꾸로 차단 정리의 cursor 로 필터를 이어 하지도 못한다', () => {
const cursor = encodeBlockRunCursor(position, 'kim@spam.example');
for (const other of ['lee@spam.example', '@spam.example']) {
expect(failureOf(() => decodeBlockRunCursor(cursor, other))).toEqual({
code: 'VAL_001',
reason: 'invalid-mail-block-run-cursor:other-sender',
});
}
expect(
failureOf(() =>
decodeBlockRunCursor(
encodeFilterRunCursor(position, rule),
'kim@spam.example',
),
),
).toEqual({
code: 'VAL_001',
reason: 'invalid-mail-block-run-cursor:other-sender',
});
expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({
code: 'VAL_001',
reason: 'invalid-filter-run-cursor:other-rule',
});
});
it('깨진 cursor 는 필터 적용과 같은 검사로 VAL_001', () => {
for (const [cursor, reason] of [
['%%%', 'not-json'],
[forged({ v: 1, b: NINE }), 'shape'],
['a'.repeat(1_025), 'too-long'],
] as const) {
expect(
failureOf(() => decodeBlockRunCursor(cursor, 'kim@spam.example')),
).toEqual({
code: 'VAL_001',
reason: `invalid-mail-block-run-cursor:${reason}`,
});
}
});
});
@@ -17,7 +17,10 @@ import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
* - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다
* (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다
* - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다)
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다.
*
* 차단할 때 받은편지함 정리(15단계 — 계약 2절 "보낸 사람 차단")도 같은 자리 · 같은 모양을 쓴다 — 지문만 그 보낸 사람(차단 목록의 값)이다.
* 지문의 재료가 달라(필터는 규칙의 세 칸, 차단은 표시 글 + 보낸 사람) 필터 적용의 cursor 를 차단 정리에 쓰면 다른 주인의 것으로 막힌다
*/
const CURSOR_VERSION = 2;
@@ -51,9 +54,20 @@ interface EncodedFilterRunCursor {
r: string;
}
/** 차단 정리 cursor 의 지문 재료 앞에 두는 표시 — 필터 규칙의 재료(첫 칸이 묶는 방법)와 섞이지 않게 */
const MAIL_BLOCK_SCOPE = 'mail-block';
/** 정해진 차례의 JSON 을 SHA-256 으로 — 짧게 자른다 */
function scopeHash(canonical: unknown[]): string {
return createHash('sha256')
.update(JSON.stringify(canonical), 'utf8')
.digest('base64url')
.slice(0, RULE_HASH_LENGTH);
}
/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */
function ruleHash(rule: FilterRunRule): string {
const canonical = JSON.stringify([
return scopeHash([
rule.match,
rule.conditions.map(({ field, operator, value }) => [
field,
@@ -62,15 +76,19 @@ function ruleHash(rule: FilterRunRule): string {
]),
[rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag],
]);
return createHash('sha256')
.update(canonical, 'utf8')
.digest('base64url')
.slice(0, RULE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
/** 차단 정리의 지문 — 보낸 사람(차단 목록의 값 — 소문자) */
function senderHash(sender: string): string {
return scopeHash([MAIL_BLOCK_SCOPE, sender]);
}
/** 깨진 cursor 의 로그 까닭 — 필터 적용 · 차단 정리를 가른다 */
type CursorKind = 'filter-run' | 'mail-block-run';
function invalidCursor(kind: CursorKind, reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-filter-run-cursor:${reason}` },
context: { reason: `invalid-${kind}-cursor:${reason}` },
});
}
@@ -84,50 +102,85 @@ function isSeconds(value: unknown): value is number {
);
}
export function encodeFilterRunCursor(
position: FilterRunPosition,
rule: FilterRunRule,
): string {
/** 자리와 지문을 cursor 로 */
function encodeRunCursor(position: FilterRunPosition, hash: string): string {
const payload: EncodedFilterRunCursor = {
v: CURSOR_VERSION,
b: position.before,
a: position.anchor,
t: position.anchorTime,
r: ruleHash(rule),
r: hash,
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 지문이 다르면(다른 주인의 cursor) VAL_001 */
function decodeRunCursor(
cursor: string,
hash: string,
kind: CursorKind,
): FilterRunPosition {
if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
throw invalidCursor(kind, 'too-long');
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor(kind, 'not-json');
}
const { v, b, a, t, r } = (parsed ?? {}) as Partial<EncodedFilterRunCursor>;
if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
throw invalidCursor(kind, 'shape');
}
if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
throw invalidCursor(kind, 'shape');
}
// anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
throw invalidCursor(kind, 'shape');
}
if (typeof r !== 'string') throw invalidCursor(kind, 'shape');
if (r !== hash) {
throw invalidCursor(
kind,
kind === 'filter-run' ? 'other-rule' : 'other-sender',
);
}
return { before: b, anchor: a, anchorTime: t };
}
export function encodeFilterRunCursor(
position: FilterRunPosition,
rule: FilterRunRule,
): string {
return encodeRunCursor(position, ruleHash(rule));
}
/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */
export function decodeFilterRunCursor(
cursor: string,
rule: FilterRunRule,
): FilterRunPosition {
if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
throw invalidCursor('too-long');
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, b, a, t, r } = (parsed ?? {}) as Partial<EncodedFilterRunCursor>;
if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
throw invalidCursor('shape');
}
if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
throw invalidCursor('shape');
}
// anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
throw invalidCursor('shape');
}
if (typeof r !== 'string') throw invalidCursor('shape');
if (r !== ruleHash(rule)) throw invalidCursor('other-rule');
return { before: b, anchor: a, anchorTime: t };
return decodeRunCursor(cursor, ruleHash(rule), 'filter-run');
}
/** 차단 정리의 cursor (15단계) — 자리는 필터 적용과 같고 지문은 그 보낸 사람 */
export function encodeBlockRunCursor(
position: FilterRunPosition,
sender: string,
): string {
return encodeRunCursor(position, senderHash(sender));
}
/** 차단 정리의 cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 보낸 사람(또는 필터 적용)의 것이면 VAL_001 */
export function decodeBlockRunCursor(
cursor: string,
sender: string,
): FilterRunPosition {
return decodeRunCursor(cursor, senderHash(sender), 'mail-block-run');
}
@@ -83,6 +83,33 @@ function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] {
);
}
/**
* 차단할 때 받은편지함 정리(15단계)가 읽는 보낸 사람 — 엔진이 푼 From 머리글의 주소 목록, 같은 이름의 머리글이 여럿이면 모두(머리글마다 배열).
* Sieve 의 `address` 판정(jmap/sieve-script.ts 의 blockSection)이 견주는 것과 같다 — 표시 이름에 쓴 주소는 들지 않는다.
* 엔진은 물은 이름 그대로 답한다(Stalwart 0.16.27 — 2026-11-09 확인: `[[{ name, email }]]`)
*/
export const FROM_ADDRESSES_PROPERTY = 'header:From:asAddresses:all';
/** From 머리글들의 주소 — 물은 이름으로 찾되 대소문자가 달라도 찾는다. 모양이 틀린 칸은 건너뛴다 */
export function fromAddressesOf(email: JmapEmail): string[] {
const wanted = FROM_ADDRESSES_PROPERTY.toLowerCase();
for (const [key, value] of Object.entries(email)) {
if (key.toLowerCase() !== wanted || !Array.isArray(value)) continue;
return value.flatMap((header: unknown) =>
Array.isArray(header)
? header.flatMap((address: unknown) => {
const found =
typeof address === 'object' && address !== null
? (address as { email?: unknown }).email
: undefined;
return typeof found === 'string' ? [found] : [];
})
: [],
);
}
return [];
}
/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */
export function headerTextsOf(email: JmapEmail): Omit<MailFilterTexts, 'body'> {
const subject = email.subject;
@@ -11,6 +11,7 @@ import type {
} from '@/modules/mail/mail.types';
import {
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
} from './filter-run-cursor';
import type { JmapClient } from './jmap.client';
@@ -1180,6 +1181,278 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
).toMatchObject({ code: 'MAIL_001', reason: 'timeout' });
});
});
describe('차단할 때 받은편지함 정리 (runBlock — 15단계)', () => {
const JUNK = 'k';
const BLOCK_MAILBOXES: readonly Mailbox[] = [
mailbox(INBOX, 'inbox', 3),
mailbox(TARGET, null),
mailbox(JUNK, 'junk'),
];
const blockContext = (
overrides: Partial<MailFilterRunContext> = {},
): MailFilterRunContext => ({
mailboxes: BLOCK_MAILBOXES,
startedAt: Date.now(),
...overrides,
});
/** 받은편지함의 메일 — 엔진이 푼 From 머리글의 주소(머리글마다 배열) */
const fromMail = (
id: string,
...headers: { name: string | null; email: string }[][]
) => ({
id,
mailboxIds: { [INBOX]: true },
receivedAt: utc(T0),
'header:From:asAddresses:all': headers,
});
const KIM = { name: null, email: 'kim@spam.example' };
const junkPatch = {
[`mailboxIds/${INBOX}`]: null,
[`mailboxIds/${JUNK}`]: true,
'keywords/$junk': true,
'keywords/$notjunk': null,
};
it('받은편지함을 받은 시각 내림차순으로 읽어 엔진이 푼 From 주소로 판정하고, 맞은 메일을 스팸메일함으로 옮기며 스팸 표시 — 옮기기는 읽은 상태 그대로일 때만', async () => {
const { runner, recorded } = fakeRunner([
page(
['m1', 'm2', 'm3'],
[
fromMail('m1', [KIM]),
fromMail('m2', [{ name: null, email: 'lee@spam.example' }]),
fromMail('m3', [{ name: 'Kim', email: 'KIM@Spam.Example' }]),
],
),
updated(['m1', 'm3']),
]);
await expect(
runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
),
).resolves.toEqual({
scanned: 3,
matched: 2,
remaining: 0,
cursor: null,
rounds: 1,
});
expect(argsOf(recorded[0])).toMatchObject({
filter: { inMailbox: INBOX },
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 500,
calculateTotal: true,
});
expect(argsOf(recorded[0], 1)).toMatchObject({
properties: [
'id',
'mailboxIds',
'receivedAt',
'header:From:asAddresses:all',
],
});
// 붙잡힌 메일을 같은 요청에서 묻는다 (9단계 — 옮기지 않는다)
expect(methodsOf([recorded[0]])).toEqual([
'Email/query',
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
]);
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
ifInState: 's1',
update: { m1: junkPatch, m3: junkPatch },
});
});
it('판정은 차단 규칙(Sieve 의 address)과 같다 — 주소 전체가 같을 때 · From 이 여럿이면 하나라도 · 표시 이름의 주소 · 비슷한 주소는 아니다 (대소문자는 ASCII 만)', async () => {
const cases: [string, ReturnType<typeof fromMail>][] = [
['맞음', fromMail('m1', [KIM])],
[
'맞음',
fromMail('m2', [
{ name: 'First', email: 'first@example.net' },
{ name: 'Kim', email: 'kim@spam.example' },
]),
],
[
'맞음',
fromMail('m3', [{ name: null, email: 'first@example.net' }], [KIM]),
],
[
'아님',
fromMail('m4', [
{ name: 'kim@spam.example', email: 'other@example.net' },
]),
],
[
'아님',
fromMail('m5', [{ name: null, email: 'kim.lee@spam.example' }]),
],
[
'아님',
fromMail('m6', [{ name: null, email: 'kim@sub.spam.example' }]),
],
[
'아님',
fromMail('m7', [{ name: null, email: 'akim@spam.example' }]),
],
['아님', fromMail('m8')],
];
const { runner, recorded } = fakeRunner([
page(
cases.map(([, mail]) => mail.id),
cases.map(([, mail]) => mail),
),
updated(['m1', 'm2', 'm3']),
]);
await runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
);
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual(
cases
.filter(([verdict]) => verdict === '맞음')
.map(([, mail]) => mail.id),
);
});
it('도메인 차단(@도메인)은 From 주소의 @ 뒤가 그 도메인일 때만 — 하위 도메인 · 더 긴 도메인 · 표시 이름은 아니다', async () => {
const mails = [
fromMail('m1', [{ name: null, email: 'kim@spam.example' }]),
fromMail('m2', [{ name: null, email: 'Lee@SPAM.example' }]),
fromMail('m3', [{ name: null, email: 'kim@sub.spam.example' }]),
fromMail('m4', [{ name: null, email: 'kim@spam.example.net' }]),
fromMail('m5', [
{ name: '@spam.example', email: 'kim@other.example' },
]),
];
const { runner, recorded } = fakeRunner([
page(
mails.map(({ id }) => id),
mails,
),
updated(['m1', 'm2']),
]);
await runner.runBlock(
ARA,
{ sender: '@spam.example', cursor: null },
blockContext(),
);
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual([
'm1',
'm2',
]);
});
it('붙잡힌 메일(9단계)은 맞아도 옮기지 않는다 — 받은편지함에 남긴 것으로 세고 matched 에 넣지 않는다', async () => {
const later = utc(Math.floor(Date.now() / 1_000) + 3_600);
const { runner, recorded } = fakeRunner([
page(['m1', 'm2'], [fromMail('m1', [KIM]), fromMail('m2', [KIM])], {
held: [
{
id: 'sub1',
emailId: 'm2',
undoStatus: 'pending',
sendAt: later,
},
],
}),
updated(['m1']),
]);
await expect(
runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
),
).resolves.toMatchObject({ scanned: 2, matched: 1 });
expect(argsOf(recorded[1])).toMatchObject({
update: { m1: junkPatch },
});
expect(argsOf(recorded[1]).update).not.toHaveProperty('m2');
});
it('스팸메일함이 없으면 엔진에 묻지 않고 MAIL_001 · 받은편지함이 없으면 할 메일이 없다', async () => {
const noJunk = fakeRunner([]);
expect(
await failureOf(
noJunk.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext({
mailboxes: BLOCK_MAILBOXES.filter(
({ role }) => role !== 'junk',
),
}),
),
),
).toMatchObject({ code: 'MAIL_001', reason: 'junk-mailbox-missing' });
expect(noJunk.recorded).toHaveLength(0);
const noInbox = fakeRunner([]);
await expect(
noInbox.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext({
mailboxes: BLOCK_MAILBOXES.filter(({ role }) => role !== 'inbox'),
}),
),
).resolves.toEqual({
scanned: 0,
matched: 0,
remaining: 0,
cursor: null,
rounds: 0,
});
});
it('이어 할 자리 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 엔진에 묻기 전에 VAL_001', async () => {
const position = { before: T0 + 1, anchor: 'm1', anchorTime: T0 };
const others = [
encodeBlockRunCursor(position, 'lee@spam.example'),
encodeFilterRunCursor(position, request()),
'not-a-cursor',
];
for (const cursor of others) {
const fake = fakeRunner([]);
expect(
await failureOf(
fake.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor },
blockContext(),
),
),
).toMatchObject({ code: 'VAL_001' });
expect(fake.recorded).toHaveLength(0);
}
// 같은 보낸 사람의 cursor 는 그 자리부터 — anchor 다음 · 위 경계 미만
const fake = fakeRunner([page([], [])]);
await fake.runner.runBlock(
ARA,
{
sender: 'kim@spam.example',
cursor: encodeBlockRunCursor(position, 'kim@spam.example'),
},
blockContext(),
);
expect(argsOf(fake.recorded[0])).toMatchObject({
filter: { inMailbox: INBOX, before: utc(T0 + 1) },
anchor: 'm1',
anchorOffset: 1,
});
});
});
});
/**
@@ -1369,6 +1642,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
),
receivedAt: utc(email.receivedAt),
'header:From:all': [` ${email.from}`],
// 엔진이 푼 주소 — 꺾쇠 안 (차단 정리 — 15단계)
'header:From:asAddresses:all': [
[{ name: null, email: /<([^>]+)>/.exec(email.from)?.[1] ?? '' }],
],
};
}
@@ -1706,5 +1983,68 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
}
expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length);
});
it('차단 정리(15단계)도 같은 틀로 끝까지 — 그 보낸 사람의 메일만 스팸메일함으로(스팸 표시) · 요청 사이에 온 새 메일은 건드리지 않는다', async () => {
const JUNK = 'k';
const engine = new FakeEngine();
fill(engine, 1_200);
let now = 1_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
const client = {
call: jest.fn(
(_user: string, build: (accountId: string) => JmapInvocation[]) => {
const calls = build('d');
if (calls[0][0] === 'Email/query') now += 21_000;
return Promise.resolve(engine.handle(calls));
},
),
};
const runner = new JmapFilterRunner(client as unknown as JmapClient);
const sender = 'sales@hanbit-partners.co.kr';
const wanted = engine
.sorted(INBOX)
.filter(({ from }) => from.includes(sender)).length;
let cursor: string | null = null;
let requests = 0;
let matched = 0;
do {
// 요청 사이에 그 보낸 사람의 새 메일이 온다 — 시작한 뒤 온 메일은 보지 않는다 (새 메일은 Sieve 가 이미 판정했다)
if (cursor !== null) engine.add(T0 + 10_000 + requests, HANBIT_FROM);
const result = await runner.runBlock(
ARA,
{ sender, cursor },
{
mailboxes: [
mailbox(INBOX, 'inbox', engine.sorted(INBOX).length),
mailbox(JUNK, 'junk'),
],
startedAt: now,
},
);
matched += result.matched;
cursor = result.cursor;
requests += 1;
now += 100_000;
} while (cursor !== null && requests < 100);
expect(cursor).toBeNull();
expect(requests).toBeGreaterThan(1);
expect(matched).toBe(wanted);
const moved = engine.sorted(JUNK);
expect(moved).toHaveLength(wanted);
for (const email of moved) {
expect(email.keywords.has('$junk')).toBe(true);
expect(email.receivedAt).toBeLessThan(T0 + 10_000);
}
// 받은편지함에 남은 그 보낸 사람의 메일은 요청 사이에 온 새 메일뿐
const left = engine
.sorted(INBOX)
.filter(({ from }) => from.includes(sender));
expect(left).toHaveLength(requests - 1);
expect(left.every(({ receivedAt }) => receivedAt >= T0 + 10_000)).toBe(
true,
);
});
});
});
+262 -38
View File
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { isBlockedSenderMail } from '@/modules/mail/mail-block-match';
import {
filterFields,
judgeFilter,
@@ -8,6 +9,7 @@ import {
} from '@/modules/mail/mail-filter-match';
import type {
Mailbox,
MailBlockRunRequest,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -15,15 +17,20 @@ import type {
import { BulkRun } from './bulk-run';
import {
MAX_FILTER_RUN_SECONDS,
decodeBlockRunCursor,
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
type FilterRunPosition,
} from './filter-run-cursor';
import {
FILTER_RUN_BODY_PROPERTIES,
FILTER_RUN_PART_PROPERTIES,
FROM_ADDRESSES_PROPERTY,
bodyTextsOf,
estimatedBodyBytes,
filterRunProperties,
fromAddressesOf,
headerTextsOf,
textPartsOf,
} from './filter-run-texts';
@@ -97,6 +104,32 @@ const HELD_GUARD_CALLS = 'h';
/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */
const NO_HELD: HeldEmails = { ids: new Set(), complete: true };
/** 처음부터 — 위 경계 · 남긴 메일 · 그 받은 시각을 아직 모른다 (첫 차례가 위 경계를 잡는다) */
const FROM_TOP: RunState = { before: null, anchor: null, anchorTime: null };
/** 바꾼 것이 없다 — 맞은 메일이 없거나 모두 바꿀 것이 없는 차례 */
const NOTHING_APPLIED: Applied = { updated: new Set(), gone: new Set() };
/**
* 차단 정리(15단계)가 차례마다 읽는 속성 — 받은편지함에 아직 있는지 · 받은 시각(이어 할 자리) · 엔진이 푼 From 머리글의 주소(filter-run-texts.ts).
* 표시는 읽지 않는다 — 맞은 메일은 늘 옮기고 스팸 표시를 붙인다(이미 붙어 있어도 같은 패치)
*/
const BLOCK_RUN_PROPERTIES = [
'id',
'mailboxIds',
'receivedAt',
FROM_ADDRESSES_PROPERTY,
];
/** 받은편지함이 없는 계정 — 할 메일이 없다 (필터 적용 · 차단 정리 모두) */
const NOTHING_TO_RUN: MailFilterRunOutcome = {
scanned: 0,
matched: 0,
remaining: 0,
cursor: null,
rounds: 0,
};
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
@@ -185,6 +218,37 @@ interface BodyBatch {
readonly ids: readonly string[];
}
/** 한 차례를 판정하고 할 일을 한다 — 필터 적용(applyRound) · 차단 정리(applyBlockRound). 멈춰야 하면 null */
type RoundRunner = (page: RoundPage) => Promise<RoundResult | null>;
/** 받은편지함을 차례로 훑은 결과 (sweep) */
interface SweepResult {
readonly scanned: number;
readonly matched: number;
readonly remaining: number;
/** 끝까지 했다 — 이어 할 자리가 없다 */
readonly finished: boolean;
/** 마지막으로 마친 차례까지의 자리 */
readonly position: RunState;
readonly rounds: number;
}
/** 판정 — true 맞음 · false 안 맞음 · 'gone' 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
type Verdict = boolean | 'gone';
/**
* 차단 정리(15단계)의 패치 — 받은편지함에서 빼고 스팸메일함에 넣고 스팸 표시(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다,
* jmap-mail.provider.ts 의 toEmailPatch)
*/
function blockPatch(inboxId: string, junkId: string): Record<string, unknown> {
return {
[`mailboxIds/${inboxId}`]: null,
[`mailboxIds/${junkId}`]: true,
'keywords/$junk': true,
'keywords/$notjunk': null,
};
}
/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */
function runPlan(
request: MailFilterRunRequest,
@@ -242,6 +306,41 @@ function utcDate(seconds: number): string {
return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString());
}
/**
* 한 차례를 마친 결과 — 판정한 수(받은편지함에서 빠진 메일은 빼고)와 받은편지함에 남긴 마지막 메일(다음 차례의 anchor).
* 옮긴 메일(moving 가운데 바뀐 것)과 그사이 없어진 메일은 남긴 것이 아니다
* @param moving 옮기기가 든 패치를 보낸 메일 — 바뀌면 받은편지함을 떠난다
* @param matched 이 차례에서 할 일을 한 수 (필터 적용은 이미 표시가 있던 메일 포함 · 차단 정리는 옮긴 수)
*/
function roundResult(
page: RoundPage,
verdicts: ReadonlyMap<string, Verdict>,
moving: ReadonlySet<string>,
applied: Applied,
matched: number,
): RoundResult {
let judged = 0;
let lastStayed: string | null = null;
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
judged,
matched,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
}
/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */
function newestSecond(page: RoundPage): number | null {
let newest: number | null = null;
@@ -305,6 +404,9 @@ function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] {
* 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다
* - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다)
* - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다
*
* 차단할 때 받은편지함 정리(runBlock — 15단계, 계약 2절 "보낸 사람 차단")도 같은 틀이다 — 차례 · 위 경계 · 자리 · 시간 · 멈춤 · 옮기기의 조건(ifInState) ·
* 붙잡힌 메일 막기는 그대로(sweep · readPage · applyPatches), 판정만 차단 규칙(Sieve 의 `address` — mail-block-match.ts)이고 할 일은 스팸메일함으로 옮기기 + 스팸 표시다
*/
@Injectable()
export class JmapFilterRunner {
@@ -318,17 +420,15 @@ export class JmapFilterRunner {
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
// 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001
let position: RunState =
const start: RunState =
request.cursor === null
? { before: null, anchor: null, anchorTime: null }
? FROM_TOP
: decodeFilterRunCursor(request.cursor, request);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
// 받은편지함이 없는 계정 — 적용할 메일이 없다
if (!inbox) {
return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 };
}
if (!inbox) return NOTHING_TO_RUN;
const plan = runPlan(request, inbox.id, context.mailboxes);
const fields = filterFields(request);
@@ -341,6 +441,78 @@ export class JmapFilterRunner {
withHeld: plan.moveTo !== null,
};
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
this.applyRound(user, request, plan, page, run),
);
if (!swept.finished) run.assertProgress('filter-run');
return this.outcomeOf(swept, (position) =>
encodeFilterRunCursor(position, request),
);
}
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함의 그 보낸 사람 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다.
* 판정은 엔진이 푼 From 머리글의 주소로 차단 규칙과 같게(mail-block-match.ts) — 머리글 원문을 읽지 않는다(표시 이름에 쓴 주소가 걸리지 않게).
* 맞은 메일은 늘 옮긴다(옮기기라 읽은 상태 그대로일 때만 — ifInState). 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로).
* 스팸메일함이 없으면 엔진에 묻기 전에 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 그 앞에 VAL_001.
* 응답의 matched 는 스팸메일함으로 옮긴 수다
*/
async runBlock(
user: string,
request: MailBlockRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
const start: RunState =
request.cursor === null
? FROM_TOP
: decodeBlockRunCursor(request.cursor, request.sender);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
if (!inbox) return NOTHING_TO_RUN;
const junk = context.mailboxes.find((mailbox) => mailbox.role === 'junk');
if (!junk) {
throw new BusinessException('MAIL_001', {
context: { reason: 'junk-mailbox-missing' },
});
}
const plan: RunPlan = {
inboxId: inbox.id,
moveTo: junk.id,
markAsRead: false,
flag: false,
};
const read: PageRead = {
properties: BLOCK_RUN_PROPERTIES,
needsBody: false,
limit: HEADER_ROUND_SIZE,
withHeld: true,
};
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
this.applyBlockRound(user, request.sender, plan, junk.id, page, run),
);
if (!swept.finished) run.assertProgress('mail-block-run');
return this.outcomeOf(swept, (position) =>
encodeBlockRunCursor(position, request.sender),
);
}
/**
* 받은편지함을 받은 시각이 최근인 메일부터 차례로 훑는다 — 차례마다 읽고(readPage) 판정 · 할 일(applyRound)을 한 뒤 자리를 옮긴다.
* 한 요청의 시간 · 멈춤은 BulkRun. 처음 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다(그 뒤에 온 메일은 보지 않는다).
* 남은 시간 때문에 끊긴 호출은 진행한 것이 있으면 거기까지로 답한다(cutOffAfterProgress)
*/
private async sweep(
user: string,
inbox: Mailbox,
start: RunState,
read: PageRead,
run: BulkRun,
applyRound: RoundRunner,
): Promise<SweepResult> {
let position = start;
let scanned = 0;
let matched = 0;
let rounds = 0;
@@ -359,7 +531,7 @@ export class JmapFilterRunner {
const newest = position.before === null ? newestSecond(page) : null;
if (newest !== null) position = { ...position, before: newest + 1 };
const round = await this.applyRound(user, request, plan, page, run);
const round = await applyRound(page);
// 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다)
if (round === null) break;
@@ -387,22 +559,38 @@ export class JmapFilterRunner {
} catch (error) {
if (!run.cutOffAfterProgress(error)) throw error;
}
if (!finished) run.assertProgress('filter-run');
return {
scanned,
matched,
remaining: finished ? 0 : remaining,
cursor: finished ? null : this.cursorOf(position, request),
finished,
position,
rounds,
};
}
/** 훑은 결과 → 응답 — 끝까지 했으면 cursor 없음, 아니면 마친 자리의 cursor (cursorOf) */
private outcomeOf(
swept: SweepResult,
encode: (position: FilterRunPosition) => string,
): MailFilterRunOutcome {
return {
scanned: swept.scanned,
matched: swept.matched,
remaining: swept.remaining,
cursor: swept.finished ? null : this.cursorOf(swept.position, encode),
rounds: swept.rounds,
};
}
/**
* 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001
* (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다)
*/
private cursorOf(position: RunState, request: MailFilterRunRequest): string {
private cursorOf(
position: RunState,
encode: (position: FilterRunPosition) => string,
): string {
if (
position.before === null ||
(position.anchor !== null && position.anchorTime === null)
@@ -411,14 +599,11 @@ export class JmapFilterRunner {
context: { reason: 'received-at-missing' },
});
}
return encodeFilterRunCursor(
{
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
},
request,
);
return encode({
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
});
}
/**
@@ -571,7 +756,7 @@ export class JmapFilterRunner {
run: BulkRun,
): Promise<RoundResult | null> {
/** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
const verdicts = new Map<string, boolean | 'gone'>();
const verdicts = new Map<string, Verdict>();
const undecided: JmapEmail[] = [];
const headerTexts = new Map<string, Omit<MailFilterTexts, 'body'>>();
for (const id of page.ids) {
@@ -641,30 +826,69 @@ export class JmapFilterRunner {
const applied: Applied | null =
patches.size === 0
? { updated: new Set(), gone: new Set() }
? NOTHING_APPLIED
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
let judged = 0;
let lastStayed: string | null = null;
return roundResult(
page,
verdicts,
moving,
applied,
unchanged + applied.updated.size,
);
}
/**
* 차단 정리의 한 차례 (15단계) — 엔진이 푼 From 주소로 판정하고(mail-block-match.ts) 맞은 메일을 스팸메일함으로 옮긴다(스팸 표시와 함께).
* 붙잡힌 메일(9단계)은 옮기지 않는다 — 받은편지함에 남은 것으로 센다. matched 는 옮긴 수다. 멈춰야 하면 null (applyPatches)
*/
private async applyBlockRound(
user: string,
sender: string,
plan: RunPlan,
junkId: string,
page: RoundPage,
run: BulkRun,
): Promise<RoundResult | null> {
const verdicts = new Map<string, Verdict>();
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
const email = page.emails.get(id);
verdicts.set(
id,
email?.mailboxIds?.[plan.inboxId] !== true
? 'gone'
: isBlockedSenderMail(fromAddressesOf(email), sender),
);
}
return {
judged,
matched: unchanged + applied.updated.size,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
const matchedIds = page.ids.filter((id) => verdicts.get(id) === true);
const held =
matchedIds.length === 0
? new Set<string>()
: await heldAmong(
page.held,
matchedIds,
this.heldLookup(user, run.callOptions()),
);
const patches = new Map<string, Record<string, unknown>>();
for (const id of matchedIds) {
if (!held.has(id)) patches.set(id, blockPatch(plan.inboxId, junkId));
}
const applied: Applied | null =
patches.size === 0
? NOTHING_APPLIED
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
return roundResult(
page,
verdicts,
new Set(patches.keys()),
applied,
applied.updated.size,
);
}
/**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -150,6 +150,8 @@ export interface JmapEmailSubmission {
id: string;
/** 이 제출이 보내는 메일 */
emailId?: string;
/** 이 제출의 발신 ID — 예약 메일 지금 보내기(15단계)가 같은 발신 ID 로 다시 제출한다 */
identityId?: string;
/**
* pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7).
* 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계)
@@ -0,0 +1,52 @@
import { asciiCasemap, isBlockedSenderMail } from './mail-block-match';
/**
* 차단할 때 받은편지함 정리(15단계)의 판정 — 새로 오는 메일의 차단 규칙(jmap/sieve-script.ts 의 blockSection — Sieve `address :all :is` ·
* `address :domain :is`, `i;ascii-casemap`)과 같은지. 표의 "엔진이 넓게 잡은 것"은 2026-11-09 개발 엔진의 `Email/query` from 조건이 잡은 메일이다(phase15 E3)
*/
describe('차단 정리의 판정 (mail-block-match — Sieve 의 address 와 짝)', () => {
it('ASCII 글자만 소문자로 — 그 밖의 글자는 그대로 (i;ascii-casemap)', () => {
expect(asciiCasemap('Kim@Spam.EXAMPLE')).toBe('kim@spam.example');
expect(asciiCasemap('ÄBC@Ü.example')).toBe('Äbc@Ü.example');
});
it.each<[string, string[], boolean]>([
['정확한 주소', ['kim@spam.example'], true],
['대문자 주소', ['KIM@SPAM.EXAMPLE'], true],
[
'보낸 사람 둘 가운데 둘째',
['first@example.net', 'kim@spam.example'],
true,
],
// 엔진 검색(from)은 잡지만 차단 규칙은 맞지 않는 것
['점이 더 붙은 주소', ['kim.lee@spam.example'], false],
['하위 도메인', ['kim@sub.spam.example'], false],
['더 긴 도메인', ['kim@spam.example.example.net'], false],
['앞에 글자가 더', ['akim@spam.example'], false],
['다른 도메인', ['kim@spam.example.net'], false],
['From 이 없음', [], false],
])('주소 차단 — %s', (_label, addresses, expected) => {
expect(isBlockedSenderMail(addresses, 'kim@spam.example')).toBe(expected);
});
it('주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다 — 엔진 규칙과 같은 한계', () => {
expect(isBlockedSenderMail(['ü@spam.example'], 'ü@spam.example')).toBe(
true,
);
expect(isBlockedSenderMail(['Ü@spam.example'], 'ü@spam.example')).toBe(
false,
);
});
it.each<[string, string[], boolean]>([
['그 도메인', ['kim@spam.example'], true],
['대문자 도메인', ['Lee@SPAM.Example'], true],
['둘째 보낸 사람이 그 도메인', ['a@example.net', 'b@spam.example'], true],
['하위 도메인', ['kim@sub.spam.example'], false],
['더 긴 도메인', ['kim@spam.example.net'], false],
['도메인이 이름 끝에만', ['kim@notspam.example'], false],
['@ 가 없는 주소', ['spam.example'], false],
])('도메인 차단 — %s', (_label, addresses, expected) => {
expect(isBlockedSenderMail(addresses, '@spam.example')).toBe(expected);
});
});
@@ -0,0 +1,40 @@
/**
* 차단할 때 받은편지함 정리(계약 2절 "보낸 사람 차단" — 15단계)의 판정 — 순수 함수.
* **짝: jmap/sieve-script.ts 의 blockSection**(새로 오는 메일의 차단 규칙) — `address :all :is "from" [주소들]` ·
* `address :domain :is "from" [도메인들]`, 비교는 엔진 기본 `i;ascii-casemap`(ASCII 대소문자만 무시). 한쪽을 바꾸면 다른 쪽도.
*
* 견주는 것은 엔진이 푼 From 머리글의 주소다(같은 이름의 머리글이 여럿이면 모두 — 표시 이름은 보지 않는다, jmap/filter-run-texts.ts 의 fromAddressesOf).
* 엔진의 검색(`Email/query` 의 from)은 쓰지 않는다 — 낱말 단위라 비슷한 주소(`kim.lee@` · `@sub.` · `.example.net`)와
* 표시 이름에만 그 주소를 쓴 남의 메일까지 잡는다(2026-11-09 개발 엔진 확인)
*/
/** ASCII 글자만 소문자로 — 엔진의 `i;ascii-casemap` (그 밖의 글자는 그대로 견준다) */
export function asciiCasemap(value: string): string {
return value.replace(/[A-Z]/g, (char) => char.toLowerCase());
}
/** 주소의 도메인 — 마지막 `@` 뒤 (없으면 빈 글) */
function domainOf(address: string): string {
const at = address.lastIndexOf('@');
return at < 0 ? '' : address.slice(at + 1);
}
/**
* 그 메일의 From 주소 가운데 하나라도 차단 대상에 맞는가 — 주소 차단(`kim@spam.example`)은 주소 전체가 같을 때,
* 도메인 차단(`@spam.example`)은 주소의 도메인이 같을 때(하위 도메인 · 더 긴 도메인은 아님)
* @param fromAddresses 엔진이 푼 From 머리글의 주소들
* @param sender 차단 목록의 값 (소문자 주소 또는 `@도메인` — BlockedSender.address)
*/
export function isBlockedSenderMail(
fromAddresses: readonly string[],
sender: string,
): boolean {
const wanted = asciiCasemap(sender);
if (wanted.startsWith('@')) {
const domain = wanted.slice(1);
return fromAddresses.some(
(address) => asciiCasemap(domainOf(address)) === domain,
);
}
return fromAddresses.some((address) => asciiCasemap(address) === wanted);
}
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
@@ -28,17 +29,23 @@ import {
MailBlockListDto,
MailBlockRemovedDto,
} from './dto/mail-block.dto';
import {
MailBlockRunDto,
MailBlockRunRequestDto,
} from './dto/mail-block-run.dto';
import { MailBlockService } from './mail-block.service';
import {
MAIL_BLOCK_RUNS_ROUTE,
MAIL_BLOCKS_ROUTE,
MAX_EMAIL_LENGTH,
MAX_MAIL_BLOCKS,
} from './mail.constants';
import { MailService } from './mail.service';
/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */
const BLOCK_NOTE =
'차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' +
'이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
'이미 받은 메일은 저절로 옮기지 않는다 — 받은편지함에 있는 것은 POST /api/mail-block-runs 로 옮긴다(15단계). 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계)
@ApiTags('Mail')
@@ -109,3 +116,52 @@ export class MailBlockController {
return this.blocks.remove(user, address);
}
}
// 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단", 15단계) — 이미 받은 메일에 필터 적용(7단계)과 같은 틀 · 같은 잠금
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_BLOCK_RUNS_ROUTE)
export class MailBlockRunController {
constructor(private readonly mail: MailService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로',
description:
'받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다($junk — 스팸 신고와 같다). 받은편지함만 본다(정리해 둔 메일함은 건드리지 않는다). ' +
'판정은 새로 오는 메일의 차단 규칙과 같다 — 주소는 머리글 From 의 주소 가운데 하나가 같을 때, 도메인은 From 주소의 @ 뒤가 그 도메인과 같을 때(하위 도메인은 아님), 대소문자는 ASCII 만 무시. ' +
'표시 이름에 쓴 주소 · 비슷한 주소는 맞지 않는다. 보내기를 기다리는 메일은 옮기지 않는다. 되돌리기는 없다(스팸메일함에서 스팸 아님으로). ' +
'받은 시각이 최근인 메일부터, 첫 요청 뒤에 온 메일은 보지 않는다. 한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' +
'같은 직원의 받은편지함 정리(이것 · POST /api/mail-filter-runs)는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001. ' +
'요청이 도는 동안 그 직원의 스팸메일함을 잡는다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429, 비우는 중이면 이 요청이 메일함 목록을 읽은 뒤 429(받은편지함은 건드리지 않았다).',
})
@ApiStandardResponse(MailBlockRunDto, {
description:
'이번 요청에서 확인한 수 · 스팸메일함으로 옮긴 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)',
})
@ApiErrorResponse(
'VAL_001',
`형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 깨진 cursor · 다른 보낸 사람의 cursor · 모르는 필드 — 옮기지 않았다`,
)
@ApiErrorResponse(
'RES_001',
'차단 목록에 없는 보낸 사람(차단한 뒤에만 부른다 · 그사이 풀었다) — 옮기지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 직원의 받은편지함 정리(필터 적용 포함)가 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다 · 스팸메일함 비우기가 진행 중 — 옮기지 않았다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 스팸메일함이 없음 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 옮겼을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다',
)
run(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailBlockRunRequestDto,
) {
return this.mail.runMailBlock(user, dto);
}
}
@@ -6,19 +6,14 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { MailBlockCreateDto } from './dto/mail-block.dto';
import {
describeBlockTarget,
findBlockProblem,
parseBlockTarget,
type BlockTarget,
} from './mail-blocks';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type { MailBlockAdded, MailBlockList } from './mail.types';
/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */
function maskedTarget(target: BlockTarget): string {
return target.kind === 'domain' ? target.value : maskEmail(target.value);
}
/**
* 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다.
* - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001)
@@ -65,7 +60,7 @@ export class MailBlockService {
});
if (result.created) {
this.logger.log(
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
);
}
return result;
@@ -84,7 +79,7 @@ export class MailBlockService {
}
await this.provider.removeMailBlock(user.email, target.value);
this.logger.log(
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
);
return { address: target.value };
}
+11
View File
@@ -1,6 +1,7 @@
import { isEmail } from 'class-validator';
import { isIP } from 'net';
import { maskEmail } from '@/shared/utils/mask';
import {
MAX_EMAIL_LENGTH,
PUBLIC_MAIL_DOMAINS,
@@ -88,6 +89,16 @@ export function blockTargetDomain(target: BlockTarget): string {
return target.value.slice(target.value.lastIndexOf('@') + 1);
}
/**
* 로그에 남기는 차단 대상 — `주소=k***@spam.example` · `도메인=@spam.example` (계약 2절 "보낸 사람 차단"의 로그 — 주소는 가리고(maskEmail),
* 도메인 차단은 도메인만). 차단 · 풀기 · 받은편지함 정리(15단계)가 같이 쓴다
*/
export function describeBlockTarget(target: BlockTarget): string {
return target.kind === 'domain'
? `도메인=${target.value}`
: `주소=${maskEmail(target.value)}`;
}
/** 사내 도메인이거나 그 하위 도메인인가 */
function isInternalDomain(domain: string, internal: string): boolean {
return domain === internal || domain.endsWith(`.${internal}`);
@@ -147,6 +147,7 @@ function setup() {
url: shareUrl(ORIGIN, TOKEN),
}),
markSent: jest.fn().mockResolvedValue(undefined),
markReleased: jest.fn().mockResolvedValue(true),
cancel: jest.fn().mockResolvedValue(undefined),
attachRetry: jest.fn().mockResolvedValue(undefined),
findBySentEmail: jest.fn().mockResolvedValue(null),
@@ -389,6 +390,31 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
FILE_B,
]);
});
it('예약 메일을 지금 보냈으면(15단계) 그 링크의 보낸 때를 앞당긴다 — 링크가 없거나 취소한 링크면 그대로, 실패해도 던지지 않는다', async () => {
const { service, sharing } = setup();
const now = new Date('2026-11-09T05:00:00.000Z');
sharing.findBySentEmail.mockResolvedValue(record());
await service.onScheduledSendReleased(ARA, 'sent-1', now);
expect(sharing.findBySentEmail).toHaveBeenCalledWith(ARA.email, 'sent-1');
expect(sharing.markReleased).toHaveBeenCalledWith('link-1', now);
sharing.markReleased.mockClear();
for (const found of [
null,
record({ canceledAt: new Date('2026-11-09T04:00:00.000Z') }),
]) {
sharing.findBySentEmail.mockResolvedValueOnce(found);
await service.onScheduledSendReleased(ARA, 'sent-1', now);
}
expect(sharing.markReleased).not.toHaveBeenCalled();
sharing.findBySentEmail.mockRejectedValueOnce(new Error('db down'));
await expect(
service.onScheduledSendReleased(ARA, 'sent-1', now),
).resolves.toBeUndefined();
});
});
describe('읽기 — 덩이 알아보기', () => {
@@ -303,6 +303,29 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* 예약 메일을 지금 보냈다(15단계 — 엔진에서 다시 제출한 뒤) — 그 발송이 만든 링크의 보낸 때를 실제로 보낸 때로 앞당긴다.
* 끝나는 때는 그대로 둔다 — 보낸 메일의 덩이가 그 날짜(예약 시각 기준)를 이미 적었다(FileSharingService.markReleased). 링크가 없거나 취소한 링크면 그대로.
* 실패해도 보내기는 된 것이다 — 경고만
*/
async onScheduledSendReleased(
user: AuthenticatedUser,
sentId: string,
sentAt: Date,
): Promise<void> {
try {
const record = await this.sharing.findBySentEmail(user.email, sentId);
if (!record || record.link.canceledAt !== null) return;
await this.sharing.markReleased(record.link.id, sentAt);
} catch (error) {
this.warn(
'지금 보낸 예약 메일의 링크에 보낸 때를 적지 못했습니다',
user,
error,
);
}
}
// ── 임시 저장 ────────────────────────────────────────────────────────────
/** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */
@@ -151,6 +151,9 @@ export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000;
/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */
export const MAIL_BLOCKS_ROUTE = 'mail-blocks';
/** 차단할 때 받은편지함 정리 라우트 경로 (`/api/mail-block-runs`) — 계약 2절 "보낸 사람 차단" (15단계) */
export const MAIL_BLOCK_RUNS_ROUTE = 'mail-block-runs';
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */
export const MAX_MAIL_BLOCKS = 500;
+6 -1
View File
@@ -34,7 +34,10 @@ import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailBlockController } from './mail-block.controller';
import {
MailBlockController,
MailBlockRunController,
} from './mail-block.controller';
import { MailBlockService } from './mail-block.service';
import { MailEventsController } from './mail-events.controller';
import {
@@ -148,6 +151,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailFilterController,
MailFilterRunController,
MailBlockController,
// 차단할 때 받은편지함 정리 (15단계 — 필터 적용과 같은 잠금)
MailBlockRunController,
MessageController,
// 같은 대화 · 대화로 묶은 목록 (14단계)
ConversationController,
+61 -3
View File
@@ -13,9 +13,12 @@ import type {
MailAttachment,
MailBlockAdded,
MailBlockList,
MailBlockRunRequest,
Mailbox,
MailboxBulkResult,
MailboxChange,
MailboxCreate,
MailboxDeleteOptions,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
@@ -40,7 +43,9 @@ import type {
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
ReleasedScheduledSend,
SavedDraft,
SendHold,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
@@ -68,6 +73,14 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
*/
export class SendNotStartedException extends BusinessException {}
/**
* 예약 메일 지금 보내기(15단계)가 끝내 보내지 못해 그 메일을 임시보관함의 초안으로 **되돌렸다** — 예약 취소(DELETE /api/scheduled-messages/{id})와 같은 자리다.
* 되돌리기까지 된 때만 이 형식이다(엔진이 다시 제출을 거듭 거절함 MAIL_001 · 받는 사람이 모두 받지 않음 MAIL_010 · MAIL_011). 코드 · 응답은 BusinessException 그대로다.
* 결과를 모르는 실패 · 남은 시간 모자람 · 되돌리기 실패는 그냥 BusinessException(MAIL_001)이다 — 메일은 보낸편지함에 그대로다.
* 서비스는 이 형식으로 갈라 대용량 첨부 링크를 취소한 발송처럼 둔다(되돌린 초안이 그 파일을 쓴다) — context 는 로그 전용이라 갈래에 쓰지 않는다
*/
export class ScheduledSendReturnedToDraftsException extends BusinessException {}
/**
* 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다
* (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다.
@@ -148,16 +161,30 @@ export interface MailProvider {
*/
createMailbox(user: string, input: MailboxCreate): Promise<Mailbox>;
/** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */
renameMailbox(user: string, id: string, name: string): Promise<Mailbox>;
/**
* 메일함의 이름 · 상위를 바꾼다 (15단계 — 옮기기) — 보내지 않은 칸은 그대로. 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001,
* 새 상위가 없으면 RES_001 · 만들 수 없는 자리(만들기와 같다)이거나 자기 자신 · 자기 하위면 VAL_001, 새 자리에 같은 이름이 있으면 MAIL_009.
* 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 하위 메일함과 안의 메일은 함께 간다(메일함 id 는 그대로다)
*/
updateMailbox(
user: string,
id: string,
change: MailboxChange,
): Promise<Mailbox>;
/**
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
* `withChildren` 이면(15단계) 하위가 있어도 나무 전체(그 메일함과 하위 모두)를 같은 규칙으로 — 메일을 옮긴 뒤 깊은 것부터 지운다.
* 하위에 역할이 있는 메일함이 있으면 VAL_001. 수(processed · movedToTrash · remaining)는 나무 전체의 것이다.
* 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015
*/
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
deleteMailbox(
user: string,
id: string,
options?: MailboxDeleteOptions,
): Promise<MailboxDeletion>;
/** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */
markMailboxRead(user: string, id: string): Promise<MailboxBulkResult>;
@@ -224,6 +251,18 @@ export interface MailProvider {
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 한다.
* 판정은 새로 오는 메일의 차단 규칙(Sieve 의 `address` 판정 — mail-block-match.ts)과 같다. 차례 · 시간 · cursor 는 runMailFilter 와 같은 규칙이다
* (matched = 스팸메일함으로 옮긴 수). 스팸메일함이 없으면 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 엔진에 묻기 전에 VAL_001.
* 차단 목록에 있는지는 서비스가 본 값이다. 받은편지함 · 스팸메일함은 context.mailboxes 에서 찾는다
*/
runMailBlock(
user: string,
request: MailBlockRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
@@ -391,6 +430,25 @@ export interface MailProvider {
*/
cancelPendingSend(user: string, id: string): Promise<void>;
/**
* 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — `id` 는 보낸 사본의 메일 id.
* 기다리는 예약 제출을 취소하고(엔진이 확인한 뒤에만) 같은 메일을 그 제출의 envelope 그대로 다시 제출한다 — `hold` 만큼 붙잡는다(보내기 취소 설정 — null 이면 바로).
* 예약 표시를 떼고, 받는 사람별 결과는 발송과 같은 규칙으로 읽는다.
* - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) · 없는 메일 → RES_001 (아무것도 바꾸지 않는다)
* - 이미 보냈다(예약 시각이 막 지남 — 제출이 final · 취소하려니 엔진이 받지 않음) → 오류가 아니다: pendingSend null (releasedAt null)
* - 이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답이 끊겨 다시 보냄) → 그 새 제출의 지금 상태
* - 엔진이 다시 제출을 분명히 거절하면 한 번 더 — 그래도 거절이면 그 메일을 임시보관함으로 되돌린 뒤 ScheduledSendReturnedToDraftsException(MAIL_001).
* 받는 사람이 모두 받지 않았어도 임시보관함으로 되돌린 뒤 같은 예외(MAIL_010 · MAIL_011). 되돌리지 못하면 그냥 MAIL_001
* - 다시 제출의 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 · 되돌리지도 않는다 — 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 MAIL_001.
* 남은 시간이 모자라 내지 않았어도 그냥 MAIL_001 — 둘 다 메일은 예약이 취소된 채 보낸편지함에 있고, 같은 요청을 다시 받으면 이어 낸다
* - 예약 제출의 envelope · 발신 ID 를 읽지 못하거나 엔진이 취소를 확인해 주지 않으면 MAIL_001 — 취소하지 않았거나 취소를 확인하지 못해 다시 제출하지 않았다
*/
releaseScheduledSend(
user: string,
id: string,
hold: SendHold | null,
): Promise<ReleasedScheduledSend>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
+358 -8
View File
@@ -9,6 +9,7 @@ import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-
import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types';
import type { ContactsService } from '@/modules/contacts/contacts.service';
import type { DirectoryService } from '@/modules/contacts/directory.service';
import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
@@ -82,7 +83,7 @@ function fakeProvider(): ProviderMocks {
getProfile: mockOf<'getProfile'>(),
listMailboxes: mockOf<'listMailboxes'>(),
createMailbox: mockOf<'createMailbox'>(),
renameMailbox: mockOf<'renameMailbox'>(),
updateMailbox: mockOf<'updateMailbox'>(),
deleteMailbox: mockOf<'deleteMailbox'>(),
markMailboxRead: mockOf<'markMailboxRead'>(),
emptyMailbox: mockOf<'emptyMailbox'>(),
@@ -91,6 +92,7 @@ function fakeProvider(): ProviderMocks {
getMailFilters: mockOf<'getMailFilters'>(),
setMailFilters: mockOf<'setMailFilters'>(),
runMailFilter: mockOf<'runMailFilter'>(),
runMailBlock: mockOf<'runMailBlock'>(),
openMessageSource: mockOf<'openMessageSource'>(),
getMessageSubjects: mockOf<'getMessageSubjects'>(),
getUsage: mockOf<'getUsage'>(),
@@ -132,6 +134,7 @@ function fakeProvider(): ProviderMocks {
removeMailBlock: mockOf<'removeMailBlock'>(),
listScheduledMessages: mockOf<'listScheduledMessages'>(),
cancelPendingSend: mockOf<'cancelPendingSend'>(),
releaseScheduledSend: mockOf<'releaseScheduledSend'>(),
};
}
@@ -544,7 +547,7 @@ describe('MailService', () => {
it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => {
provider.createMailbox.mockResolvedValue(mailbox);
provider.renameMailbox.mockResolvedValue({
provider.updateMailbox.mockResolvedValue({
...mailbox,
id: 'a',
name: 'Inbox',
@@ -562,13 +565,34 @@ describe('MailService', () => {
// (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다
await expect(
service.renameMailbox(user, 'a', { name: '새 이름' }),
service.updateMailbox(user, 'a', { name: '새 이름' }),
).resolves.toMatchObject({ name: '받은편지함' });
expect(provider.renameMailbox).toHaveBeenCalledWith(
user.email,
'a',
'새 이름',
);
expect(provider.updateMailbox).toHaveBeenCalledWith(user.email, 'a', {
name: '새 이름',
});
});
it('메일함 바꾸기(15단계) — 이름 · 상위 가운데 보낸 것만 넘긴다, 둘 다 없으면 엔진에 묻지 않고 VAL_001', async () => {
provider.updateMailbox.mockResolvedValue(mailbox);
expect(
await failureOf(() => service.updateMailbox(user, 'n1', {})),
).toEqual({
code: 'VAL_001',
reason: 'empty-mailbox-update',
});
expect(provider.updateMailbox).not.toHaveBeenCalled();
await service.updateMailbox(user, 'n1', { parentId: null });
await service.updateMailbox(user, 'n1', { parentId: 'u1' });
await service.updateMailbox(user, 'n1', { name: '2025', parentId: 'u1' });
expect(provider.updateMailbox.mock.calls).toEqual([
[user.email, 'n1', { parentId: null }],
[user.email, 'n1', { parentId: 'u1' }],
[user.email, 'n1', { name: '2025', parentId: 'u1' }],
]);
// 옮긴 로그는 어댑터가 실제로 옮겼을 때만 남긴다 (지금 자리 그대로면 남기지 않게 — jmap-mail.provider.spec.ts)
expect(logSpy).not.toHaveBeenCalled();
});
it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => {
@@ -598,6 +622,37 @@ describe('MailService', () => {
expect(line).toContain('휴지통으로 옮김=7통');
expect(line).toContain('이 메일함에서만 뺌=2통');
expect(line).not.toContain(user.email);
// 하위까지가 아니면 메일함 목록을 읽지 않고, 한 요청의 시간은 서비스가 받은 때부터 (startedAt)
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.deleteMailbox).toHaveBeenLastCalledWith(
user.email,
'u4',
{
startedAt: expect.any(Number) as number,
},
);
});
it('하위 메일함까지 지우기(15단계 — withChildren=true)는 메일함 목록으로 나무를 정해 넘긴다', async () => {
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
{ ...mailbox, id: 'u3' },
]);
provider.deleteMailbox.mockResolvedValue({
deleted: true,
processed: 3,
movedToTrash: 3,
remaining: 0,
});
await service.deleteMailbox(user, 'u1', { withChildren: 'true' });
expect(provider.deleteMailbox).toHaveBeenCalledWith(user.email, 'u1', {
withChildren: true,
startedAt: expect.any(Number) as number,
});
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('하위까지(메일함 2개)');
});
it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => {
@@ -712,6 +767,103 @@ describe('MailService', () => {
provider.emptyMailbox.mockResolvedValueOnce(emptied);
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied);
});
it('하위까지 지우기는 나무의 메일함 모두를 잡는다 — 하위 메일함의 처리 · 그 안으로 옮기기는 REQ_001, 나무 밖은 된다 (15단계)', async () => {
const deletion = {
deleted: true,
processed: 0,
movedToTrash: 0,
remaining: 0,
};
const first = pending<typeof deletion>();
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
{ ...mailbox, id: 'u3' },
]);
provider.deleteMailbox.mockReturnValueOnce(first.promise);
provider.markMailboxRead.mockResolvedValue({
processed: 0,
remaining: 0,
});
provider.updateMailbox.mockResolvedValue(mailbox);
const running = service.deleteMailbox(user, 'u1', {
withChildren: 'true',
});
// 메일함 목록(엔진)을 지나 어댑터에 들어갈 때까지 기다린다
await new Promise((resolve) => setImmediate(resolve));
expect(provider.deleteMailbox).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.updateMailboxMessages(user, 'u2', { isRead: true }),
() => service.emptyMailbox(user, 'u2'),
// 지우는 나무 안으로 옮기기 · 지우는 나무의 메일함을 빼 가기
() => service.updateMailbox(user, 'u3', { parentId: 'u2' }),
() => service.updateMailbox(user, 'u2', { parentId: null }),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'mailbox-operation-in-progress',
});
}
expect(provider.updateMailbox).not.toHaveBeenCalled();
// 나무 밖은 된다 — 이름만 바꾸기는 잠그지 않는다(지금처럼)
await service.updateMailboxMessages(user, 'u3', { isRead: true });
await service.updateMailbox(user, 'u2', { name: '새 이름' });
expect(provider.updateMailbox).toHaveBeenCalledTimes(1);
first.finish(deletion);
await running;
await service.updateMailbox(user, 'u3', { parentId: 'u1' });
expect(provider.updateMailbox).toHaveBeenCalledTimes(2);
});
it('하위까지 지우기는 그 메일함을 먼저 잡는다 — 그 메일함이 처리 중이면 목록을 읽지 않고 REQ_001, 하위가 처리 중이면 목록만 읽고 REQ_001 (15단계)', async () => {
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
]);
const busy = pending<{ processed: number; remaining: number }>();
provider.markMailboxRead.mockReturnValueOnce(busy.promise);
// 그 메일함이 처리 중 — 엔진에 묻지 않는다
const rootBusy = service.updateMailboxMessages(user, 'u1', {
isRead: true,
});
expect(
await failureOf(() =>
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.listMailboxes).not.toHaveBeenCalled();
busy.finish({ processed: 0, remaining: 0 });
await rootBusy;
// 하위가 처리 중 — 목록만 읽고 지우지 않는다, 그 메일함의 잠금은 푼다
const child = pending<{ processed: number; remaining: number }>();
provider.markMailboxRead.mockReturnValueOnce(child.promise);
const childBusy = service.updateMailboxMessages(user, 'u2', {
isRead: true,
});
expect(
await failureOf(() =>
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
expect(provider.deleteMailbox).not.toHaveBeenCalled();
provider.markMailboxRead.mockResolvedValueOnce({
processed: 0,
remaining: 0,
});
await expect(
service.updateMailboxMessages(user, 'u1', { isRead: true }),
).resolves.toEqual({ processed: 0, remaining: 0 });
child.finish({ processed: 0, remaining: 0 });
await childBusy;
});
});
describe('부재중 자동 응답 저장', () => {
@@ -1456,6 +1608,204 @@ describe('MailService', () => {
});
});
describe('차단할 때 받은편지함 정리 (15단계)', () => {
const outcome = {
scanned: 500,
matched: 12,
remaining: 40,
cursor: 'next-cursor',
rounds: 1,
};
const blocked = (address: string) => ({
address,
blockedAt: '2026-11-09T01:00:00.000Z',
});
const mailboxes: Mailbox[] = [
{
id: 'a',
name: 'Inbox',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 540,
unreadCount: 0,
},
{
id: 'c',
name: 'Junk Mail',
role: 'junk',
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
];
const body = (
sender: string,
cursor: string | null = null,
): MailBlockRunRequestDto => ({ sender, cursor });
/** 손으로 끝내는 정리 — 끝낼 때까지 진행 중이다 */
function pending<T>(): {
promise: Promise<T>;
finish: (value: T) => void;
} {
let finish!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
finish = resolve;
});
return { promise, finish };
}
beforeEach(() => {
provider.getMailBlocks.mockResolvedValue({
senders: [blocked('kim@spam.example'), blocked('@spam.example')],
isActive: true,
});
provider.listMailboxes.mockResolvedValue(mailboxes);
provider.runMailBlock.mockResolvedValue(outcome);
});
it('차단 목록에 있는 보낸 사람이면 메일함 목록과 함께 넘기고 결과를 돌려준다 — 대소문자 · 앞뒤 공백은 맞춘다, 로그에는 가린 주소 · 수만', async () => {
await expect(
service.runMailBlock(user, body(' Kim@Spam.Example ')),
).resolves.toEqual({
scanned: 500,
matched: 12,
remaining: 40,
cursor: 'next-cursor',
});
expect(provider.runMailBlock).toHaveBeenCalledWith(
user.email,
{ sender: 'kim@spam.example', cursor: null },
{ mailboxes, startedAt: expect.any(Number) as number },
);
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('확인 500통 · 옮김 12통 · 남음 40통');
expect(line).not.toContain('kim@spam.example');
expect(line).not.toContain('next-cursor');
expect(line).not.toContain(user.email);
// 도메인 차단은 도메인만 남긴다
await service.runMailBlock(user, body('@spam.example', 'next-cursor'));
expect(provider.runMailBlock).toHaveBeenLastCalledWith(
user.email,
{ sender: '@spam.example', cursor: 'next-cursor' },
expect.any(Object),
);
const [domainLine] = logSpy.mock.calls.at(-1) as [string];
expect(domainLine).toContain('도메인=@spam.example');
});
it('차단 목록에 없으면 RES_001 — 받은편지함을 읽지 않는다 · 모양이 틀리면 VAL_001 (엔진에 묻지 않는다)', async () => {
expect(
await failureOf(() =>
service.runMailBlock(user, body('lee@spam.example')),
),
).toEqual({ code: 'RES_001', reason: 'mail-block-not-found' });
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailBlock).not.toHaveBeenCalled();
provider.getMailBlocks.mockClear();
expect(
await failureOf(() =>
service.runMailBlock(user, body('not an address')),
),
).toEqual({ code: 'VAL_001', reason: 'malformed-mail-block-run-sender' });
expect(provider.getMailBlocks).not.toHaveBeenCalled();
});
it('차단 정리가 도는 동안 그 직원의 스팸메일함을 잡는다 — 비우기 · 전체 읽음은 REQ_001, 비우는 중이면 차단 정리가 목록을 읽은 뒤 REQ_001 (받은편지함 정리 잠금도 푼다)', async () => {
const first = pending<typeof outcome>();
provider.runMailBlock.mockReturnValueOnce(first.promise);
provider.emptyMailbox.mockResolvedValue({ processed: 0, remaining: 0 });
provider.markMailboxRead.mockResolvedValue({
processed: 0,
remaining: 0,
});
const running = service.runMailBlock(user, body('kim@spam.example'));
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.emptyMailbox(user, 'c'),
() => service.updateMailboxMessages(user, 'c', { isRead: true }),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'mailbox-operation-in-progress',
});
}
expect(provider.emptyMailbox).not.toHaveBeenCalled();
// 다른 메일함은 된다
await expect(
service.updateMailboxMessages(user, 'b', { isRead: true }),
).resolves.toEqual({ processed: 0, remaining: 0 });
first.finish(outcome);
await running;
// 스팸메일함을 비우는 중이면 차단 정리가 REQ_001 — 받은편지함을 건드리지 않는다
const emptying = pending<{ processed: number; remaining: number }>();
provider.emptyMailbox.mockReturnValueOnce(emptying.promise);
const empty = service.emptyMailbox(user, 'c');
provider.runMailBlock.mockClear();
expect(
await failureOf(() =>
service.runMailBlock(user, body('kim@spam.example')),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.runMailBlock).not.toHaveBeenCalled();
// 받은편지함 정리 잠금(filterRuns)은 풀렸다 — 필터 적용은 된다
provider.runMailFilter.mockResolvedValueOnce(outcome);
await expect(
service.runMailFilter(user, {
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
cursor: null,
} as unknown as MailFilterRunRequestDto),
).resolves.toMatchObject({ matched: 12 });
emptying.finish({ processed: 0, remaining: 0 });
await empty;
await expect(
service.runMailBlock(user, body('kim@spam.example')),
).resolves.toMatchObject({ matched: 12 });
});
it('같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 REQ_001', async () => {
const first = pending<typeof outcome>();
provider.runMailBlock.mockReturnValueOnce(first.promise);
provider.runMailFilter.mockResolvedValue(outcome);
const running = service.runMailBlock(user, body('kim@spam.example'));
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
expect(
await failureOf(() =>
service.runMailBlock(user, body('@spam.example')),
),
).toMatchObject({ code: 'REQ_001' });
expect(
await failureOf(() =>
service.runMailFilter(user, {
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
cursor: null,
} as unknown as MailFilterRunRequestDto),
),
).toMatchObject({ code: 'REQ_001' });
expect(provider.runMailFilter).not.toHaveBeenCalled();
first.finish(outcome);
await running;
await expect(
service.runMailBlock(user, body('kim@spam.example')),
).resolves.toMatchObject({ matched: 12 });
});
});
describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => {
it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => {
const state = {
+138 -28
View File
@@ -30,13 +30,16 @@ import type {
MessageDeletionDto,
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { MailVacationDto } from './dto/mail-vacation.dto';
import type {
CreateMailboxDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
import {
WITH_CHILDREN,
type CreateMailboxDto,
type MailboxDeleteQueryDto,
type MailboxMessagesPatchDto,
type UpdateMailboxDto,
} from './dto/mailbox.dto';
import {
SHOW_IMAGES,
@@ -80,6 +83,7 @@ import {
SCHEDULED_SEND_MAX_LEAD_MS,
SCHEDULED_SEND_MIN_LEAD_MS,
} from './mail.constants';
import { describeBlockTarget, parseBlockTarget } from './mail-blocks';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
@@ -118,6 +122,7 @@ import type {
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { subtreeIds } from './mailbox-tree';
import { highlightDetail } from './message-highlight';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import {
@@ -131,6 +136,7 @@ import {
type LargeFilePlan,
type RecognizedLargeFiles,
} from './mail-large-files.service';
import { undoSendHold } from './send-hold';
/**
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
@@ -336,13 +342,15 @@ export class MailService {
/**
* 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다.
* 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다.
* 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 잡는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly mailboxOperations = new Set<string>();
/**
* 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* 진행 중인 받은편지함 정리 — "이미 받은 메일에 필터 적용"(7단계)과 차단할 때 받은편지함 정리(15단계)의 직원. 한 직원에 하나씩만, 종류가 달라도 막는다
* (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 정리가 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly filterRuns = new Set<string>();
@@ -415,32 +423,75 @@ export class MailService {
);
}
/** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */
async renameMailbox(
/**
* 메일함 이름 바꾸기 · 옮기기 (15단계) — 이름 · 상위 가운데 보낸 것만 바꾼다. 둘 다 없으면 엔진에 묻지 않고 VAL_001.
* 기본 메일함(역할 있음) · 만들 수 없는 자리 · 자기 하위 아래는 VAL_001, 없는 새 상위는 RES_001, 같은 이름은 MAIL_009 (어댑터).
* 옮길 때(parentId 가 있을 때)는 그 메일함과 새 상위의 오래 걸리는 처리와 함께 하지 않는다 — 진행 중이면 REQ_001 (runExclusive — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게).
* 옮긴 로그는 어댑터가 남긴다 — 실제로 옮겼을 때만(지금 자리 그대로면 엔진에 묻지 않아 남기지 않는다), 메일함 id 만
*/
async updateMailbox(
user: AuthenticatedUser,
id: string,
dto: RenameMailboxDto,
dto: UpdateMailboxDto,
): Promise<Mailbox> {
const { name, parentId } = dto;
if (name === undefined && parentId === undefined) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-mailbox-update' },
});
}
if (parentId === undefined) {
return presentMailbox(
await this.provider.updateMailbox(user.email, id, { name }),
);
}
const keys = parentId === null ? [id] : [id, parentId];
return presentMailbox(
await this.provider.renameMailbox(user.email, id, dto.name),
await this.runExclusive(user, keys, () =>
this.provider.updateMailbox(user.email, id, {
...(name === undefined ? {} : { name }),
parentId,
}),
),
);
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false.
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive).
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 엔진에 묻지 않고 REQ_001 — runExclusive). 한 요청의 시간(25초)은 여기 들어온 때부터 센다.
* 하위 메일함까지 지우기(withChildren=true — 15단계)는 나무(그 메일함과 하위 모두)를 지운다 — 그 메일함을 먼저 잡고(진행 중이면 엔진에 묻지 않고 REQ_001)
* 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 REQ_001 — 아무것도 바꾸지 않는다).
* 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다)
*/
async deleteMailbox(
user: AuthenticatedUser,
id: string,
query: MailboxDeleteQueryDto = {},
): Promise<MailboxDeletion> {
const result = await this.runExclusive(user, id, () =>
this.provider.deleteMailbox(user.email, id),
);
const startedAt = Date.now();
const withChildren = query.withChildren === WITH_CHILDREN;
let treeSize = 1;
const result = await this.runExclusive(user, [id], async () => {
if (!withChildren) {
return this.provider.deleteMailbox(user.email, id, { startedAt });
}
// 나무의 하위 메일함 — 없는 메일함이면 없음(어댑터가 RES_001)
const descendants = subtreeIds(
await this.provider.listMailboxes(user.email),
id,
).filter((mailboxId) => mailboxId !== id);
treeSize = descendants.length + 1;
return this.runExclusive(user, descendants, () =>
this.provider.deleteMailbox(user.email, id, {
withChildren,
startedAt,
}),
);
});
if (result.deleted) {
this.logger.log(
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, ${withChildren ? `하위까지(메일함 ${treeSize}개), ` : ''}이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
);
}
return result;
@@ -461,7 +512,7 @@ export class MailService {
context: { reason: 'unsupported-mailbox-messages-change' },
});
}
return this.runExclusive(user, id, () =>
return this.runExclusive(user, [id], () =>
this.provider.markMailboxRead(user.email, id),
);
}
@@ -474,7 +525,7 @@ export class MailService {
user: AuthenticatedUser,
id: string,
): Promise<MailboxBulkResult> {
const result = await this.runExclusive(user, id, () =>
const result = await this.runExclusive(user, [id], () =>
this.provider.emptyMailbox(user.email, id),
);
if (result.processed > 0) {
@@ -648,6 +699,61 @@ export class MailService {
);
}
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮긴다.
* 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 한 요청의 시간(25초)은 여기 들어온 때부터, 이어 할 자리(cursor)는 어댑터가 만든다.
* - 보낸 사람은 차단 목록의 모양(주소 · `@도메인`)이어야 한다 — 아니면 엔진에 묻지 않고 VAL_001
* - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금(filterRuns), 진행 중이면 엔진에 묻지 않고 REQ_001
* - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) RES_001 — 요청마다 본다(도중에 풀면 다음 요청에서 멈춘다)
* - 요청이 도는 동안 그 직원의 스팸메일함을 메일함 처리 잠금(mailboxOperations)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 모두 읽음이 함께 돌지 않게.
* 비우는 중이면 메일함 목록을 읽은 뒤 REQ_001 (받은편지함은 건드리지 않았다)
* 로그에는 가린 보낸 사람(도메인 차단은 도메인만) · 확인 · 옮긴 수 · 차례 수 · 걸린 시간만 남긴다
*/
async runMailBlock(
user: AuthenticatedUser,
dto: MailBlockRunRequestDto,
): Promise<MailFilterRun> {
const startedAt = Date.now();
const target = parseBlockTarget(dto.sender);
if (target === null) {
throw new BusinessException('VAL_001', {
context: { reason: 'malformed-mail-block-run-sender' },
});
}
return this.exclusive(
this.filterRuns,
user.email.toLowerCase(),
'mail-block-run-in-progress',
user,
async () => {
const { senders } = await this.provider.getMailBlocks(user.email);
if (!senders.some(({ address }) => address === target.value)) {
throw new BusinessException('RES_001', {
context: { reason: 'mail-block-not-found', kind: target.kind },
});
}
const mailboxes = await this.provider.listMailboxes(user.email);
// 스팸메일함이 없으면 잡을 것이 없다 — 어댑터가 MAIL_001 로 멈춘다
const junk = mailboxes.find((mailbox) => mailbox.role === 'junk');
const { rounds, ...result } = await this.runExclusive(
user,
junk ? [junk.id] : [],
() =>
this.provider.runMailBlock(
user.email,
{ sender: target.value, cursor: dto.cursor },
{ mailboxes, startedAt },
),
);
this.logger.log(
`${traceTag()} 차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로 옮겼습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}, 확인 ${result.scanned}통 · 옮김 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`,
);
return result;
},
);
}
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
async openMessageSource(
user: AuthenticatedUser,
@@ -974,12 +1080,10 @@ export class MailService {
);
}
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다 — send-hold.ts) */
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
const { undoSendSeconds } = await this.userSettingsOf(user);
return undoSendSeconds > 0
? { kind: 'undo', seconds: undoSendSeconds }
: null;
return undoSendHold(undoSendSeconds);
}
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
@@ -1091,15 +1195,19 @@ export class MailService {
);
}
/** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive).
* 메일함 여럿(하위까지 지우기의 나무 · 옮기기의 그 메일함과 새 상위 — 15단계)은 모두 비어 있을 때만 한꺼번에 잡는다
*/
private runExclusive<T>(
user: AuthenticatedUser,
mailboxId: string,
mailboxIds: readonly string[],
operation: () => Promise<T>,
): Promise<T> {
const owner = user.email.toLowerCase();
return this.exclusive(
this.mailboxOperations,
`${user.email.toLowerCase()}\n${mailboxId}`,
mailboxIds.map((mailboxId) => `${owner}\n${mailboxId}`),
'mailbox-operation-in-progress',
user,
operation,
@@ -1108,25 +1216,27 @@ export class MailService {
/**
* 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양).
* 열쇠가 여럿이면 하나라도 잡혀 있을 때 REQ_001 이고, 모두 비었으면 await 없이 한꺼번에 잡는다(사이에 다른 요청이 끼지 않는다).
* 끝나면 성공 · 실패와 상관없이 푼다
*/
private async exclusive<T>(
running: Set<string>,
key: string,
keys: string | readonly string[],
reason: string,
user: AuthenticatedUser,
operation: () => Promise<T>,
): Promise<T> {
if (running.has(key)) {
const held = [...new Set(typeof keys === 'string' ? [keys] : keys)];
if (held.some((key) => running.has(key))) {
throw new BusinessException('REQ_001', {
context: { reason, user: maskEmail(user.email) },
});
}
running.add(key);
for (const key of held) running.add(key);
try {
return await operation();
} finally {
running.delete(key);
for (const key of held) running.delete(key);
}
}
+47 -3
View File
@@ -285,18 +285,38 @@ export interface MailboxCreate {
readonly parentId: string | null;
}
/**
* 메일함 바꾸기 (PATCH /api/mailboxes/{id} — 계약 2절 "메일함 관리") — 이름 · 상위(15단계 — 옮기기). 보내지 않은 칸은 그대로다.
* 서비스가 하나 이상인지 · 이름 형식을 본 값이다
*/
export interface MailboxChange {
/** 앞뒤 공백을 뺀 1~64자, `/` 없음 — 없으면 이름은 그대로 */
readonly name?: string;
/** 새 상위 메일함 — null 이면 맨 위, 없으면(undefined) 상위는 그대로 */
readonly parentId?: string | null;
}
/** 메일함 지우기의 방법 (DELETE /api/mailboxes/{id} — 15단계 "하위 메일함까지 지우기") */
export interface MailboxDeleteOptions {
/** true 면 하위 메일함이 있어도 깊은 것부터 모두 지운다 — 없거나 false 면 하위가 있을 때 VAL_001 (지금처럼) */
readonly withChildren?: boolean;
/** 요청이 서비스에 들어온 때(Date.now() 의 ms) — 한 요청의 시간(25초)을 이때부터 센다. 없으면 어댑터가 시작한 때부터 */
readonly startedAt?: number;
}
/**
* 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다.
* 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다.
* 하위 메일함까지 지우면(15단계 — withChildren) 수는 나무 전체(그 메일함과 하위 모두)의 것이다
*/
export interface MailboxDeletion {
/** true = 지웠다 */
/** true = 지웠다 (하위까지 지울 때는 나무 전체를 지웠다) */
readonly deleted: boolean;
/** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */
/** 이번 요청에서 이 메일함(나무)에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함(나무)에서만 뺀 것 (진행 수) */
readonly processed: number;
/** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */
readonly movedToTrash: number;
/** 아직 이 메일함에 남은 메일 수 */
/** 아직 이 메일함(나무)에 남은 메일 수 */
readonly remaining: number;
}
@@ -428,6 +448,18 @@ export interface MailFilterRunOutcome extends MailFilterRun {
readonly rounds: number;
}
/**
* 차단할 때 받은편지함 정리 (POST /api/mail-block-runs — 계약 2절 "보낸 사람 차단" · 15단계) — 서비스가 차단 목록에 있는지 본 값이다.
* 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 응답 · 서비스가 넘기는 것(MailFilterRunContext) · 어댑터의 결과(MailFilterRunOutcome)는 그것을 함께 쓴다
* (matched 는 스팸메일함으로 옮긴 수)
*/
export interface MailBlockRunRequest {
/** 차단 목록의 값 — 소문자 주소 또는 `@도메인` (BlockedSender.address) */
readonly sender: string;
/** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 보낸 사람의 것이면 VAL_001) */
readonly cursor: string | null;
}
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
export interface MessageSource {
readonly stream: Readable;
@@ -738,6 +770,18 @@ export interface SentMessage {
readonly pendingSend: PendingSend | null;
}
/**
* 예약 메일 지금 보내기의 결과 (POST /api/scheduled-messages/{id}/release — 15단계) — 응답은 발송 결과(SentMessage)와 같은 칸이고,
* 대용량 첨부 링크에 적을 보낸 때를 더했다 (서비스가 떼고 답한다)
*/
export interface ReleasedScheduledSend extends SentMessage {
/**
* 지금 보내기로 다시 제출한 때(ISO 8601 UTC — 이 요청이나 응답을 받지 못한 앞 요청) — 대용량 첨부 링크의 "보낸 때"로 적는다.
* 예약 시각에 이미 보낸 메일(지금 보내기가 아니다)이면 null — 링크를 그대로 둔다
*/
readonly releasedAt: string | null;
}
/**
* 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다
* (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게)
@@ -0,0 +1,72 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
deepestFirstIds,
descendantsOf,
isSelfOrDescendant,
subtreeIds,
} from './mailbox-tree';
/** 거래처 › 2025 › 1분기 · 거래처 › 2026, 프로젝트(맨 위), 받은편지함 › 프로젝트 */
const mailboxes = [
{ id: 'a', parentId: null },
{ id: 'u1', parentId: null },
{ id: 'u2', parentId: 'u1' },
{ id: 'u3', parentId: 'u2' },
{ id: 'u4', parentId: 'u1' },
{ id: 'p', parentId: undefined },
{ id: 'q', parentId: 'a' },
];
describe('메일함 나무 (mailbox-tree — 15단계 옮기기 · 하위까지 지우기)', () => {
it('하위 메일함 모두 — 깊이와 함께 가까운 것부터, 그 메일함 자신은 넣지 않는다', () => {
expect(
descendantsOf(mailboxes, 'u1').map(({ mailbox, depth }) => [
mailbox.id,
depth,
]),
).toEqual([
['u2', 1],
['u4', 1],
['u3', 2],
]);
expect(descendantsOf(mailboxes, 'p')).toEqual([]);
expect(descendantsOf(mailboxes, 'nope')).toEqual([]);
});
it('잠글 메일함(그 메일함이 맨 앞) · 지울 차례(깊은 것부터, 그 메일함은 맨 끝)', () => {
expect(subtreeIds(mailboxes, 'u1')).toEqual(['u1', 'u2', 'u4', 'u3']);
expect(deepestFirstIds(mailboxes, 'u1')).toEqual(['u3', 'u2', 'u4', 'u1']);
expect(deepestFirstIds(mailboxes, 'u3')).toEqual(['u3']);
});
it('자기 자신 · 자기 하위(손주까지)인가 — 위 · 옆 메일함은 아니다', () => {
for (const candidate of ['u1', 'u2', 'u3', 'u4']) {
expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(true);
}
for (const candidate of ['a', 'p', 'q']) {
expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(false);
}
expect(isSelfOrDescendant(mailboxes, 'u2', 'u1')).toBe(false);
});
it('읽은 목록에 고리가 있어도 끝난다 — 한 번 본 메일함은 다시 보지 않는다', () => {
const looped = [
{ id: 'x', parentId: 'z' },
{ id: 'y', parentId: 'x' },
{ id: 'z', parentId: 'y' },
];
expect(subtreeIds(looped, 'x')).toEqual(['x', 'y', 'z']);
expect(deepestFirstIds(looped, 'x')).toEqual(['z', 'y', 'x']);
});
it('메일함 수에 비례한다 — 깊이 3,000 의 한 줄 나무도 바로 (CPU 시간)', () => {
const chain = Array.from({ length: 3_000 }, (_, index) => ({
id: `m${index}`,
parentId: index === 0 ? null : `m${index - 1}`,
}));
const elapsed = startCpuTimer();
expect(deepestFirstIds(chain, 'm0')).toHaveLength(3_000);
expect(isSelfOrDescendant(chain, 'm0', 'm2999')).toBe(true);
expect(elapsed()).toBeLessThan(500);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* 메일함 나무 (순수 함수 — 계약 2절 "메일함 관리"의 15단계: 옮기기 · 하위 메일함까지 지우기) — 메일함은 parentId 로 잇는다.
* 엔진의 메일함(JmapMailbox)과 도메인의 메일함(Mailbox)이 함께 쓴다 — id · parentId 만 본다.
* 엔진이 고리를 막지만(옮기기의 invalidProperties — 2026-11-09 확인) 읽은 목록에 고리가 있어도 끝나게, 한 번 본 메일함은 다시 보지 않는다.
* 메일함 수에 비례한다(부모 → 자식 표를 한 번 만든다)
*/
/** 나무를 만드는 데 쓰는 칸 */
interface TreeNode {
readonly id: string;
readonly parentId?: string | null;
}
/** 하위 메일함 하나와 그 깊이 (그 메일함의 바로 아래가 1) */
export interface MailboxTreeEntry<T> {
readonly mailbox: T;
readonly depth: number;
}
/**
* 그 메일함의 하위 메일함 모두 — 깊이와 함께, 위에서부터(가까운 것부터) 차례. 그 메일함 자신은 넣지 않는다.
* 그 메일함이 목록에 없어도 그 id 를 상위로 둔 메일함을 찾는다(부르는 쪽이 먼저 있는지 본다)
*/
export function descendantsOf<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): MailboxTreeEntry<T>[] {
const children = new Map<string, T[]>();
for (const mailbox of mailboxes) {
const parentId = mailbox.parentId ?? null;
if (parentId === null) continue;
const siblings = children.get(parentId);
if (siblings) siblings.push(mailbox);
else children.set(parentId, [mailbox]);
}
const seen = new Set<string>([rootId]);
const found: MailboxTreeEntry<T>[] = [];
let level = [rootId];
for (let depth = 1; level.length > 0; depth += 1) {
const next: string[] = [];
for (const id of level) {
for (const child of children.get(id) ?? []) {
if (seen.has(child.id)) continue;
seen.add(child.id);
found.push({ mailbox: child, depth });
next.push(child.id);
}
}
level = next;
}
return found;
}
/** 그 메일함과 하위 모두의 id — 그 메일함이 맨 앞, 하위는 가까운 것부터 (잠금 · 메일 찾기) */
export function subtreeIds<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): string[] {
return [
rootId,
...descendantsOf(mailboxes, rootId).map(({ mailbox }) => mailbox.id),
];
}
/**
* 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝. 엔진은 한 요청의 destroy 를 적은 차례대로 하므로(E2 — 2026-11-09 확인)
* 이 차례면 빈 나무를 요청 하나로 지운다. 깊이가 같으면 찾은 차례 그대로다
*/
export function deepestFirstIds<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): string[] {
const descendants = descendantsOf(mailboxes, rootId);
return [
...[...descendants]
.sort((a, b) => b.depth - a.depth)
.map(({ mailbox }) => mailbox.id),
rootId,
];
}
/** candidateId 가 그 메일함 자신이거나 그 하위인가 — 옮기기의 고리(자기 아래로 옮기기) 막기 */
export function isSelfOrDescendant<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
candidateId: string,
): boolean {
return (
candidateId === rootId ||
descendantsOf(mailboxes, rootId).some(
({ mailbox }) => mailbox.id === candidateId,
)
);
}
+38 -14
View File
@@ -9,6 +9,7 @@ import {
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
@@ -34,8 +35,9 @@ import {
import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto';
import {
CreateMailboxDto,
MailboxDeleteQueryDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
UpdateMailboxDto,
} from './dto/mailbox.dto';
import { MailIdPipe } from './mail-id.pipe';
import {
@@ -51,7 +53,8 @@ import { MailService } from './mail.service';
const BULK_NOTE =
'한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' +
'도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' +
'같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.';
'같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다. ' +
'차단할 때 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 도는 동안은 스팸메일함도 처리 중이다.';
/** 오래 걸리는 처리가 진행 중일 때의 안내 */
const BULK_IN_PROGRESS =
@@ -108,20 +111,36 @@ export class MailboxController {
@Patch(':id')
@ApiOperation({
summary: '메일함 이름 바꾸기',
summary: '메일함 이름 바꾸기 · 옮기기',
description:
'기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.',
'이름(name) · 상위(parentId — 15단계 "메일함 옮기기") 가운데 바꿀 것만 보낸다 — 하나 이상(빈 본문은 400 VAL_001). parentId: null 은 맨 위다. ' +
'기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 보관함 · 내 메일함), ' +
'자기 자신 · 자기 하위 아래로는 옮길 수 없다. 하위 메일함과 안의 메일은 함께 가고 메일함 id 는 그대로다(그 메일함으로 옮기는 필터도 그대로 돈다). ' +
'이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 옮길 때(parentId 가 있을 때) 그 메일함이나 새 상위의 오래 걸리는 처리가 진행 중이면 429 REQ_001.',
})
@ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' })
@ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다')
rename(
@ApiErrorResponse(
'VAL_001',
'빈 본문 · 기본 메일함 · 이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 자기 자신 · 자기 하위 아래 · 모르는 필드',
)
@ApiErrorResponse(
'RES_001',
'없는 메일함 · 없는 새 상위 (형식이 틀린 id 포함)',
)
@ApiErrorResponse(
'MAIL_009',
'새 자리(같은 상위 안)에 같은 이름의 메일함이 있다 — 대소문자는 가리지 않는다',
)
@ApiErrorResponse(
'REQ_001',
'옮길 때 — 그 메일함이나 새 상위의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 진행 중 — 엔진에 묻지 않았다',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Body() dto: RenameMailboxDto,
@Body() dto: UpdateMailboxDto,
) {
return this.mail.renameMailbox(user, id, dto);
return this.mail.updateMailbox(user, id, dto);
}
@Delete(':id')
@@ -129,13 +148,17 @@ export class MailboxController {
summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다',
description:
'메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' +
`옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`,
'하위 메일함이 있으면 withChildren=true 일 때만 지운다(15단계) — 그 메일함과 하위 모두(나무)의 메일을 같은 규칙으로 옮긴 뒤 깊은 것부터 지운다. 수는 나무 전체의 것이다. ' +
`옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} 하위까지 지울 때는 그 메일함을 먼저 잡고 목록을 읽어 하위 메일함까지 잡는다 — 하위가 처리 중이면 목록만 읽고 429. (deleted: false 면 다시 보낸다)`,
})
@ApiStandardResponse(MailboxDeletionDto, {
description:
'지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)',
'지웠다(deleted: true — 하위까지면 나무 전체) · 아직 옮길 메일이 남았다(deleted: false)',
})
@ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음')
@ApiErrorResponse(
'VAL_001',
'기본 메일함 · 하위 메일함이 있는데 withChildren=true 가 아님 · 하위에 기본 메일함이 있음 · withChildren 의 다른 값 · 모르는 쿼리',
)
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse(
'MAIL_015',
@@ -146,8 +169,9 @@ export class MailboxController {
remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Query() query: MailboxDeleteQueryDto,
) {
return this.mail.deleteMailbox(user, id);
return this.mail.deleteMailbox(user, id, query);
}
@Patch(':id/messages')
@@ -2,7 +2,10 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
@@ -22,6 +25,7 @@ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
ScheduledMessageCanceledDto,
ScheduledMessagePageDto,
SentMessageDto,
} from './dto/mail-response.dto';
import { MailIdPipe } from './mail-id.pipe';
import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants';
@@ -72,10 +76,54 @@ export class ScheduledMessageController {
'MAIL_014',
'이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 메일의 지금 보내기가 처리 중(15단계) — 엔진에 묻지 않았다',
)
cancel(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.scheduled.cancel(user, id);
}
@Post(':id/release')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '예약한 메일 지금 보내기 (15단계)',
description:
'id 는 보낸 사본의 메일 id 다. 본문 없음. 기다리는 예약 제출을 메일 서버에서 취소한 **뒤에** 같은 메일을 그 제출의 받는 사람(envelope — 숨은참조 포함) 그대로 다시 제출한다 — ' +
'[보내기]를 누른 것처럼 그 직원의 보내기 취소 설정 초만큼 붙잡았다 보낸다(0초면 바로 — 응답 pendingSend). 예약 표시를 떼서 이제 보내기 취소를 기다리는 메일이다(취소는 DELETE /api/scheduled-messages/{id}). ' +
'대용량 첨부 링크의 보낸 때는 지금으로 바꾸고 끝나는 날은 그대로 둔다(본문에 적힌 날짜). 받는 사람별 결과는 발송과 같다(rejectedRecipients). ' +
'이미 보낸 메일(예약 시각이 막 지남)은 오류가 아니다 — 200 pendingSend: null. 이미 지금 보내기를 한 메일(다시 누름 · 응답을 받지 못한 재시도)도 그 발송의 지금 상태로 200. ' +
'메일 서버가 다시 제출을 거절하면 한 번 더 하고, 그래도 거절이면 그 메일을 임시보관함으로 되돌리고(예약 취소와 같은 자리) 503 MAIL_001. ' +
'결과를 모르게 끝나면(시간 초과 · 연결 끊김) 다시 내지도 되돌리지도 않고 503 MAIL_001 — 메일은 보낸편지함에 그대로라 같은 요청을 다시 보내면 이어 낸다 ' +
'(503 뒤에는 그 메일을 다시 읽어 임시보관함인지 보낸편지함인지로 가른다). 54초 남짓 안에 답한다(정리 몫 포함 — 앞단 · 클라이언트 60초 안).',
})
@ApiStandardResponse(SentMessageDto, {
description:
'보냈다 · 보내기 취소를 기다린다(pendingSend.kind = undo) — 발송 응답과 같은 칸 (id 는 같은 메일 id)',
})
@ApiErrorResponse(
'RES_001',
'없는 메일 · 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) — 형식이 틀린 id 포함, 아무것도 바꾸지 않았다',
)
@ApiErrorResponse(
'MAIL_010',
'받는 사람 모두 거절(없는 주소 등) — 보내지 않고 임시보관함으로 되돌렸다',
)
@ApiErrorResponse(
'MAIL_011',
'받는 사람 모두 지금은 받을 수 없음 — 보내지 않고 임시보관함으로 되돌렸다',
)
@ApiErrorResponse(
'REQ_001',
'같은 메일의 예약 취소 · 지금 보내기가 처리 중 — 엔진에 묻지 않았다',
)
release(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.scheduled.release(user, id);
}
}
@@ -1,8 +1,15 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import type { MailLargeFilesService } from './mail-large-files.service';
import type { MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
import {
ScheduledSendReturnedToDraftsException,
type MailProvider,
} from './mail.provider';
import type { MessageSummary, ReleasedScheduledSend } from './mail.types';
import { ScheduledMessagesService } from './scheduled-messages.service';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
@@ -44,24 +51,48 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
let provider: {
listScheduledMessages: jest.Mock;
cancelPendingSend: jest.Mock;
releaseScheduledSend: jest.Mock;
};
let service: ScheduledMessagesService;
let largeFiles: { onPendingSendCanceled: jest.Mock };
let largeFiles: {
onPendingSendCanceled: jest.Mock;
onScheduledSendReleased: jest.Mock;
};
let userSettings: { get: jest.Mock };
/** 대역 엔진의 지금 보내기 결과 — 보내기 취소를 기다린다 */
const released: ReleasedScheduledSend = {
id: 'm1',
rejectedRecipients: [],
pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' },
releasedAt: '2026-11-09T05:00:00.000Z',
};
beforeEach(() => {
provider = {
listScheduledMessages: jest.fn().mockResolvedValue(all),
cancelPendingSend: jest.fn().mockResolvedValue(undefined),
releaseScheduledSend: jest.fn().mockResolvedValue(released),
};
largeFiles = {
onPendingSendCanceled: jest.fn().mockResolvedValue(undefined),
onScheduledSendReleased: jest.fn().mockResolvedValue(undefined),
};
userSettings = {
get: jest.fn().mockResolvedValue({
...DEFAULT_MAIL_USER_SETTINGS,
undoSendSeconds: 20,
}),
};
service = new ScheduledMessagesService(
provider as unknown as MailProvider,
largeFiles as unknown as MailLargeFilesService,
userSettings as unknown as MailUserSettingsService,
);
});
afterEach(() => jest.restoreAllMocks());
it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => {
const first = await service.list(user, { limit: 2 });
expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']);
@@ -138,4 +169,123 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
await failureOf(() => service.cancel(user, 'm2'));
expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled();
});
describe('지금 보내기 (15단계)', () => {
it('그 직원의 보내기 취소 초만큼 붙잡아 다시 내게 하고, 발송과 같은 칸으로 답한다 — 다시 냈으면 대용량 링크의 보낸 때를 그 시각으로', async () => {
await expect(service.release(user, 'm1')).resolves.toEqual({
id: 'm1',
rejectedRecipients: [],
pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' },
});
expect(provider.releaseScheduledSend).toHaveBeenCalledWith(
user.email,
'm1',
{ kind: 'undo', seconds: 20 },
);
expect(largeFiles.onScheduledSendReleased).toHaveBeenCalledWith(
user,
'm1',
new Date('2026-11-09T05:00:00.000Z'),
);
});
it('보내기 취소를 0초로 두었으면 붙잡지 않는다(null) · 설정을 읽지 못하면 처음 값(10초)으로 — 경고만', async () => {
userSettings.get.mockResolvedValueOnce({
...DEFAULT_MAIL_USER_SETTINGS,
undoSendSeconds: 0,
});
await service.release(user, 'm1');
expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith(
user.email,
'm1',
null,
);
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
userSettings.get.mockRejectedValueOnce(new Error('db down'));
await service.release(user, 'm1');
expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith(
user.email,
'm1',
{ kind: 'undo', seconds: 10 },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).not.toContain(user.email);
});
it('이미 보낸 메일(releasedAt null)은 링크를 그대로 둔다 — 응답은 pendingSend null', async () => {
provider.releaseScheduledSend.mockResolvedValueOnce({
...released,
pendingSend: null,
releasedAt: null,
});
await expect(service.release(user, 'm1')).resolves.toMatchObject({
pendingSend: null,
});
expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled();
});
it('끝내 보내지 못해 임시보관함으로 되돌렸으면 링크를 취소한 발송처럼 둔다 — 그 밖의 실패는 링크를 건드리지 않는다 (코드는 그대로)', async () => {
provider.releaseScheduledSend.mockRejectedValueOnce(
new ScheduledSendReturnedToDraftsException('MAIL_010', {
context: { reason: 'x' },
}),
);
expect(await failureOf(() => service.release(user, 'm1'))).toMatchObject({
code: 'MAIL_010',
});
expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1');
largeFiles.onPendingSendCanceled.mockClear();
for (const code of ['RES_001', 'MAIL_001'] as const) {
provider.releaseScheduledSend.mockRejectedValueOnce(
new BusinessException(code, { context: { reason: 'x' } }),
);
expect(
await failureOf(() => service.release(user, 'm1')),
).toMatchObject({
code,
});
}
expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled();
expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled();
});
it('같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 REQ_001, 다른 메일은 된다, 끝나면 다시 받는다', async () => {
let finish!: (value: ReleasedScheduledSend) => void;
provider.releaseScheduledSend.mockReturnValueOnce(
new Promise<ReleasedScheduledSend>((resolve) => {
finish = resolve;
}),
);
const running = service.release(user, 'm1');
await new Promise((resolve) => setImmediate(resolve));
expect(provider.releaseScheduledSend).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.release(user, 'm1'),
() => service.cancel(user, 'm1'),
() =>
service.release({ ...user, email: 'ARA.KIM@arasystem.test' }, 'm1'),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'scheduled-send-in-progress',
});
}
expect(provider.cancelPendingSend).not.toHaveBeenCalled();
await expect(service.cancel(user, 'm2')).resolves.toEqual({
draftId: 'm2',
});
finish(released);
await running;
await expect(service.cancel(user, 'm1')).resolves.toEqual({
draftId: 'm1',
});
});
});
});
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
@@ -7,28 +7,48 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { MailLargeFilesService } from './mail-large-files.service';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
import {
MAIL_PROVIDER,
ScheduledSendReturnedToDraftsException,
type MailProvider,
} from './mail.provider';
import type { MessageSummary, SendHold, SentMessage } from './mail.types';
import { undoSendHold } from './send-hold';
/** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */
const SCHEDULED_SCOPE = 'scheduled-messages';
/**
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소.
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록 · 취소 · 지금 보내기(15단계).
* 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다).
* 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다
*/
@Injectable()
export class ScheduledMessagesService {
private readonly logger = new Logger(ScheduledMessagesService.name);
/**
* 진행 중인 (직원, 메일)의 취소 · 지금 보내기 — 같은 메일에는 하나씩만(종류가 달라도 막는다 — 계약 2절, 진행 중이면 429 REQ_001).
* 취소와 지금 보내기가 같은 예약 제출을 함께 다루면 한쪽이 취소한 뒤 다른 쪽이 다시 내거나 임시보관함으로 옮긴다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다
*/
private readonly inProgress = new Set<string>();
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
/** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */
/** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 · 지금 보낸 예약의 링크에 보낸 때를 (11단계 · 15단계) */
private readonly largeFiles: MailLargeFilesService,
/** 지금 보내기의 보내기 취소 초 (15단계 — [보내기]를 누른 것처럼) */
private readonly userSettings: MailUserSettingsService,
) {}
/**
@@ -64,14 +84,88 @@ export class ScheduledMessagesService {
/**
* 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId).
* 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다).
* 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다)
* 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다).
* 같은 메일의 지금 보내기가 진행 중이면 REQ_001 (15단계)
*/
async cancel(
user: AuthenticatedUser,
id: string,
): Promise<{ draftId: string }> {
await this.provider.cancelPendingSend(user.email, id);
await this.largeFiles.onPendingSendCanceled(user, id);
return { draftId: id };
return this.exclusive(user, id, async () => {
await this.provider.cancelPendingSend(user.email, id);
await this.largeFiles.onPendingSendCanceled(user, id);
return { draftId: id };
});
}
/**
* 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — [보내기]를 누른 것처럼 그 직원의 보내기 취소 초만큼 붙잡았다 보낸다(0 이면 바로).
* 응답은 발송 결과(SentMessage)와 같은 칸이다 — 예약 시각이 막 지나 이미 보냈으면 pendingSend null(오류가 아니다).
* - 예약이 아닌 메일 · 없는 메일 RES_001, 다시 제출하지 못함 MAIL_001 · 받는 사람 모두 거절 MAIL_010 · MAIL_011 (어댑터 — 로그도 어댑터가 남긴다)
* - 다시 제출한 뒤 그 발송이 만든 대용량 첨부 링크의 보낸 때를 지금으로 (끝나는 때는 그대로 — 덩이가 이미 적었다)
* - 끝내 보내지 못해 임시보관함으로 **되돌렸을 때만**(ScheduledSendReturnedToDraftsException) 링크를 취소한 발송처럼 둔다(되돌린 초안이 쓴다).
* 그 밖의 MAIL_001(결과를 모름 · 남은 시간 모자람 · 되돌리기 실패)은 링크를 건드리지 않는다 — 메일은 보낸편지함에 그대로고 다시 부르면 이어 낸다
* - 같은 메일의 취소 · 지금 보내기가 진행 중이면 REQ_001
*/
async release(user: AuthenticatedUser, id: string): Promise<SentMessage> {
return this.exclusive(user, id, async () => {
const hold = await this.undoHold(user);
try {
const { releasedAt, ...sent } =
await this.provider.releaseScheduledSend(user.email, id, hold);
if (releasedAt !== null) {
await this.largeFiles.onScheduledSendReleased(
user,
id,
new Date(releasedAt),
);
}
return sent;
} catch (error) {
if (error instanceof ScheduledSendReturnedToDraftsException) {
await this.largeFiles.onPendingSendCanceled(user, id);
}
throw error;
}
});
}
/**
* 지금 보내기의 붙잡기 — 그 직원 설정의 보내기 취소 초(send-hold.ts). 설정을 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 —
* 발송과 같다(MailService.userSettingsOf)
*/
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
try {
const { undoSendSeconds } = await this.userSettings.get(user.email);
return undoSendHold(undoSendSeconds);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
return undoSendHold(DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds);
}
}
/** 같은 직원 · 같은 메일의 취소 · 지금 보내기를 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001. 끝나면 성공 · 실패와 상관없이 푼다 */
private async exclusive<T>(
user: AuthenticatedUser,
id: string,
operation: () => Promise<T>,
): Promise<T> {
const key = `${user.email.toLowerCase()}\n${id}`;
if (this.inProgress.has(key)) {
throw new BusinessException('REQ_001', {
context: {
reason: 'scheduled-send-in-progress',
user: maskEmail(user.email),
},
});
}
this.inProgress.add(key);
try {
return await operation();
} finally {
this.inProgress.delete(key);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { SendHold } from './mail.types';
/**
* 보내기 취소를 기다리는 붙잡기 (계약 2절 "보내기 취소 · 예약 발송") — 그 직원 설정의 초, 0 이면 붙잡지 않는다(null — 바로 보낸다).
* 발송(MailService.sendMessage)과 예약 메일 지금 보내기(ScheduledMessagesService.release — 15단계)가 같이 쓴다 — [보내기]를 누른 것처럼 붙잡는다
*/
export function undoSendHold(undoSendSeconds: number): SendHold | null {
return undoSendSeconds > 0
? { kind: 'undo', seconds: undoSendSeconds }
: null;
}