generated from ttipo/project-base
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:
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+577
-20
@@ -84,6 +84,7 @@ import {
|
||||
AccountLoginDisabledException,
|
||||
MAIL_PROVIDER,
|
||||
PasswordExpiredException,
|
||||
ScheduledSendReturnedToDraftsException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
@@ -93,10 +94,13 @@ import type {
|
||||
DraftSource,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxChange,
|
||||
MailboxCreate,
|
||||
MailboxDeleteOptions,
|
||||
MailboxDeletion,
|
||||
MailBlockAdded,
|
||||
MailBlockList,
|
||||
MailBlockRunRequest,
|
||||
MailFilterRule,
|
||||
MailFilterRunOutcome,
|
||||
MailFilterRunRequest,
|
||||
@@ -118,7 +122,9 @@ import type {
|
||||
PasswordChangeOutcome,
|
||||
PendingSend,
|
||||
RecipientRejectionReason,
|
||||
ReleasedScheduledSend,
|
||||
SavedDraft,
|
||||
SendHold,
|
||||
SentMessage,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
@@ -326,6 +332,9 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
|
||||
const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
|
||||
|
||||
/** 15단계 — 대역 메일 엔진의 지금 보내기가 받는 사람을 모두 받지 않아 임시보관함으로 되돌리는 메일 id (400 MAIL_010 경로) */
|
||||
const RELEASE_REJECTED_MESSAGE = 'releaserejected';
|
||||
|
||||
/**
|
||||
* 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
|
||||
* 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
|
||||
@@ -860,6 +869,12 @@ describe('응답 계약 (e2e)', () => {
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
|
||||
/** 15단계 — 대역 엔진이 받은 차단 정리 · 지금 보내기, 직원마다 더한 메일함(하위 메일함이 있는 나무) · 지금 보내기를 붙잡는 문 */
|
||||
const blockRuns: { user: string; request: MailBlockRunRequest }[] = [];
|
||||
const releases: { user: string; id: string; hold: SendHold | null }[] = [];
|
||||
const extraMailboxes = new Map<string, Mailbox[]>();
|
||||
let slowReleaseGate: Promise<void> | null = null;
|
||||
|
||||
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
|
||||
const mailBlocks = new Map<string, BlockedSender[]>();
|
||||
const scheduledMessages = new Map<string, MessageSummary[]>();
|
||||
@@ -1588,30 +1603,57 @@ describe('응답 계약 (e2e)', () => {
|
||||
unreadCount: 0,
|
||||
});
|
||||
},
|
||||
renameMailbox: (
|
||||
// 15단계 — 이름 · 상위 바꾸기. 상위의 자리 · 고리 판정은 jmap-mail.provider.spec.ts 가 본다 (여기서는 엔진의 답만 흉내 낸다)
|
||||
updateMailbox: (
|
||||
user: string,
|
||||
id: string,
|
||||
name: string,
|
||||
change: MailboxChange,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
|
||||
mailboxCalls.push({ user, action: 'update', args: [id, change] });
|
||||
if (change.parentId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-parent-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (change.parentId === id) {
|
||||
return Promise.reject(
|
||||
new BusinessException('VAL_001', {
|
||||
context: { reason: 'e2e-mailbox-move-into-itself' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (change.name === TAKEN_MAILBOX_NAME) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_009', {
|
||||
context: { reason: 'e2e-mailbox-name-taken' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
id,
|
||||
name,
|
||||
name: change.name ?? id,
|
||||
role: null,
|
||||
parentId: null,
|
||||
parentId: change.parentId ?? null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
});
|
||||
},
|
||||
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id] });
|
||||
return Promise.resolve({
|
||||
deleteMailbox: async (
|
||||
user: string,
|
||||
id: string,
|
||||
options?: MailboxDeleteOptions,
|
||||
): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id, options] });
|
||||
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
|
||||
return {
|
||||
deleted: true,
|
||||
processed: 4,
|
||||
movedToTrash: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
};
|
||||
},
|
||||
markMailboxRead: (
|
||||
user: string,
|
||||
@@ -1646,7 +1688,11 @@ describe('응답 계약 (e2e)', () => {
|
||||
inactiveScripts.delete(user);
|
||||
return Promise.resolve({ ...vacation, isActive: true });
|
||||
},
|
||||
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
|
||||
listMailboxes: (user: string): Promise<Mailbox[]> =>
|
||||
Promise.resolve([
|
||||
...E2E_MAILBOXES,
|
||||
...(extraMailboxes.get(user) ?? []),
|
||||
]),
|
||||
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
|
||||
getMailFilters: (user: string): Promise<MailFilterSet> => {
|
||||
const rules = mailScripts.get(user)?.rules ?? [];
|
||||
@@ -1724,6 +1770,34 @@ describe('응답 계약 (e2e)', () => {
|
||||
rounds: 1,
|
||||
};
|
||||
},
|
||||
// 15단계 — 차단 정리. 판정 · 차례 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 필터 적용과 같은 문으로 붙잡는다(같은 잠금 확인)
|
||||
runMailBlock: async (
|
||||
user: string,
|
||||
request: MailBlockRunRequest,
|
||||
): Promise<MailFilterRunOutcome> => {
|
||||
blockRuns.push({ user, request });
|
||||
if (request.cursor === OTHER_RULE_CURSOR) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'e2e-mail-block-run-other-sender' },
|
||||
});
|
||||
}
|
||||
if (slowFilterRunGate) await slowFilterRunGate;
|
||||
return request.cursor === null
|
||||
? {
|
||||
scanned: 500,
|
||||
matched: 7,
|
||||
remaining: 40,
|
||||
cursor: 'e2eblockcursor',
|
||||
rounds: 1,
|
||||
}
|
||||
: {
|
||||
scanned: 40,
|
||||
matched: 1,
|
||||
remaining: 0,
|
||||
cursor: null,
|
||||
rounds: 1,
|
||||
};
|
||||
},
|
||||
openMessageSource: (
|
||||
_user: string,
|
||||
id: string,
|
||||
@@ -1905,6 +1979,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
canceledSends.push({ user, id });
|
||||
return Promise.resolve();
|
||||
},
|
||||
// 15단계 — 지금 보내기 (취소 → 다시 제출 · 상태 가르기 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
|
||||
releaseScheduledSend: async (
|
||||
user: string,
|
||||
id: string,
|
||||
hold: SendHold | null,
|
||||
): Promise<ReleasedScheduledSend> => {
|
||||
releases.push({ user, id, hold });
|
||||
if (slowReleaseGate) await slowReleaseGate;
|
||||
if (id === GONE_MESSAGE) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-scheduled-send' },
|
||||
});
|
||||
}
|
||||
if (id === RELEASE_REJECTED_MESSAGE) {
|
||||
throw new ScheduledSendReturnedToDraftsException('MAIL_010', {
|
||||
context: { reason: 'e2e-release-all-recipients-rejected' },
|
||||
});
|
||||
}
|
||||
if (id === ALREADY_RELEASED_MESSAGE) {
|
||||
return {
|
||||
id,
|
||||
rejectedRecipients: [],
|
||||
pendingSend: null,
|
||||
releasedAt: null,
|
||||
};
|
||||
}
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
rejectedRecipients: [],
|
||||
pendingSend:
|
||||
hold?.kind === 'undo'
|
||||
? {
|
||||
kind: 'undo',
|
||||
sendAt: new Date(now + hold.seconds * 1_000).toISOString(),
|
||||
}
|
||||
: null,
|
||||
releasedAt: new Date(now).toISOString(),
|
||||
};
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -2449,6 +2563,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
['get', '/api/mail-filters'],
|
||||
['put', '/api/mail-filters'],
|
||||
['post', '/api/mail-filter-runs'],
|
||||
// 15단계 — 차단 정리 · 지금 보내기 (옮기기 · 하위까지 지우기는 위의 PATCH · DELETE 와 같은 경로)
|
||||
['post', '/api/mail-block-runs'],
|
||||
['post', '/api/scheduled-messages/eaaaaab/release'],
|
||||
['delete', '/api/mailboxes/u1?withChildren=true'],
|
||||
['get', '/api/messages/eaaaaab/source'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
@@ -3638,27 +3756,21 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 이름만 보내면 상위는 그대로 (15단계부터 상위도 받는다 — 아래 "15단계")', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/u1', {
|
||||
name: ' 새 이름 ',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'rename',
|
||||
args: ['u1', '새 이름'],
|
||||
action: 'update',
|
||||
args: ['u1', { name: '새 이름' }],
|
||||
});
|
||||
|
||||
const malformed = await send('patch', '/api/mailboxes/a%20b', {
|
||||
name: '새 이름',
|
||||
}).expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
|
||||
const move = await send('patch', '/api/mailboxes/u1', {
|
||||
name: '새 이름',
|
||||
parentId: 'a',
|
||||
}).expect(400);
|
||||
expect(body(move).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
|
||||
@@ -3671,7 +3783,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: ['u1'],
|
||||
args: ['u1', { startedAt: expect.any(Number) as number }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6201,6 +6313,31 @@ describe('응답 계약 (e2e)', () => {
|
||||
return { sentId, message, token, link };
|
||||
};
|
||||
|
||||
it('예약 메일을 지금 보내면(15단계) 그 링크의 보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다 — 덩이가 적은 날짜 (실제 DB 의 조건부 UPDATE)', async () => {
|
||||
const ME = 'lf.release@e2e.arasystem.test';
|
||||
const file = await uploadReady(ME, '지금 보내기 시험', '도면.zip');
|
||||
const sendAt = new Date(
|
||||
Math.floor((Date.now() + 3 * 24 * 60 * 60_000) / 1_000) * 1_000,
|
||||
).toISOString();
|
||||
const { sentId, link } = await sendAndShare(ME, [file.id], { sendAt });
|
||||
expect(link?.sentAt).toBe(sendAt);
|
||||
const expiresAt = link?.expiresAt;
|
||||
|
||||
const before = Date.now();
|
||||
await client(
|
||||
request(server).post(`/api/scheduled-messages/${sentId}/release`),
|
||||
ME,
|
||||
).expect(200);
|
||||
const after = (await shareLinksOf(ME)).items.find(
|
||||
(item) => item.sentEmailId === sentId,
|
||||
);
|
||||
expect(Date.parse(after?.sentAt ?? '')).toBeGreaterThanOrEqual(
|
||||
Math.floor(before / 1_000) * 1_000,
|
||||
);
|
||||
expect(Date.parse(after?.sentAt ?? '')).toBeLessThan(Date.parse(sendAt));
|
||||
expect(after?.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => {
|
||||
const id = randomUUID();
|
||||
for (const res of [
|
||||
@@ -11119,6 +11256,426 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절).
|
||||
* 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다
|
||||
*/
|
||||
describe('15단계 — 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기', () => {
|
||||
/** 보내기 · 읽기 설정 — 지금 보내기의 보내기 취소 초 (9단계 "보내기 · 읽기 설정") */
|
||||
const patchSettings = (email: string, payload: object) =>
|
||||
request(server)
|
||||
.patch('/api/mail-settings')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send(payload);
|
||||
|
||||
describe('메일함 옮기기 (PATCH /api/mailboxes/{id} 의 parentId)', () => {
|
||||
const ME = 'move.me@e2e.arasystem.test';
|
||||
const patch = (id: string, payload: object) =>
|
||||
request(server)
|
||||
.patch(`/api/mailboxes/${id}`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('parentId 로 옮긴다 — null 은 맨 위, 이름과 함께도 된다 · 보낸 칸만 엔진에 넘긴다', async () => {
|
||||
const res = await patch('u1', { parentId: 'a' }).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'u1',
|
||||
name: 'u1',
|
||||
role: null,
|
||||
parentId: 'a',
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
},
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'update',
|
||||
args: ['u1', { parentId: 'a' }],
|
||||
});
|
||||
|
||||
await patch('u1', { parentId: null }).expect(200);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual(['u1', { parentId: null }]);
|
||||
|
||||
await patch('u1', { name: ' 2025 ', parentId: 'j' }).expect(200);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual([
|
||||
'u1',
|
||||
{ name: '2025', parentId: 'j' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 본문 · 상위 id 형식 · null 이름 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
const before = mailboxCalls.length;
|
||||
for (const payload of [
|
||||
{},
|
||||
{ parentId: '../a' },
|
||||
{ parentId: 7 },
|
||||
{ name: null },
|
||||
{ parentId: 'a', role: 'inbox' },
|
||||
]) {
|
||||
const res = await patch('u1', payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('엔진의 답 그대로 — 없는 상위 404 RES_001 · 자기 아래 400 VAL_001 · 같은 이름 409 MAIL_009', async () => {
|
||||
const orphan = await patch('u1', { parentId: MISSING_MAILBOX }).expect(
|
||||
404,
|
||||
);
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
const self = await patch('u1', { parentId: 'u1' }).expect(400);
|
||||
expect(body(self).error?.code).toBe('VAL_001');
|
||||
const taken = await patch('u1', {
|
||||
name: TAKEN_MAILBOX_NAME,
|
||||
parentId: 'a',
|
||||
}).expect(409);
|
||||
expect(body(taken).error?.code).toBe('MAIL_009');
|
||||
});
|
||||
});
|
||||
|
||||
describe('하위 메일함까지 지우기 (DELETE /api/mailboxes/{id}?withChildren=true)', () => {
|
||||
const ME = 'tree.me@e2e.arasystem.test';
|
||||
const CHILD = 'slowchild';
|
||||
const remove = (id: string, query = '') =>
|
||||
request(server)
|
||||
.delete(`/api/mailboxes/${id}${query}`)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
beforeAll(() => {
|
||||
// 대역 엔진의 나무 — SLOW_MAILBOX › CHILD
|
||||
extraMailboxes.set(ME, [
|
||||
{
|
||||
id: SLOW_MAILBOX,
|
||||
name: '거래처',
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{
|
||||
id: CHILD,
|
||||
name: '2025',
|
||||
role: null,
|
||||
parentId: SLOW_MAILBOX,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('withChildren=true 면 하위까지 지우기를 넘기고 200 MailboxDeletion — 다른 값 · 모르는 쿼리는 400 VAL_001 (엔진에 묻지 않는다)', async () => {
|
||||
const res = await remove('u1', '?withChildren=true').expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: [
|
||||
'u1',
|
||||
{ withChildren: true, startedAt: expect.any(Number) as number },
|
||||
],
|
||||
});
|
||||
|
||||
const before = mailboxCalls.length;
|
||||
for (const query of [
|
||||
'?withChildren=false',
|
||||
'?withChildren=TRUE',
|
||||
'?withChildren=1',
|
||||
'?recursive=true',
|
||||
]) {
|
||||
const rejected = await remove('u1', query).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it(
|
||||
'나무를 지우는 동안 나무의 메일함 모두가 처리 중이다 — 하위의 전체 읽음 · 나무 안으로 옮기기는 429 REQ_001, 나무 밖은 된다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowMailboxGate = gate.opened;
|
||||
const before = mailboxCalls.length;
|
||||
const first = remove(SLOW_MAILBOX, '?withChildren=true').then(
|
||||
(res) => res,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitUntil(
|
||||
() => mailboxCalls.length > before,
|
||||
GATE_DEADLINE_MS,
|
||||
);
|
||||
for (const [method, path, payload] of [
|
||||
['patch', `/api/mailboxes/${CHILD}/messages`, { isRead: true }],
|
||||
['delete', `/api/mailboxes/${CHILD}`, undefined],
|
||||
['patch', '/api/mailboxes/j', { parentId: CHILD }],
|
||||
] as const) {
|
||||
const req = request(server)
|
||||
[method](path)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
const busy = await (
|
||||
payload === undefined ? req : req.send(payload)
|
||||
).expect(429);
|
||||
expect(body(busy).error?.code).toBe('REQ_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before + 1);
|
||||
|
||||
// 나무 밖은 된다 · 이름만 바꾸기는 잠그지 않는다(지금처럼)
|
||||
await request(server)
|
||||
.patch('/api/mailboxes/a/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ isRead: true })
|
||||
.expect(200);
|
||||
await request(server)
|
||||
.patch(`/api/mailboxes/${CHILD}`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ name: '새 이름' })
|
||||
.expect(200);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowMailboxGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('차단할 때 받은편지함 정리 (POST /api/mail-block-runs)', () => {
|
||||
const ME = 'blockrun.me@e2e.arasystem.test';
|
||||
const SENDER = 'kim@spam.e2e.example';
|
||||
const run = (payload: object) =>
|
||||
request(server)
|
||||
.post('/api/mail-block-runs')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
beforeAll(() => {
|
||||
mailBlocks.set(ME, [
|
||||
{ address: SENDER, blockedAt: '2026-11-09T01:00:00.000Z' },
|
||||
{
|
||||
address: '@spam.e2e.example',
|
||||
blockedAt: '2026-11-09T01:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('200 { scanned, matched, remaining, cursor } — cursor 로 이어 하고 null 이면 끝 (차례 수는 응답에 없다)', async () => {
|
||||
const first = await run({ sender: SENDER, cursor: null }).expect(200);
|
||||
expect(body(first)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
scanned: 500,
|
||||
matched: 7,
|
||||
remaining: 40,
|
||||
cursor: 'e2eblockcursor',
|
||||
},
|
||||
});
|
||||
expect(blockRuns.at(-1)).toEqual({
|
||||
user: ME,
|
||||
request: { sender: SENDER, cursor: null },
|
||||
});
|
||||
|
||||
const second = await run({
|
||||
sender: '@SPAM.e2e.example',
|
||||
cursor: 'e2eblockcursor',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ cursor: string | null }>(second).cursor).toBeNull();
|
||||
// 차단 목록의 모양(소문자)으로 넘긴다
|
||||
expect(blockRuns.at(-1)?.request.sender).toBe('@spam.e2e.example');
|
||||
});
|
||||
|
||||
it('차단 목록에 없으면 404 RES_001 · 형식 · cursor 키 없음 · 모르는 필드는 400 VAL_001 · 어댑터의 다른 보낸 사람 cursor 도 400 — 받은편지함을 읽지 않는다', async () => {
|
||||
const before = blockRuns.length;
|
||||
const missing = await run({
|
||||
sender: 'lee@spam.e2e.example',
|
||||
cursor: null,
|
||||
}).expect(404);
|
||||
expect(body(missing).error?.code).toBe('RES_001');
|
||||
|
||||
for (const payload of [
|
||||
{ sender: SENDER },
|
||||
{ sender: 'not an address', cursor: null },
|
||||
{ sender: SENDER, cursor: 7 },
|
||||
{ sender: SENDER, cursor: 'a b' },
|
||||
{ sender: SENDER, cursor: null, mailboxId: 'a' },
|
||||
]) {
|
||||
const rejected = await run(payload).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(blockRuns).toHaveLength(before);
|
||||
|
||||
const other = await run({
|
||||
sender: SENDER,
|
||||
cursor: OTHER_RULE_CURSOR,
|
||||
}).expect(400);
|
||||
expect(body(other).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it(
|
||||
'같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 429 REQ_001',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowFilterRunGate = gate.opened;
|
||||
const before = blockRuns.length;
|
||||
const first = run({ sender: SENDER, cursor: null }).then(
|
||||
(res) => res,
|
||||
);
|
||||
try {
|
||||
await waitUntil(() => blockRuns.length > before, GATE_DEADLINE_MS);
|
||||
const again = await run({ sender: SENDER, cursor: null }).expect(
|
||||
429,
|
||||
);
|
||||
expect(body(again).error?.code).toBe('REQ_001');
|
||||
const filter = await request(server)
|
||||
.post('/api/mail-filter-runs')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
},
|
||||
cursor: null,
|
||||
})
|
||||
.expect(429);
|
||||
expect(body(filter).error?.code).toBe('REQ_001');
|
||||
// 차단 정리가 도는 동안 그 직원의 스팸메일함(c)도 잡혀 있다 — 비우기는 429 (엔진에 묻지 않는다)
|
||||
const emptyCalls = mailboxCalls.length;
|
||||
const empty = await request(server)
|
||||
.delete('/api/mailboxes/c/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(429);
|
||||
expect(body(empty).error?.code).toBe('REQ_001');
|
||||
expect(mailboxCalls).toHaveLength(emptyCalls);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowFilterRunGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('예약 메일 지금 보내기 (POST /api/scheduled-messages/{id}/release)', () => {
|
||||
const ME = 'release.me@e2e.arasystem.test';
|
||||
const release = (id: string) =>
|
||||
request(server)
|
||||
.post(`/api/scheduled-messages/${id}/release`)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('200 — 발송 응답과 같은 칸 { id, rejectedRecipients, pendingSend }, 보내기 취소 설정 초만큼 붙잡게 넘긴다(0초면 null)', async () => {
|
||||
const res = await release('escheduled1').expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'escheduled1',
|
||||
rejectedRecipients: [],
|
||||
pendingSend: {
|
||||
kind: 'undo',
|
||||
sendAt: expect.any(String) as string,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(releases.at(-1)).toEqual({
|
||||
user: ME,
|
||||
id: 'escheduled1',
|
||||
hold: { kind: 'undo', seconds: 10 },
|
||||
});
|
||||
|
||||
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
|
||||
const now = await release('escheduled2').expect(200);
|
||||
expect(
|
||||
dataOf<{ pendingSend: PendingSend | null }>(now).pendingSend,
|
||||
).toBeNull();
|
||||
expect(releases.at(-1)?.hold).toBeNull();
|
||||
await patchSettings(ME, { undoSendSeconds: 10 }).expect(200);
|
||||
});
|
||||
|
||||
it('이미 보낸 메일은 오류가 아니다 — 200 pendingSend: null', async () => {
|
||||
const res = await release(ALREADY_RELEASED_MESSAGE).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: ALREADY_RELEASED_MESSAGE,
|
||||
rejectedRecipients: [],
|
||||
pendingSend: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('예약이 아닌 메일 · 형식이 틀린 id 는 404 RES_001 · 받는 사람이 모두 받지 않으면 400 MAIL_010 (임시보관함으로 되돌렸다)', async () => {
|
||||
const gone = await release(GONE_MESSAGE).expect(404);
|
||||
expect(body(gone).error?.code).toBe('RES_001');
|
||||
|
||||
const before = releases.length;
|
||||
const malformed = await release('a"b').expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
expect(releases).toHaveLength(before);
|
||||
|
||||
const rejected = await release(RELEASE_REJECTED_MESSAGE).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('MAIL_010');
|
||||
});
|
||||
|
||||
it(
|
||||
'같은 메일의 지금 보내기 · 예약 취소는 한 번에 하나 — 진행 중이면 429 REQ_001, 다른 메일은 된다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowReleaseGate = gate.opened;
|
||||
const before = releases.length;
|
||||
const first = release('eslowrelease').then((res) => res);
|
||||
try {
|
||||
await waitUntil(() => releases.length > before, GATE_DEADLINE_MS);
|
||||
const again = await release('eslowrelease').expect(429);
|
||||
expect(body(again).error?.code).toBe('REQ_001');
|
||||
const cancel = await request(server)
|
||||
.delete('/api/scheduled-messages/eslowrelease')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(429);
|
||||
expect(body(cancel).error?.code).toBe('REQ_001');
|
||||
// 다른 메일의 취소는 된다
|
||||
await request(server)
|
||||
.delete('/api/scheduled-messages/eotherheld')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
} finally {
|
||||
gate.open();
|
||||
slowReleaseGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
expect((await first).status).toBe(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('출처 검사 (상태 변경 요청)', () => {
|
||||
const EVIL_ORIGIN = 'https://evil.example';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user