feat(backend): 보내기 취소 · 예약 발송 · 외부 이미지 중계 · 보낸 사람 차단 · 보내기 · 읽기 설정 API

계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다.
외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화.
차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings).

리뷰로 고친 것:
- 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds).
  읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다.
- 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다
  (한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다.
- 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게.
- 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다.
- 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시.
- 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정).

에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 22:07:56 +09:00
parent fa6799d9f7
commit b0412ac453
89 changed files with 12851 additions and 351 deletions
@@ -64,6 +64,19 @@ describe('에러 코드 레지스트리', () => {
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
});
it('MAIL_013 의 문구는 필터와 차단 목록(9단계)을 함께 말한다 — 같은 엔진 규칙의 한도라서', () => {
expect(ERROR_CODES.MAIL_013.message).toContain('필터');
expect(ERROR_CODES.MAIL_013.message).toContain('차단');
});
it('이미 보낸 · 기다리는 발송이 아닌 메일의 취소(MAIL_014)는 409 다 (9단계)', () => {
expect(ERROR_CODES.MAIL_014.status).toBe(HttpStatus.CONFLICT);
});
it('붙잡힌 메일을 옮기거나 지우기(MAIL_015)는 409 다 — 취소로만 보낸편지함을 떠난다 (9단계)', () => {
expect(ERROR_CODES.MAIL_015.status).toBe(HttpStatus.CONFLICT);
});
it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => {
expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT);
expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT);
+17 -2
View File
@@ -155,11 +155,26 @@ const DOMAIN_ERROR_CODES = {
message:
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
},
// 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기)
// 필터 · 부재중 응답 · 보낸 사람 차단(9단계)으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다
// (계약 2절 "메일 필터"의 크기 · "보낸 사람 차단"). 뜻은 6단계 그대로이고 문구만 차단 목록을 함께 말한다
MAIL_013: {
status: HttpStatus.BAD_REQUEST,
message:
'필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
},
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
MAIL_014: {
status: HttpStatus.CONFLICT,
message: '이미 보낸 메일이라 취소할 수 없습니다.',
},
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)의 보낸 사본을 옮기거나 지우려 했다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다
// (옮기거나 지워도 엔진은 시간이 되면 보낸다). 아무것도 바꾸지 않았다. 여러 통 처리는 이 코드 대신 그 id 를 failedIds · skippedIds 로 돌려준다
// (계약 2절 "보내기 취소 · 예약 발송")
MAIL_015: {
status: HttpStatus.CONFLICT,
message:
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
},
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 보내기 · 읽기 설정 표 (docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" — 9단계).
* - mail_settings: 직원 주소마다 한 줄 — 보내기 취소 초(undo_send_seconds) · 외부 이미지(external_images). 없으면 처음 값(10 · ask)
* 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory)는 건드리지 않는다.
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-user-settings/mail-user-settings.entity.ts)
*/
export class AddMailSettings1793255372738 implements MigrationInterface {
name = 'AddMailSettings1793255372738';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "mail_settings" ("user_email" character varying(254) NOT NULL, "undo_send_seconds" smallint NOT NULL, "external_images" character varying(16) NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_b2ca6c2963fd9eca971baa44421" PRIMARY KEY ("user_email"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "mail_settings"`);
}
}
@@ -323,6 +323,24 @@ describe('ContactsService (주소록)', () => {
book.seedGroup('g1', '거래처', ['urn:uuid:c-pa', 'urn:uuid:gone']);
});
it('hasEmail — 내 주소록의 연락처 주소인지(대소문자 · 앞뒤 공백 무시), 판을 확인한 주소록에서 (9단계 외부 이미지)', async () => {
await expect(
service.hasEmail(ARA.email, ' jihoon@HANBIT.example '),
).resolves.toBe(true);
await expect(
service.hasEmail(ARA.email, 'someone@hanbit.example'),
).resolves.toBe(false);
// 다른 곳에서 더한 연락처도 판이 바뀌었으니 바로 맞는다
book.seedContact('c-new', {
emails: [{ address: 'someone@hanbit.example', label: 'work' }],
});
book.touch();
await expect(
service.hasEmail(ARA.email, 'someone@hanbit.example'),
).resolves.toBe(true);
});
it('이름 차례 · total 포함 — 연락처의 그룹은 이 주소록의 그룹만', async () => {
const page = await service.list(ARA, listQuery());
@@ -867,6 +867,16 @@ export class ContactsService {
return (await this.freshBook(userEmail)).suggestionLines;
}
/**
* 그 주소(대소문자 무시)의 연락처가 내 주소록에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 "외부 이미지 보기" — 9단계).
* 판을 확인한 주소록에서 본다 (연락처를 바꾸면 바로 맞는다)
*/
async hasEmail(userEmail: string, address: string): Promise<boolean> {
return (await this.freshBook(userEmail)).contactsByEmail.has(
emailKey(address),
);
}
/** 그룹 구성원이 가진 주소 (소문자) — 없는 그룹이면 RES_001 */
private async groupMemberEmails(
userEmail: string,
@@ -90,6 +90,24 @@ describe('DirectoryService (사내 목록)', () => {
afterEach(() => jest.restoreAllMocks());
it('hasEmail — 사내 목록(직원 · 공용 주소)의 주소인지(대소문자 · 앞뒤 공백 무시) — 빼는 계정은 아니다, 같은 기억을 쓴다 (9단계 외부 이미지)', async () => {
await expect(service.hasEmail(' Doyun.Lee@AraSystem.test ')).resolves.toBe(
true,
);
await expect(service.hasEmail('sales@arasystem.test')).resolves.toBe(true);
await expect(service.hasEmail('admin@arasystem.test')).resolves.toBe(false);
await expect(service.hasEmail('nobody@arasystem.test')).resolves.toBe(
false,
);
expect(listAccounts).toHaveBeenCalledTimes(1);
listAccounts.mockRejectedValue(new BusinessException('REQ_001'));
jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS);
expect(
(await failureOf(service.hasEmail('doyun.lee@arasystem.test'))).code,
).toBe('MAIL_001');
});
it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => {
const page = await service.list(query());
@@ -18,6 +18,8 @@ import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy';
interface DirectoryListing {
readonly entries: readonly DirectoryEntry[];
readonly byId: ReadonlyMap<string, DirectoryEntry>;
/** 주소(소문자) — 외부 이미지의 "사내 직원" 판정 (9단계) */
readonly emails: ReadonlySet<string>;
readonly expiresAt: number;
}
@@ -101,6 +103,14 @@ export class DirectoryService {
return pageOf(matched, start, query.limit, scope);
}
/**
* 그 주소(대소문자 무시)가 사내 목록(직원 · 공용 주소)에 있는가 — 외부 이미지 "주소록 · 사내 직원은 늘 보기"(계약 2절 — 9단계).
* 읽지 못하면 MAIL_001 (부르는 쪽이 없는 것으로 본다)
*/
async hasEmail(address: string): Promise<boolean> {
return (await this.listing()).emails.has(address.trim().toLowerCase());
}
/** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */
async findById(id: string): Promise<DirectoryEntry> {
const [entry] = await this.findByIds([id]);
@@ -132,6 +142,7 @@ export class DirectoryService {
const listing: DirectoryListing = {
entries,
byId: new Map(entries.map((entry) => [entry.id, entry])),
emails: new Set(entries.map((entry) => entry.email.toLowerCase())),
expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS,
};
this.cached = listing;
@@ -0,0 +1,49 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MailUserSettingsPatchDto } from './mail-user-settings.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(
plainToInstance(MailUserSettingsPatchDto, body),
{
whitelist: true,
forbidNonWhitelisted: true,
},
);
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', () => {
it('고를 수 있는 값이면 통과한다 — 한 칸만 보내도 된다 (빈 본문은 서비스가 VAL_001)', async () => {
for (const undoSendSeconds of [0, 5, 10, 20, 30]) {
expect(await constraintsOf({ undoSendSeconds })).toEqual([]);
}
for (const externalImages of ['ask', 'trusted', 'always']) {
expect(await constraintsOf({ externalImages })).toEqual([]);
}
expect(
await constraintsOf({ undoSendSeconds: 20, externalImages: 'trusted' }),
).toEqual([]);
expect(await constraintsOf({})).toEqual([]);
});
it('고를 수 없는 초 · 글로 쓴 초 · null 은 형식 오류', async () => {
for (const undoSendSeconds of [7, -1, 31, 10.5, '10', null, true]) {
expect(await constraintsOf({ undoSendSeconds })).toEqual(['isIn']);
}
});
it('모르는 외부 이미지 값 · 대문자 · null 은 형식 오류', async () => {
for (const externalImages of ['never', 'ASK', '', null, 1]) {
expect(await constraintsOf({ externalImages })).toEqual(['isIn']);
}
});
it('모르는 칸은 받지 않는다', async () => {
expect(await constraintsOf({ theme: 'dark' })).toEqual([
'whitelistValidation',
]);
});
});
@@ -0,0 +1,66 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, ValidateIf } from 'class-validator';
import {
EXTERNAL_IMAGE_MODES,
UNDO_SEND_SECONDS_CHOICES,
type ExternalImageMode,
type MailUserSettings,
type MailUserSettingsPatch,
type UndoSendSeconds,
} from '@/modules/mail-user-settings/mail-user-settings.types';
/** 보낸 칸만 검사한다 — 보내지 않은 칸(undefined)은 그대로 둔다. null 은 형식 오류다 (IsOptional 은 null 도 건너뛴다) */
const whenSent = (_object: object, value: unknown) => value !== undefined;
const UNDO_DESCRIPTION =
`보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` +
'예약 발송에는 더하지 않는다';
const IMAGES_DESCRIPTION =
'외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' +
'스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)';
/** GET · PATCH /api/mail-settings 의 응답 — 계약 2절 MailSettings */
export class MailUserSettingsDto implements MailUserSettings {
@ApiProperty({
description: UNDO_DESCRIPTION,
enum: [...UNDO_SEND_SECONDS_CHOICES],
example: 10,
})
undoSendSeconds!: UndoSendSeconds;
@ApiProperty({
description: IMAGES_DESCRIPTION,
enum: [...EXTERNAL_IMAGE_MODES],
example: 'ask',
})
externalImages!: ExternalImageMode;
}
/**
* PATCH /api/mail-settings 본문 — 바꿀 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다).
* 고를 수 있는 값이 아니면(문자열 "10" · null 포함) 400 VAL_001
*/
export class MailUserSettingsPatchDto implements MailUserSettingsPatch {
@ApiPropertyOptional({
description: UNDO_DESCRIPTION,
enum: [...UNDO_SEND_SECONDS_CHOICES],
example: 20,
})
@ValidateIf(whenSent)
@IsIn(UNDO_SEND_SECONDS_CHOICES, {
message: `보내기 취소 시간은 ${UNDO_SEND_SECONDS_CHOICES.join(' · ')}초 가운데에서 골라 주세요.`,
})
undoSendSeconds?: UndoSendSeconds;
@ApiPropertyOptional({
description: IMAGES_DESCRIPTION,
enum: [...EXTERNAL_IMAGE_MODES],
example: 'trusted',
})
@ValidateIf(whenSent)
@IsIn(EXTERNAL_IMAGE_MODES, {
message: '외부 이미지 설정을 다시 골라 주세요.',
})
externalImages?: ExternalImageMode;
}
@@ -0,0 +1,62 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailUserSettingsDto,
MailUserSettingsPatchDto,
} from './dto/mail-user-settings.dto';
import { MailUserSettingsService } from './mail-user-settings.service';
/** 보내기 · 읽기 설정 라우트 (`/api/mail-settings`) — 계약 2절 "보내기 · 읽기 설정" (9단계) */
export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings';
// 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정", 9단계) — 서버 DB 에 둔다(어느 PC 에서나 같다)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_USER_SETTINGS_ROUTE)
export class MailUserSettingsController {
constructor(private readonly settings: MailUserSettingsService) {}
@Get()
@ApiOperation({
summary: '보내기 · 읽기 설정',
description:
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.',
})
@ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' })
get(@CurrentUser() user: AuthenticatedUser) {
return this.settings.get(user.email);
}
@Patch()
@ApiOperation({
summary: '보내기 · 읽기 설정 바꾸기 (보낸 칸만)',
description:
'바꿀 칸만 보낸다(하나 이상). 보내지 않은 칸은 그대로다 — 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다. 바꾼 뒤의 설정 전체를 돌려준다.',
})
@ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' })
@ApiErrorResponse(
'VAL_001',
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailUserSettingsPatchDto,
) {
return this.settings.update(user.email, {
undoSendSeconds: dto.undoSendSeconds,
externalImages: dto.externalImages,
});
}
}
@@ -0,0 +1,37 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** 외부 이미지 설정 값의 최대 길이 — 'trusted' 가 가장 길다 (여유를 둔다) */
const EXTERNAL_IMAGES_COLUMN_LENGTH = 16;
/**
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원 주소마다 한 줄. 없으면 처음 값(10초 · 늘 묻기)이다.
* 메일 엔진이 아니라 우리 DB 에 둔다 — 어느 PC 에서나 같고, 엔진을 바꿔도 남는다.
* 값은 서비스가 검사한 것만 들어간다 (mail-user-settings.types.ts)
*/
@Entity({ name: 'mail_settings' })
export class MailUserSettingsEntity {
@PrimaryColumn({
name: 'user_email',
type: 'varchar',
length: EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
/** 보내기 취소를 기다리는 초 — 0 · 5 · 10 · 20 · 30 */
@Column({ name: 'undo_send_seconds', type: 'smallint' })
undoSendSeconds!: number;
/** 외부 이미지 — ask · trusted · always */
@Column({
name: 'external_images',
type: 'varchar',
length: EXTERNAL_IMAGES_COLUMN_LENGTH,
})
externalImages!: string;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailUserSettingsController } from './mail-user-settings.controller';
import { MailUserSettingsEntity } from './mail-user-settings.entity';
import { MailUserSettingsRepository } from './mail-user-settings.repository';
import { MailUserSettingsService } from './mail-user-settings.service';
/**
* 보내기 · 읽기 설정 모듈 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다의 설정을 서버 DB(`mail_settings`)에 둔다.
* 메일 모듈이 발송(보내기 취소 초) · 메일 읽기(외부 이미지)에 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다)
*/
@Module({
imports: [TypeOrmModule.forFeature([MailUserSettingsEntity])],
controllers: [MailUserSettingsController],
providers: [MailUserSettingsRepository, MailUserSettingsService],
exports: [MailUserSettingsService],
})
export class MailUserSettingsModule {}
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { MailUserSettingsEntity } from './mail-user-settings.entity';
import type {
MailUserSettings,
MailUserSettingsPatch,
} from './mail-user-settings.types';
/** 바꿀 수 있는 칸 → 표의 열 이름 */
const COLUMNS: Readonly<Record<keyof MailUserSettings, string>> = {
undoSendSeconds: 'undo_send_seconds',
externalImages: 'external_images',
};
/** RETURNING 한 줄 (열 이름이 키다) */
interface ReturnedRow {
readonly user_email: string;
readonly undo_send_seconds: number;
readonly external_images: string;
readonly updated_at: Date;
}
/**
* 보내기 · 읽기 설정 표(`mail_settings`) 접근 — 값의 검사 · 처음 값은 MailUserSettingsService 가 정한다.
*/
@Injectable()
export class MailUserSettingsRepository {
constructor(
@InjectRepository(MailUserSettingsEntity)
private readonly rows: Repository<MailUserSettingsEntity>,
) {}
find(userEmail: string): Promise<MailUserSettingsEntity | null> {
return this.rows.findOneBy({ userEmail });
}
/**
* 보낸 칸만 바꾼다 — 한 문장(INSERT … ON CONFLICT DO UPDATE SET <보낸 칸>)이라 두 탭이 서로 다른 칸을 동시에 바꿔도 하나를 잃지 않는다.
* 줄이 없으면 보내지 않은 칸은 `base`(처음 값)로 넣는다.
* @returns 저장한 뒤의 줄
*/
async upsert(
userEmail: string,
patch: MailUserSettingsPatch,
base: MailUserSettings,
now: Date,
): Promise<MailUserSettingsEntity> {
const changed = (Object.keys(COLUMNS) as (keyof MailUserSettings)[])
.filter((key) => patch[key] !== undefined)
.map((key) => COLUMNS[key]);
const result = await this.rows
.createQueryBuilder()
.insert()
.into(MailUserSettingsEntity)
.values({
userEmail,
undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds,
externalImages: patch.externalImages ?? base.externalImages,
updatedAt: now,
})
.orUpdate([...changed, 'updated_at'], ['user_email'])
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING 열 이름 (행의 키는 열 이름)
.returning([
'userEmail',
'undoSendSeconds',
'externalImages',
'updatedAt',
])
.execute();
const [row] = (result.raw ?? []) as ReturnedRow[];
const saved = new MailUserSettingsEntity();
saved.userEmail = row?.user_email ?? userEmail;
saved.undoSendSeconds = Number(row?.undo_send_seconds);
saved.externalImages = row?.external_images ?? '';
saved.updatedAt = row?.updated_at ?? now;
return saved;
}
}
@@ -0,0 +1,218 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MailUserSettingsEntity } from './mail-user-settings.entity';
import type { MailUserSettingsRepository } from './mail-user-settings.repository';
import {
MAIL_USER_SETTINGS_CACHE_MAX_USERS,
MAIL_USER_SETTINGS_CACHE_TTL_MS,
MailUserSettingsService,
} from './mail-user-settings.service';
import {
DEFAULT_MAIL_USER_SETTINGS,
type MailUserSettings,
type MailUserSettingsPatch,
} from './mail-user-settings.types';
const ARA = 'ara.kim@arasystem.test';
function row(
userEmail: string,
undoSendSeconds: number,
externalImages: string,
): MailUserSettingsEntity {
const entity = new MailUserSettingsEntity();
entity.userEmail = userEmail;
entity.undoSendSeconds = undoSendSeconds;
entity.externalImages = externalImages;
entity.updatedAt = new Date('2026-10-29T00:00:00.000Z');
return entity;
}
/** 대역 표 — 직원마다 한 줄 */
function fakeRepository() {
const rows = new Map<string, MailUserSettingsEntity>();
const find = jest.fn((userEmail: string) =>
Promise.resolve(rows.get(userEmail) ?? null),
);
const upsert = jest.fn(
(
userEmail: string,
patch: MailUserSettingsPatch,
base: MailUserSettings,
) => {
const current = rows.get(userEmail);
const saved = row(
userEmail,
patch.undoSendSeconds ??
current?.undoSendSeconds ??
base.undoSendSeconds,
patch.externalImages ?? current?.externalImages ?? base.externalImages,
);
rows.set(userEmail, saved);
return Promise.resolve(saved);
},
);
return {
rows,
find,
upsert,
repository: { find, upsert } as unknown as MailUserSettingsRepository,
};
}
async function failureOf(
run: () => Promise<unknown>,
): Promise<{ code?: string; reason?: unknown }> {
try {
await run();
} catch (error) {
return error instanceof BusinessException
? { code: error.code, reason: error.context?.reason }
: { code: 'NOT_BUSINESS' };
}
return {};
}
describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', () => {
let logSpy: jest.SpyInstance;
beforeEach(() => {
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다', async () => {
const fake = fakeRepository();
const service = new MailUserSettingsService(fake.repository);
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 10,
externalImages: 'ask',
});
expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({
undoSendSeconds: 10,
externalImages: 'ask',
});
expect(fake.upsert).not.toHaveBeenCalled();
});
it('표의 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다', async () => {
const fake = fakeRepository();
fake.rows.set(ARA, row(ARA, 7, 'always'));
fake.rows.set('b@arasystem.test', row('b@arasystem.test', 30, 'never'));
const service = new MailUserSettingsService(fake.repository);
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 10,
externalImages: 'always',
});
await expect(service.get('b@arasystem.test')).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
});
});
it('1분 기억한다 — 주소의 대소문자 · 앞뒤 공백과 상관없이, 지나면 다시 읽는다', async () => {
jest.useFakeTimers({ now: Date.parse('2026-10-29T00:00:00.000Z') });
const fake = fakeRepository();
fake.rows.set(ARA, row(ARA, 20, 'trusted'));
const service = new MailUserSettingsService(fake.repository);
await service.get(ARA);
await service.get(` ${ARA.toUpperCase()} `);
expect(fake.find).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + MAIL_USER_SETTINGS_CACHE_TTL_MS);
await service.get(ARA);
expect(fake.find).toHaveBeenCalledTimes(2);
});
it('바꾸기는 보낸 칸만 — 저장한 값을 돌려주고 바로 기억에 넣는다 (다시 읽지 않는다), 로그에는 가린 주소', async () => {
const fake = fakeRepository();
fake.rows.set(ARA, row(ARA, 20, 'trusted'));
const service = new MailUserSettingsService(fake.repository);
await service.get(ARA);
await expect(
service.update(ARA, { externalImages: 'always' }),
).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' });
expect(fake.upsert).toHaveBeenCalledWith(
ARA,
{ externalImages: 'always' },
DEFAULT_MAIL_USER_SETTINGS,
expect.any(Date),
);
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 20,
externalImages: 'always',
});
expect(fake.find).toHaveBeenCalledTimes(1);
const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n');
expect(lines).toContain('외부 이미지 always');
expect(lines).not.toContain(ARA);
});
it('빈 바꾸기는 VAL_001 — 저장하지 않는다', async () => {
const fake = fakeRepository();
const service = new MailUserSettingsService(fake.repository);
expect(await failureOf(() => service.update(ARA, {}))).toEqual({
code: 'VAL_001',
reason: 'empty-mail-settings-patch',
});
expect(fake.upsert).not.toHaveBeenCalled();
});
it('읽는 사이 이 서버에서 바꿨으면 늦게 끝난 옛 읽기가 바꾼 값을 덮지 않는다', async () => {
const fake = fakeRepository();
fake.rows.set(ARA, row(ARA, 10, 'ask'));
let release!: () => void;
fake.find.mockImplementationOnce(
(userEmail: string) =>
new Promise((resolve) => {
const old = fake.rows.get(userEmail) ?? null;
release = () => resolve(old);
}),
);
const service = new MailUserSettingsService(fake.repository);
const reading = service.get(ARA);
await service.update(ARA, { undoSendSeconds: 30 });
release();
await expect(reading).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
});
await expect(service.get(ARA)).resolves.toEqual({
undoSendSeconds: 30,
externalImages: 'ask',
});
expect(fake.find).toHaveBeenCalledTimes(1);
});
it(`기억하는 직원은 ${MAIL_USER_SETTINGS_CACHE_MAX_USERS}명까지 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다`, async () => {
const fake = fakeRepository();
const service = new MailUserSettingsService(fake.repository);
await service.get('first@arasystem.test');
for (let i = 0; i < MAIL_USER_SETTINGS_CACHE_MAX_USERS; i += 1) {
await service.get(`u${i}@arasystem.test`);
}
fake.find.mockClear();
await service.get(
`u${MAIL_USER_SETTINGS_CACHE_MAX_USERS - 1}@arasystem.test`,
);
expect(fake.find).not.toHaveBeenCalled();
await service.get('first@arasystem.test');
expect(fake.find).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,125 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { MailUserSettingsEntity } from './mail-user-settings.entity';
import { MailUserSettingsRepository } from './mail-user-settings.repository';
import {
DEFAULT_MAIL_USER_SETTINGS,
isExternalImageMode,
isUndoSendSeconds,
type MailUserSettings,
type MailUserSettingsPatch,
} from './mail-user-settings.types';
/** 직원마다 설정을 기억하는 시간 — 발송 · 메일 읽기마다 DB 를 묻지 않게. 이 서버에서 바꾸면 바로 맞춘다 */
export const MAIL_USER_SETTINGS_CACHE_TTL_MS = 60_000;
/** 기억하는 직원 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 (한 사람에 몇십 바이트) */
export const MAIL_USER_SETTINGS_CACHE_MAX_USERS = 1_000;
interface CacheEntry {
readonly value: MailUserSettings;
readonly expiresAt: number;
/** 이 값을 넣은 차례 — 그보다 앞서 시작한 읽기는 이 값을 덮지 않는다 */
readonly sequence: number;
}
function cacheKey(userEmail: string): string {
return userEmail.trim().toLowerCase();
}
/** 표의 줄 → 설정 — 모르는 값(손으로 고친 값 등)은 그 칸만 처음 값으로 읽는다 */
function toSettings(row: MailUserSettingsEntity | null): MailUserSettings {
if (row === null) return DEFAULT_MAIL_USER_SETTINGS;
return {
undoSendSeconds: isUndoSendSeconds(row.undoSendSeconds)
? row.undoSendSeconds
: DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds,
externalImages: isExternalImageMode(row.externalImages)
? row.externalImages
: DEFAULT_MAIL_USER_SETTINGS.externalImages,
};
}
/**
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
* - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다
* - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다
*
* ⚠️ 기억은 프로세스 메모리다. 인스턴스를 늘리면 다른 인스턴스는 바꾼 값을 1분까지 늦게 본다 (보내기 취소 시간 · 외부 이미지가 1분 늦게 바뀔 뿐 — 틀린 값을 저장하지는 않는다)
*/
@Injectable()
export class MailUserSettingsService {
private readonly logger = new Logger(MailUserSettingsService.name);
private readonly cache = new Map<string, CacheEntry>();
/** 기억에 넣은 차례 — 읽는 사이 바뀐 값을 옛 읽기가 덮지 않게 */
private sequence = 0;
constructor(private readonly repository: MailUserSettingsRepository) {}
async get(userEmail: string): Promise<MailUserSettings> {
const key = cacheKey(userEmail);
const cached = this.cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
// 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다
this.cache.delete(key);
this.cache.set(key, cached);
return cached.value;
}
const startedAt = this.sequence;
const value = toSettings(await this.repository.find(userEmail));
const latest = this.cache.get(key);
// 읽는 사이 이 서버에서 바꿨으면 그 값이 더 새롭다 — 덮지 않고 그 값을 준다
if (latest && latest.sequence > startedAt) return latest.value;
this.remember(key, value);
return value;
}
async update(
userEmail: string,
patch: MailUserSettingsPatch,
): Promise<MailUserSettings> {
if (
patch.undoSendSeconds === undefined &&
patch.externalImages === undefined
) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-mail-settings-patch' },
});
}
const saved = toSettings(
await this.repository.upsert(
userEmail,
patch,
DEFAULT_MAIL_USER_SETTINGS,
new Date(),
),
);
this.remember(cacheKey(userEmail), saved);
this.logger.log(
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`,
);
return saved;
}
private remember(key: string, value: MailUserSettings): void {
const now = Date.now();
for (const [other, entry] of this.cache) {
if (entry.expiresAt <= now) this.cache.delete(other);
}
this.sequence += 1;
this.cache.delete(key);
this.cache.set(key, {
value,
expiresAt: now + MAIL_USER_SETTINGS_CACHE_TTL_MS,
sequence: this.sequence,
});
while (this.cache.size > MAIL_USER_SETTINGS_CACHE_MAX_USERS) {
const oldest = this.cache.keys().next().value;
if (oldest === undefined) break;
this.cache.delete(oldest);
}
}
}
@@ -0,0 +1,40 @@
/**
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 직원마다 한 줄, 서버 DB(`mail_settings`)에 둔다.
* 메일 엔진 연결 설정(modules/mail/mail.settings.ts 의 MailSettings)과는 다르다 — 이름이 겹치지 않게 MailUserSettings 로 부른다.
*/
/** 보내기 취소를 기다리는 초 — 0 이면 바로 보낸다 */
export const UNDO_SEND_SECONDS_CHOICES = [0, 5, 10, 20, 30] as const;
export type UndoSendSeconds = (typeof UNDO_SEND_SECONDS_CHOICES)[number];
/**
* 외부 이미지 — ask 늘 묻기 · trusted 보낸 사람이 내 주소록 · 사내 목록에 있으면 늘 보기 · always 늘 보기.
* 스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)
*/
export const EXTERNAL_IMAGE_MODES = ['ask', 'trusted', 'always'] as const;
export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number];
export interface MailUserSettings {
readonly undoSendSeconds: UndoSendSeconds;
readonly externalImages: ExternalImageMode;
}
/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */
export type MailUserSettingsPatch = Partial<MailUserSettings>;
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */
export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({
undoSendSeconds: 10,
externalImages: 'ask',
});
/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */
export function isUndoSendSeconds(value: unknown): value is UndoSendSeconds {
return (UNDO_SEND_SECONDS_CHOICES as readonly unknown[]).includes(value);
}
export function isExternalImageMode(
value: unknown,
): value is ExternalImageMode {
return (EXTERNAL_IMAGE_MODES as readonly unknown[]).includes(value);
}
@@ -0,0 +1,62 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
import { MailBlockCreateDto } from './mail-block.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(MailBlockCreateDto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('보낸 사람 차단 본문 (POST /api/mail-blocks — 9단계)', () => {
it('주소 · @도메인 — 앞뒤 공백은 빼고 본다 (막을 수 없는 대상은 서비스가 본다)', async () => {
for (const address of [
'kim@spam.example',
' Kim@Spam.Example ',
'@spam.example',
'@xn--vg1b002a.example',
'@arasystem.test',
]) {
expect(await constraintsOf({ address })).toEqual([]);
}
});
it('검사 차례는 형식 → 길이 → 모양', async () => {
expect(await constraintsOf({ address: 42 })).toEqual([
'isString',
'maxCodeUnits',
'isBlockTarget',
]);
expect(
await constraintsOf({
address: `${'a'.repeat(MAX_EMAIL_LENGTH)}@spam.example`,
}),
).toEqual(['maxCodeUnits', 'isBlockTarget']);
});
it.each([
['빈 글', ' '],
['@ 만', '@'],
['점 없는 도메인', '@localhost'],
['IP 주소', '@127.0.0.1'],
['숫자 최상위 도메인', '@spam.123'],
['밑줄 도메인', '@spam_ads.example'],
['한글 도메인(punycode 로)', '@스팸.example'],
['${ 가 든 주소', '${x}@spam.example'],
['주소 모양이 아님', 'kim at spam.example'],
])('모양이 틀리면 형식 오류 — %s', async (_label, address) => {
expect(await constraintsOf({ address })).toEqual(['isBlockTarget']);
});
it('주소가 없거나 모르는 칸이 있으면 형식 오류', async () => {
expect(await constraintsOf({})).not.toEqual([]);
expect(
await constraintsOf({ address: 'kim@spam.example', note: '메모' }),
).toEqual(['whitelistValidation']);
});
});
@@ -0,0 +1,98 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, ValidateBy } from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import { parseBlockTarget } from '@/modules/mail/mail-blocks';
import {
MAX_EMAIL_LENGTH,
MAX_MAIL_BLOCKS,
} from '@/modules/mail/mail.constants';
import type { BlockedSender, MailBlockList } from '@/modules/mail/mail.types';
/**
* 보낸 사람 차단 — POST 본문과 응답 (계약 2절 "보낸 사람 차단" — 9단계).
* 여기서는 모양만 본다(주소는 발송의 주소와 같은 규칙 · 도메인은 `@` + 호스트 이름 — mail-blocks.ts). 내 주소 · 사내 도메인 ·
* 여러 사람이 쓰는 메일 서비스의 도메인 · 500개는 서비스 · 어댑터가 본다 (모두 400 VAL_001).
* 검사 차례(applyDecorators — 적은 차례대로): 형식 → 길이 → 모양
*/
/** 차단할 주소 · @도메인의 모양 */
function IsBlockTarget() {
return ValidateBy(
{
name: 'isBlockTarget',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && parseBlockTarget(value) !== null,
},
},
{ message: '메일 주소나 @도메인 형식으로 써 주세요.' },
);
}
function BlockAddress(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }),
MaxCodeUnits(MAX_EMAIL_LENGTH, {
message: `차단할 주소는 ${MAX_EMAIL_LENGTH}자 이하로 써 주세요.`,
}),
IsBlockTarget(),
);
}
/** POST /api/mail-blocks 본문 */
export class MailBlockCreateDto {
@ApiProperty({
description:
`차단할 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두, 하위 도메인은 따로) — 앞뒤 공백을 빼고 ${MAX_EMAIL_LENGTH}자까지, 소문자로 맞춰 저장한다. ` +
'내 주소 · 사내 도메인(그 하위 포함)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등)는 도메인으로 막을 수 없다(주소로는 된다)',
example: '@spam.example',
maxLength: MAX_EMAIL_LENGTH,
})
@BlockAddress()
address!: string;
}
/** 계약 2절 BlockedSender */
export class BlockedSenderDto implements BlockedSender {
@ApiProperty({
description:
'소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example")',
example: 'kim@spam.example',
})
address!: string;
@ApiProperty({
description: '차단한 시각 (ISO 8601 UTC)',
example: '2026-10-29T06:00:00.000Z',
})
blockedAt!: string;
}
/** 계약 2절 MailBlockList — GET /api/mail-blocks */
export class MailBlockListDto implements MailBlockList {
@ApiProperty({
type: [BlockedSenderDto],
description: `최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지`,
})
senders!: BlockedSenderDto[];
@ApiProperty({
description:
'엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다. false 면 지금은 차단도 동작하지 않는다(필터 · 부재중 · 차단 가운데 무엇이든 저장하면 다시 켜진다)',
example: true,
})
isActive!: boolean;
}
/** DELETE /api/mail-blocks/{address} 의 응답 */
export class MailBlockRemovedDto {
@ApiProperty({
description: '차단을 푼 주소 · 도메인 (소문자로 맞춘 값)',
example: 'kim@spam.example',
})
address!: string;
}
@@ -21,6 +21,8 @@ import type {
MessageDetail,
MessagesPatchResult,
MessageSummary,
PendingSend,
PendingSendKind,
RecipientRejectionReason,
RecipientSuggestion,
RejectedRecipient,
@@ -215,6 +217,25 @@ export class MailQuotaDto implements MailQuota {
attachmentTotalMaxBytes!: number;
}
const PENDING_SEND_KINDS: PendingSendKind[] = ['undo', 'scheduled'];
/** 계약 2절 "보내기 취소 · 예약 발송"의 PendingSend — 엔진이 붙잡아 둔 발송 (9단계) */
export class PendingSendDto implements PendingSend {
@ApiProperty({
description:
'엔진이 보낼 시각 (ISO 8601 UTC) — 클라이언트는 보내기 취소의 남은 시간을 설정한 초가 아니라 이 시각으로 센다',
example: '2026-10-30T00:00:00.000Z',
})
sendAt!: string;
@ApiProperty({
description: 'undo = 보내기 취소를 기다리는 중 · scheduled = 예약 발송',
enum: PENDING_SEND_KINDS,
example: 'scheduled',
})
kind!: PendingSendKind;
}
export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '메일 id', example: 'eaaaaab' })
id!: string;
@@ -270,6 +291,14 @@ export class MessageSummaryDto implements MessageSummary {
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
size!: number;
@ApiProperty({
description:
'아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약 — 엔진의 기다리는 제출로 정한다)이면 그 발송, 그 밖에는 null (9단계)',
type: PendingSendDto,
nullable: true,
})
pendingSend!: PendingSendDto | null;
}
export class MessageDetailDto
@@ -324,11 +353,19 @@ export class MessageDetailDto
textBody!: string | null;
@ApiProperty({
description: '외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내',
description:
'외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내. 보이게 했으면(images=show 또는 설정) false',
example: false,
})
hasBlockedImages!: boolean;
@ApiProperty({
description:
'외부 이미지를 중계 주소(/api/mail-images/{token})로 바꿔 넣었으면 true — images=show 또는 설정(externalImages)으로 (9단계 "외부 이미지 보기")',
example: false,
})
imagesShown!: boolean;
@ApiProperty({
description:
'HTML 이 있었지만 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true → 서식 없이 textBody 를 보여 주고 안내한다 (textBody 도 없으면 표시할 수 없음). ' +
@@ -400,6 +437,40 @@ export class SentMessageDto implements SentMessage {
type: [RejectedRecipientDto],
})
rejectedRecipients!: RejectedRecipientDto[];
@ApiProperty({
description:
'엔진이 붙잡아 둔 발송이면 그 정보 — 보내기 취소(설정의 초만큼) · 예약(sendAt). 바로 보냈으면 null (9단계)',
type: PendingSendDto,
nullable: true,
})
pendingSend!: PendingSendDto | null;
}
/** GET /api/scheduled-messages 의 응답 — 예약한 메일, 보낼 시각 차례 (9단계) */
export class ScheduledMessagePageDto {
@ApiProperty({
type: [MessageSummaryDto],
description:
'예약한 메일(pendingSend.kind 가 scheduled 이고 아직 보내지 않은 것) — 보낼 시각 차례(같으면 id 차례). 보낸 메일은 저절로 빠진다',
})
items!: MessageSummaryDto[];
@ApiProperty({
type: PageInfoDto,
description: 'total 포함 — 메일함 패널의 「예약됨」 통 수',
})
pageInfo!: PageInfoDto;
}
/** DELETE /api/scheduled-messages/{id} 의 응답 — 취소한 발송을 되돌린 임시 저장본 (9단계) */
export class ScheduledMessageCanceledDto {
@ApiProperty({
description:
'임시 저장본 id — 보낸 사본과 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 받는 사람 · 제목 · 본문 · 첨부는 그대로다',
example: 'maaaaad',
})
draftId!: string;
}
/** 계약 2절 "새 메일 확인"의 MailState */
@@ -38,7 +38,8 @@ export function isUtcDateTime(value: unknown): boolean {
);
}
function IsUtcDateTime(options: ValidationOptions) {
/** ISO 8601 UTC 시각 검사 — 부재중 기간 · 예약 발송 시각(send-message.dto.ts — 9단계)이 같이 쓴다 */
export function IsUtcDateTime(options: ValidationOptions) {
return ValidateBy(
{ name: 'isUtcDateTime', validator: { validate: isUtcDateTime } },
options,
@@ -0,0 +1,27 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MessageDetailQueryDto } from './message-detail-query.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고) */
async function constraintsOf(query: object): Promise<string[]> {
const errors = await validate(plainToInstance(MessageDetailQueryDto, query), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 images)', () => {
it('images 는 없거나 show 만', async () => {
expect(await constraintsOf({})).toEqual([]);
expect(await constraintsOf({ images: 'show' })).toEqual([]);
});
it('모르는 값 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => {
for (const images of ['SHOW', '1', 'true', '']) {
expect(await constraintsOf({ images })).toEqual(['isIn']);
}
expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']);
});
});
@@ -0,0 +1,21 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */
export const SHOW_IMAGES = 'show';
/**
* GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도).
* 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001
*/
export class MessageDetailQueryDto {
@ApiPropertyOptional({
description:
'show — 외부 이미지를 보이는 본문(imagesShown: true · hasBlockedImages: false). 없으면 설정(ask 지우기 · trusted 주소록 · 사내 목록의 보낸 사람 · always 늘)을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다',
enum: [SHOW_IMAGES],
example: SHOW_IMAGES,
})
@IsOptional()
@IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' })
images?: typeof SHOW_IMAGES;
}
@@ -94,3 +94,34 @@ describe('SendMessageDto 첨부 목록', () => {
).toContain('arrayMaxSize');
});
});
describe('SendMessageDto 예약 시각 (sendAt — 9단계)', () => {
const message = (sendAt: unknown) => ({
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
subject: '제목',
body: '본문',
sendAt,
});
it('없거나 null 이면(보내기 취소 설정대로) · ISO 8601 UTC 시각이면 형식은 맞다 — 범위(5분 뒤 ~ 7일)는 서비스가 본다', async () => {
expect(await constraintsOf(message(undefined))).toEqual([]);
expect(await constraintsOf(message(null))).toEqual([]);
expect(await constraintsOf(message('2030-01-01T09:00:00.000Z'))).toEqual(
[],
);
expect(await constraintsOf(message('2030-01-01T09:00:00Z'))).toEqual([]);
});
it('시간대 오프셋 · 날짜만 · 없는 날짜 · 소문자 z · 글이 아님은 형식 오류', async () => {
for (const sendAt of [
'2030-01-01T18:00:00+09:00',
'2030-01-01',
'2030-02-30T09:00:00.000Z',
'2030-01-01T09:00:00.000z',
'내일 아침',
1893488400000,
]) {
expect(await constraintsOf(message(sendAt))).not.toEqual([]);
}
});
});
@@ -17,6 +17,7 @@ import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
} from '@/modules/mail/attachment.policy';
import { IsUtcDateTime } from '@/modules/mail/dto/mail-vacation.dto';
import {
MAIL_ID_PATTERN,
MAX_BODY_HTML_LENGTH,
@@ -188,6 +189,18 @@ export class SendMessageDto extends ComposeContentDto {
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' })
draftId?: string;
@ApiPropertyOptional({
description:
'예약 발송 시각 (ISO 8601 UTC — 소수 초는 버린다) — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안(엔진 한도). 밖이면 400 VAL_001. ' +
'메일 서버가 그때까지 붙잡았다가 보낸다(창을 닫아도 보내진다 — 예약에는 보내기 취소 시간을 더하지 않는다). 없거나 null 이면 그 직원 설정의 보내기 취소 초만큼 붙잡는다 (9단계)',
example: '2026-10-30T00:00:00.000Z',
nullable: true,
})
@IsOptional()
@IsString({ message: '예약 시각 형식이 올바르지 않습니다.' })
@IsUtcDateTime({ message: '예약 시각 형식이 올바르지 않습니다.' })
sendAt?: string | null;
}
/** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */
@@ -110,6 +110,7 @@ describe('sanitizeMailHtml', () => {
html: '<b>y</b>',
hasBlockedImages: false,
embeddedContentIds: new Set(),
proxiedImageCount: 0,
});
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
expect(cpuMs()).toBeLessThan(2_000);
@@ -266,6 +267,114 @@ describe('sanitizeMailHtml', () => {
});
});
describe('외부 이미지 보기 (externalImageSrc — 9단계)', () => {
/** 중계 주소 — 테스트에서는 원래 주소를 그대로 보이게 둔다 (실제 표는 암호화한 글이다) */
const proxied = (url: URL) =>
`/api/mail-images/${Buffer.from(url.href).toString('base64url')}`;
const show = (html: string) =>
sanitizeMailHtml(html, new Map(), { externalImageSrc: proxied });
it('<img> 의 외부 src(http · https · //)만 중계 주소로 바꾼다 — 바꾼 수를 알려 준다', () => {
const result = show(
'<p>안녕</p><img src="https://news.example.com/a.png" alt="그림" width="600">' +
'<img src="http://news.example.com/b.gif"><img src="//cdn.example.com/c.webp">',
);
expect(result.html).toBe(
'<p>안녕</p>' +
`<img alt="그림" width="600" src="${proxied(new URL('https://news.example.com/a.png'))}" />` +
`<img src="${proxied(new URL('http://news.example.com/b.gif'))}" />` +
`<img src="${proxied(new URL('https://cdn.example.com/c.webp'))}" />`,
);
expect(result.proxiedImageCount).toBe(3);
expect(result.hasBlockedImages).toBe(false);
});
it('srcset · style 의 url() · background 속성은 지금처럼 지운다 (알려진 한계) — 지운 것이 있으면 hasBlockedImages', () => {
const result = show(
'<img srcset="https://cdn.example.com/a.png 2x">' +
'<div style="background:url(https://t.example/bg.png)">배경</div>' +
'<table><tr><td background="https://t.example/bg.png">a</td></tr></table>',
);
expect(result.html).toBe(
'<div>배경</div><table><tr><td>a</td></tr></table>',
);
expect(result.proxiedImageCount).toBe(0);
expect(result.hasBlockedImages).toBe(true);
});
it('중계할 수 없는 주소(기본 포트가 아님 · 사용자 정보 · 2,048자 초과)는 지운다', () => {
const result = show(
'<img src="https://news.example.com:8443/a.png">' +
'<img src="https://user:pw@news.example.com/a.png">' +
`<img src="https://news.example.com/${'a'.repeat(2_100)}">`,
);
expect(result.html).toBe('');
expect(result.proxiedImageCount).toBe(0);
expect(result.hasBlockedImages).toBe(true);
});
it('같은 주소는 같은 중계 주소를 한 번만 만든다 · 서로 다른 주소는 한 통에 100개까지(그 뒤는 지운다)', () => {
let issued = 0;
const counting = (url: URL) => {
issued += 1;
return proxied(url);
};
const same = sanitizeMailHtml(
'<img src="https://t.example/s.gif">'.repeat(150),
new Map(),
{ externalImageSrc: counting },
);
expect(issued).toBe(1);
expect(same.proxiedImageCount).toBe(1);
expect(same.html.match(/<img /g)).toHaveLength(150);
issued = 0;
const many = sanitizeMailHtml(
Array.from(
{ length: 120 },
(_, index) => `<img src="https://t.example/${index}.gif">`,
).join(''),
new Map(),
{ externalImageSrc: counting },
);
expect(issued).toBe(100);
expect(many.proxiedImageCount).toBe(100);
expect(many.html.match(/<img /g)).toHaveLength(100);
expect(many.hasBlockedImages).toBe(true);
});
it('버린 영역(svg · template) 안의 이미지에는 중계 주소를 만들지 않는다 · cid · data: 이미지는 그대로', () => {
let issued = 0;
const result = sanitizeMailHtml(
'<svg><img src="https://t.example/hidden.png"></svg>' +
'<template><img src="https://t.example/t.png"></template>' +
`<img src="cid:logo@x"><img src="${PNG_DATA_URI}">`,
new Map([['logo@x', PNG_DATA_URI]]),
{
externalImageSrc: (url) => {
issued += 1;
return proxied(url);
},
},
);
expect(issued).toBe(0);
expect(result.html).toBe(
`<img src="${PNG_DATA_URI}" /><img src="${PNG_DATA_URI}" />`,
);
});
it('보이게 하지 않으면(옵션 없음) 바꾼 수는 0 이다', () => {
expect(
sanitizeMailHtml('<img src="https://t.example/a.png">')
.proxiedImageCount,
).toBe(0);
});
});
describe('위험한 요소', () => {
it.each([
[
@@ -1,5 +1,7 @@
import sanitizeHtml from 'sanitize-html';
import { MAX_PROXIED_IMAGES_PER_MESSAGE } from '@/modules/mail/images/mail-image.constants';
import { parseProxiedImageUrl } from '@/modules/mail/images/mail-image-url';
import { normalizeContentId } from './inline-images';
/**
@@ -13,6 +15,9 @@ import { normalizeContentId } from './inline-images';
* - 이벤트 핸들러(on*)·id·class 는 허용 속성이 아니므로 버린다
* - 링크는 http·https·mailto 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`)
* - 외부(http·https·//) 이미지는 지우고 `hasBlockedImages` 를 켠다 (style 의 url() 포함 — 추적 픽셀 차단)
* - 보이게 할 때(옵션 `externalImageSrc` — 9단계 "외부 이미지 보기")는 `<img>` 의 src 만 중계 주소로 바꾼다:
* http · https · `//` 이고 기본 포트 · 2,048자 이하(images/mail-image-url.ts)인 주소를 한 통에 100개까지. 그 밖의 외부 이미지 ·
* srcset · style 과 속성의 배경 그림(url())은 지금처럼 지운다(알려진 한계). 같은 주소는 같은 중계 주소를 쓴다
* - `cid:` 이미지는 미리 만든 data: URI 로 바꾸고, 없으면 지운다
*
* ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 길이·중첩 비용을 본다 (mail.service.ts) —
@@ -29,8 +34,10 @@ import { normalizeContentId } from './inline-images';
export interface SanitizedMailHtml {
readonly html: string;
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 (보이게 할 때도 지운 것 — srcset · 배경 그림 · 한도 밖 — 이 있으면 true) */
readonly hasBlockedImages: boolean;
/** 중계 주소로 바꿔 넣은 외부 이미지 수 (보이게 할 때만 — 같은 주소는 한 번) */
readonly proxiedImageCount: number;
/**
* 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값).
* 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다
@@ -295,28 +302,36 @@ function secureLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Attributes {
return next;
}
/** 이미지 하나를 정할 때 쓰는 것 — 정화 한 번(한 통)마다 하나 */
interface ImageContext {
/** 정규화한 Content-ID → data: URI */
readonly inlineImages: ReadonlyMap<string, string>;
readonly markBlocked: () => void;
readonly markEmbedded: (contentId: string) => void;
/** 외부 이미지 주소 → 중계 주소 — 보이게 할 때만 있고, 넣지 못하면(주소 · 개수 한도) null */
readonly proxy: ((src: string) => string | null) | null;
}
function resolveImage(
attribs: sanitizeHtml.Attributes,
inlineImages: ReadonlyMap<string, string>,
markBlocked: () => void,
markEmbedded: (contentId: string) => void,
context: ImageContext,
): sanitizeHtml.Attributes {
const next: sanitizeHtml.Attributes = { ...attribs };
delete next.src;
delete next.srcset;
if (attribs.srcset && EMBEDDED_EXTERNAL_URL.test(attribs.srcset)) {
markBlocked();
context.markBlocked();
}
const src = attribs.src?.trim() ?? '';
if (/^cid:/i.test(src)) {
const contentId = normalizeContentId(src.slice(4));
const dataUri = inlineImages.get(contentId);
const dataUri = context.inlineImages.get(contentId);
if (dataUri) {
next.src = dataUri;
// 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다
markEmbedded(contentId);
context.markEmbedded(contentId);
}
return next;
}
@@ -324,7 +339,15 @@ function resolveImage(
next.src = src;
return next;
}
if (EXTERNAL_URL.test(src)) markBlocked();
if (EXTERNAL_URL.test(src)) {
// 보이게 할 때 — 같은 서버의 중계 주소(상대 경로, 스킴 없음)로 바꾼다. 넣지 못한 것은 지운다
const proxied = context.proxy?.(src) ?? null;
if (proxied !== null) {
next.src = proxied;
return next;
}
context.markBlocked();
}
return next;
}
@@ -337,6 +360,14 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
);
}
export interface SanitizeMailHtmlOptions {
/**
* 외부 이미지를 보인다(9단계) — 중계할 수 있는 원래 주소(정규화한 것 — images/mail-image-url.ts)를 받아 중계 주소(`/api/mail-images/…`)를 돌려준다.
* 없으면 지금처럼 외부 이미지를 지운다
*/
readonly externalImageSrc?: (url: URL) => string;
}
/**
* 메일 HTML 을 정화한다.
* @param inlineImages 정규화한 Content-ID → data: URI (서비스 계층이 크기·형식을 확인해 만든 것)
@@ -344,6 +375,7 @@ function loadsExternalImage(attribs: sanitizeHtml.Attributes): boolean {
export function sanitizeMailHtml(
html: string,
inlineImages: ReadonlyMap<string, string> = new Map(),
options: SanitizeMailHtmlOptions = {},
): SanitizedMailHtml {
let hasBlockedImages = false;
const markBlocked = () => {
@@ -353,6 +385,27 @@ export function sanitizeMailHtml(
const markEmbedded = (contentId: string) => {
embeddedContentIds.add(contentId);
};
// 원래 주소(정규화한 것) → 중계 주소 — 같은 주소는 같은 중계 주소(브라우저가 한 번만 받는다). 한 통에 100개까지
const proxiedBySource = new Map<string, string>();
const { externalImageSrc } = options;
const proxy = externalImageSrc
? (src: string): string | null => {
const url = parseProxiedImageUrl(src);
if (url === null) return null;
const known = proxiedBySource.get(url.href);
if (known !== undefined) return known;
if (proxiedBySource.size >= MAX_PROXIED_IMAGES_PER_MESSAGE) return null;
const proxied = externalImageSrc(url);
proxiedBySource.set(url.href, proxied);
return proxied;
}
: null;
const imageContext: ImageContext = {
inlineImages,
markBlocked,
markEmbedded,
proxy,
};
const output = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
@@ -367,12 +420,7 @@ export function sanitizeMailHtml(
transformTags: {
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
img: (tagName, attribs) => {
const next = resolveImage(
attribs,
inlineImages,
markBlocked,
markEmbedded,
);
const next = resolveImage(attribs, imageContext);
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
@@ -390,5 +438,10 @@ export function sanitizeMailHtml(
},
});
return { html: output, hasBlockedImages, embeddedContentIds };
return {
html: output,
hasBlockedImages,
embeddedContentIds,
proxiedImageCount: proxiedBySource.size,
};
}
@@ -601,6 +601,7 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
html: '<div><p>서명</p></div>',
hasBlockedImages: false,
embeddedContentIds: new Set(),
proxiedImageCount: 0,
});
});
@@ -1,7 +1,11 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
findSignatureImageIds,
parseSignatureImageContentId,
parseSignatureImageId,
restoreSignatureImageSources,
rewriteSignatureImages,
signatureImageContentId,
signatureImagePath,
} from './signature-images';
@@ -56,4 +60,45 @@ describe('서명 그림 주소 (signature-images)', () => {
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="로고" />`,
);
});
it('보낸 메일의 서명 그림 Content-ID — 보내는 사람 도메인(소문자)으로 만들고, 그 꼴이면 그림 id 를 돌려준다', () => {
expect(signatureImageContentId(ID, 'Ara.Kim@ArASystem.test')).toBe(
`sig-${ID}@arasystem.test`,
);
expect(parseSignatureImageContentId(`sig-${ID}@arasystem.test`)).toBe(ID);
expect(
parseSignatureImageContentId(` SIG-${ID.toUpperCase()}@x.example `),
).toBe(ID);
for (const cid of [
`logo@x.example`,
`sig-${ID}`,
`sig-not-a-uuid@x.example`,
`xsig-${ID}@x.example`,
`sig-${ID}@a@b`,
]) {
expect(parseSignatureImageContentId(cid)).toBeNull();
}
});
it('취소한 발송을 이어 쓸 때(9단계) — 본문의 cid:sig-… 그림 주소를 서명 그림 주소로 되돌린다 (다른 그림 · 속성은 그대로)', () => {
const sent =
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="" />` +
`<img src='cid:SIG-${OTHER.toUpperCase()}@arasystem.test'>` +
'<img src="cid:logo@partner.example" />';
expect(restoreSignatureImageSources(sent)).toBe(
`<p>서명</p><img src="${signatureImagePath(ID)}" width="144" alt="" />` +
`<img src="${signatureImagePath(OTHER)}">` +
'<img src="cid:logo@partner.example" />',
);
});
it('되돌리기는 입력 길이에 비례한다 — src= 뒤 공백이 아주 길어도', () => {
const cpuMs = startCpuTimer();
const html = `<img src=${' '.repeat(500_000)}"x">`.repeat(4);
expect(restoreSignatureImageSources(html)).toBe(html);
// CPU 시간 (test/cpu-time.ts) — 지금 수십 ms. 공백을 여러 \s* 가 나눠 가지면 공백 수의 제곱이다
expect(cpuMs()).toBeLessThan(2_000);
});
});
@@ -31,11 +31,55 @@ function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** 보낸 메일의 서명 그림 Content-ID — `sig-{그림 id}@{도메인}` (signatureImageContentId 가 만든 모양) */
const SIGNATURE_IMAGE_CONTENT_ID = new RegExp(
`^sig-(${UUID})@[^\\s@<>]+$`,
'i',
);
/**
* 보낸 메일 본문의 서명 그림 주소 — `src="cid:sig-{그림 id}@{도메인}"`. 따옴표는 정화가 쓰는 큰따옴표와 작은따옴표 둘 다.
* 같은 공백을 여러 `\s*` 가 나눠 갖지 않게 둔다(입력 길이에 비례)
*/
const SENT_SIGNATURE_IMAGE_SRC = new RegExp(
`\\bsrc\\s*=\\s*(["'])cid:sig-(${UUID})@[^"'\\s<>]*\\1`,
'gi',
);
/** 그림 id 의 주소 */
export function signatureImagePath(id: string): string {
return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`;
}
/**
* 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다). 도메인은 보내는 사람의 것(소문자)
*/
export function signatureImageContentId(
imageId: string,
sender: string,
): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
return `sig-${imageId}@${domain}`;
}
/** 우리가 보낸 메일의 서명 그림 부분인가 — Content-ID 가 `sig-{UUID}@…` 꼴이면 그 그림 id(소문자), 아니면 null */
export function parseSignatureImageContentId(cid: string): string | null {
const match = cid.trim().match(SIGNATURE_IMAGE_CONTENT_ID);
return match ? match[1].toLowerCase() : null;
}
/**
* 보낸 메일 본문의 서명 그림(`cid:sig-…`)을 서명 그림 주소로 되돌린다 — 취소한 발송을 임시 저장본으로 이어 쓸 때(9단계 "보내기 취소").
* 보내는 정화 **전에** 쓴다 — 정화가 그 사람 자신의 그림만 남긴다(남의 그림 id · 지운 그림은 정화가 지운다)
*/
export function restoreSignatureImageSources(html: string): string {
return html.replace(
SENT_SIGNATURE_IMAGE_SRC,
(_whole, _quote: string, id: string) =>
`src="${signatureImagePath(id.toLowerCase())}"`,
);
}
/** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */
export function parseSignatureImageId(src: string | undefined): string | null {
const match = src?.trim().match(SIGNATURE_IMAGE_SRC);
@@ -0,0 +1,425 @@
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
import {
MAIL_IMAGE_MAX_BYTES,
MAIL_IMAGE_USER_AGENT,
} from './mail-image.constants';
import {
MailImageFetcher,
type MailImageRequest,
type MailImageResolver,
type MailImageResponse,
type MailImageTransport,
type ResolvedAddress,
} from './mail-image-fetcher';
const PUBLIC: ResolvedAddress = { address: '93.184.216.34', family: 4 };
const PUBLIC_V6: ResolvedAddress = {
address: '2606:2800:220:1:248:1893:25c8:1946',
family: 6,
};
/** 가짜 응답 — 본문 조각을 차례로 준다. 닫으면 closed */
function reply(
status: number,
options: {
body?: Buffer[];
location?: string;
contentLength?: number | null;
failAfter?: number;
} = {},
): MailImageResponse & { closed: boolean } {
const chunks = options.body ?? [];
const response = {
status,
location: options.location ?? null,
contentLength:
options.contentLength === undefined
? chunks.reduce((sum, chunk) => sum + chunk.length, 0)
: options.contentLength,
closed: false,
body: (async function* () {
for (const [index, chunk] of chunks.entries()) {
if (options.failAfter !== undefined && index >= options.failAfter) {
throw new Error('socket hang up');
}
await Promise.resolve();
yield chunk;
}
})(),
close() {
response.closed = true;
},
};
return response;
}
/** 이름 → 주소 표 · 주소 → 응답 차례로 흉내 내는 대역 — 받은 요청을 모은다 */
function fakes(
dns: Record<string, ResolvedAddress[] | Error>,
replies: (
| MailImageResponse
| Error
| ((request: MailImageRequest) => Promise<MailImageResponse>)
)[],
) {
const lookups: string[] = [];
const requests: MailImageRequest[] = [];
const resolver: MailImageResolver = {
resolve: (hostname) => {
lookups.push(hostname);
const answer = dns[hostname];
if (answer === undefined) return Promise.reject(new Error('ENOTFOUND'));
return answer instanceof Error
? Promise.reject(answer)
: Promise.resolve(answer);
},
};
const transport: MailImageTransport = {
get: (request) => {
requests.push(request);
const next = replies.shift();
if (next === undefined) return Promise.reject(new Error('no reply'));
if (next instanceof Error) return Promise.reject(next);
if (typeof next === 'function') return next(request);
return Promise.resolve(next);
},
};
return {
fetcher: new MailImageFetcher(resolver, transport),
lookups,
requests,
};
}
const live = () => new AbortController().signal;
describe('MailImageFetcher — 바깥 이미지 받기 (SSRF 막기)', () => {
it('이름을 풀어 공인 주소면 그 주소로만 연결해 받는다 — 형식은 받은 바이트로, 머리글은 고정한 것뿐', async () => {
const png = pngBytes(600, 200, 2_000);
const { fetcher, lookups, requests } = fakes(
{ 'news.example.com': [PUBLIC, PUBLIC_V6] },
[reply(200, { body: [png.subarray(0, 100), png.subarray(100)] })],
);
const result = await fetcher.fetch(
'https://news.example.com/a.png?u=1',
live(),
);
expect(result).toEqual({ ok: true, type: 'image/png', data: png });
expect(lookups).toEqual(['news.example.com']);
expect(requests).toHaveLength(1);
expect(requests[0].url.href).toBe('https://news.example.com/a.png?u=1');
expect(requests[0].addresses).toEqual([PUBLIC, PUBLIC_V6]);
expect(requests[0].headers).toEqual({
'user-agent': MAIL_IMAGE_USER_AGENT,
accept: expect.stringContaining('image/png') as unknown,
'accept-encoding': 'identity',
});
// 쿠키 · Referer · 인증 머리글은 없다
for (const name of Object.keys(requests[0].headers)) {
expect(name).not.toMatch(/^(?:cookie|referer|authorization)$/i);
}
});
it('바깥이 보낸 형식과 상관없이 그림이 아니면(HTML · SVG) 주지 않는다', async () => {
const { fetcher } = fakes({ 'news.example.com': [PUBLIC] }, [
reply(200, {
body: [Buffer.from('<svg><script>alert(1)</script></svg>')],
}),
]);
await expect(
fetcher.fetch('https://news.example.com/a.png', live()),
).resolves.toEqual({
ok: false,
reason: 'not-an-image',
host: 'news.example.com',
});
});
it.each([
['ftp', 'ftp://news.example.com/a.png'],
['기본 포트가 아님', 'https://news.example.com:8080/a.png'],
['사용자 정보', 'https://u:p@news.example.com/a.png'],
['깨진 주소', 'not a url'],
])(
'중계할 수 없는 주소(%s)는 이름을 풀지도 연결하지도 않는다',
async (_label, url) => {
const { fetcher, lookups, requests } = fakes({}, []);
const result = await fetcher.fetch(url, live());
expect(result).toMatchObject({ ok: false, reason: 'bad-url' });
expect(lookups).toEqual([]);
expect(requests).toEqual([]);
},
);
it.each([
'http://127.0.0.1/a.png',
'http://2130706433/a.png',
'http://10.0.0.5/a.png',
'http://172.16.71.3/api',
'http://169.254.169.254/latest/meta-data/',
'http://[::1]/a.png',
'http://[::ffff:127.0.0.1]/a.png',
'http://[fd00::1]/a.png',
])(
'IP 로 쓴 사설 · 루프백 · 링크 로컬 주소(%s)는 연결하지 않는다',
async (url) => {
const { fetcher, lookups, requests } = fakes({}, []);
const result = await fetcher.fetch(url, live());
expect(result).toMatchObject({ ok: false, reason: 'not-public-address' });
expect(lookups).toEqual([]);
expect(requests).toEqual([]);
},
);
it('IP 로 쓴 공인 주소는 이름을 풀지 않고 그 주소로 연결한다', async () => {
const { fetcher, lookups, requests } = fakes({}, [
reply(200, { body: [gifBytes(1, 1)] }),
]);
const result = await fetcher.fetch('http://93.184.216.34/p.gif', live());
expect(result).toMatchObject({ ok: true, type: 'image/gif' });
expect(lookups).toEqual([]);
expect(requests[0].addresses).toEqual([PUBLIC]);
});
it('점이 없는 이름(도커 서비스 이름 · localhost)은 풀어 보지도 않는다', async () => {
const { fetcher, lookups, requests } = fakes({ mail: [PUBLIC] }, []);
for (const url of ['http://mail/a.png', 'http://localhost/a.png']) {
expect(await fetcher.fetch(url, live())).toMatchObject({
ok: false,
reason: 'single-label-host',
});
}
expect(lookups).toEqual([]);
expect(requests).toEqual([]);
});
it('풀어 나온 주소 가운데 하나라도 공인이 아니면(DNS 로 사내 주소를 섞음) 연결하지 않는다', async () => {
const { fetcher, requests } = fakes(
{
'mixed.example.com': [PUBLIC, { address: '10.0.0.7', family: 4 }],
'inside.example.com': [{ address: '127.0.0.1', family: 4 }],
'v6.example.com': [{ address: 'fe80::1', family: 6 }],
},
[],
);
for (const host of ['mixed', 'inside', 'v6']) {
expect(
await fetcher.fetch(`https://${host}.example.com/a.png`, live()),
).toEqual({
ok: false,
reason: 'not-public-address',
host: `${host}.example.com`,
});
}
expect(requests).toEqual([]);
});
it('이름을 풀지 못하거나 주소가 없으면 dns-failed', async () => {
const { fetcher } = fakes(
{ 'empty.example.com': [], 'broken.example.com': new Error('ESERVFAIL') },
[],
);
for (const host of ['empty', 'broken', 'nowhere']) {
expect(
await fetcher.fetch(`https://${host}.example.com/a.png`, live()),
).toMatchObject({ ok: false, reason: 'dns-failed' });
}
});
it('다시 가기는 매번 처음과 같은 검사 — 상대 주소는 이어 풀고, 사설 주소 · 다른 스킴으로 가면 멈춘다', async () => {
const png = pngBytes(10, 10);
const ok = fakes({ 'a.example.com': [PUBLIC], 'b.example.com': [PUBLIC] }, [
reply(302, { location: '/moved.png' }),
reply(301, { location: 'https://b.example.com/final.png' }),
reply(200, { body: [png] }),
]);
await expect(
ok.fetcher.fetch('http://a.example.com/a.png', live()),
).resolves.toMatchObject({ ok: true, type: 'image/png' });
expect(ok.requests.map((request) => request.url.href)).toEqual([
'http://a.example.com/a.png',
'http://a.example.com/moved.png',
'https://b.example.com/final.png',
]);
const toPrivate = fakes(
{
'a.example.com': [PUBLIC],
'evil.example.com': [{ address: '172.16.71.5', family: 4 }],
},
[reply(302, { location: 'http://evil.example.com/' })],
);
await expect(
toPrivate.fetcher.fetch('http://a.example.com/a.png', live()),
).resolves.toEqual({
ok: false,
reason: 'not-public-address',
host: 'evil.example.com',
});
expect(toPrivate.requests).toHaveLength(1);
for (const location of [
'http://127.0.0.1:8080/admin',
'file:///etc/passwd',
'gopher://a.example.com/',
'',
]) {
const bad = fakes({ 'a.example.com': [PUBLIC] }, [
reply(307, { location }),
]);
expect(
await bad.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'bad-redirect' });
expect(bad.requests).toHaveLength(1);
}
});
it('다시 가기는 3번까지 — 네 번째는 따라가지 않는다', async () => {
const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [
reply(302, { location: '/1' }),
reply(302, { location: '/2' }),
reply(302, { location: '/3' }),
reply(302, { location: '/4' }),
]);
expect(await fetcher.fetch('http://a.example.com/0', live())).toMatchObject(
{ ok: false, reason: 'too-many-redirects' },
);
expect(requests).toHaveLength(4);
});
it('200 이 아닌 응답(404 · 304 · 500)은 주지 않고 본문을 닫는다', async () => {
for (const status of [404, 304, 500, 206]) {
const response = reply(status, { body: [pngBytes(1, 1)] });
const { fetcher } = fakes({ 'a.example.com': [PUBLIC] }, [response]);
expect(
await fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'bad-status' });
expect(response.closed).toBe(true);
}
});
it(`알려 준 길이가 ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB 를 넘으면 읽지 않고, 읽는 중에 넘으면 끊는다`, async () => {
const declared = reply(200, {
body: [pngBytes(1, 1)],
contentLength: MAIL_IMAGE_MAX_BYTES + 1,
});
const first = fakes({ 'a.example.com': [PUBLIC] }, [declared]);
expect(
await first.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'too-large' });
expect(declared.closed).toBe(true);
const chunk = Buffer.alloc(1024 * 1024);
pngBytes(1, 1).copy(chunk);
const streamed = reply(200, {
body: Array.from({ length: 6 }, () => chunk),
contentLength: null,
});
const second = fakes({ 'a.example.com': [PUBLIC] }, [streamed]);
expect(
await second.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'too-large' });
expect(streamed.closed).toBe(true);
});
it('길이를 알려 주면 그 길이로 한 번 잡은 버퍼에 조각을 바로 쓴다 — 알려 주지 않으면(chunked) 모아 한 번 잇는다, 바이트는 같다', async () => {
const png = pngBytes(40, 30, 50_000);
const parts = [
png.subarray(0, 7),
png.subarray(7, 30_000),
png.subarray(30_000),
];
const concat = jest.spyOn(Buffer, 'concat');
try {
const declared = fakes({ 'a.example.com': [PUBLIC] }, [
reply(200, { body: parts, contentLength: png.length }),
]);
expect(
await declared.fetcher.fetch('http://a.example.com/a.png', live()),
).toEqual({ ok: true, type: 'image/png', data: png });
// 알려 준 길이는 잇기 없이
expect(concat).not.toHaveBeenCalled();
const chunked = fakes({ 'a.example.com': [PUBLIC] }, [
reply(200, { body: parts, contentLength: null }),
]);
expect(
await chunked.fetcher.fetch('http://a.example.com/a.png', live()),
).toEqual({ ok: true, type: 'image/png', data: png });
// 모르는 길이는 끝에 한 번만 잇는다
expect(concat).toHaveBeenCalledTimes(1);
} finally {
concat.mockRestore();
}
});
it('알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length) — 닫는다, 덜 오고 끝나면 받은 만큼으로 판정한다', async () => {
const png = pngBytes(10, 10);
const longer = reply(200, {
body: [png, Buffer.alloc(10)],
contentLength: png.length,
});
const over = fakes({ 'a.example.com': [PUBLIC] }, [longer]);
expect(
await over.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'bad-length' });
expect(longer.closed).toBe(true);
const shorter = fakes({ 'a.example.com': [PUBLIC] }, [
reply(200, { body: [png], contentLength: png.length + 100 }),
]);
expect(
await shorter.fetcher.fetch('http://a.example.com/a.png', live()),
).toEqual({ ok: true, type: 'image/png', data: png });
});
it('연결하지 못함 · 본문을 읽다 끊김은 connect-failed', async () => {
const refused = fakes({ 'a.example.com': [PUBLIC] }, [
new Error('ECONNREFUSED'),
]);
expect(
await refused.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'connect-failed' });
const broken = reply(200, {
body: [pngBytes(1, 1), Buffer.alloc(10)],
failAfter: 1,
});
const cut = fakes({ 'a.example.com': [PUBLIC] }, [broken]);
expect(
await cut.fetcher.fetch('http://a.example.com/a.png', live()),
).toMatchObject({ ok: false, reason: 'connect-failed' });
expect(broken.closed).toBe(true);
});
it('클라이언트가 끊으면 받던 것을 멈추고 canceled — 전송에 같은 신호를 넘긴다', async () => {
const controller = new AbortController();
const { fetcher, requests } = fakes({ 'a.example.com': [PUBLIC] }, [
(request) =>
new Promise((_resolve, reject) => {
request.signal.addEventListener('abort', () =>
reject(new Error('aborted')),
);
controller.abort();
}),
]);
await expect(
fetcher.fetch('http://a.example.com/a.png', controller.signal),
).resolves.toMatchObject({ ok: false, reason: 'canceled' });
expect(requests[0].signal.aborted).toBe(true);
});
});
@@ -0,0 +1,304 @@
import { Inject, Injectable } from '@nestjs/common';
import { isIP } from 'net';
import { isPublicIpAddress } from '@/shared/utils/public-address';
import {
MAIL_IMAGE_FETCH_TIMEOUT_MS,
MAIL_IMAGE_MAX_BYTES,
MAIL_IMAGE_MAX_REDIRECTS,
MAIL_IMAGE_USER_AGENT,
} from './mail-image.constants';
import {
detectProxiedImageType,
type ProxiedImageType,
} from './mail-image-format';
import {
checkProxiedImageUrl,
hostOf,
parseProxiedImageUrl,
} from './mail-image-url';
/**
* 바깥 이미지 받기 (계약 2절 "외부 이미지 보기" — 9단계) — 이 서버가 직원 대신 바깥 서버에 묻는 길이라 SSRF 를 막는 곳이다.
*
* - 주소: http · https · 기본 포트 · 사용자 정보 없음 (mail-image-url.ts)
* - 이름을 풀어 나온 **모든** 주소가 공인 주소일 때만, 그 주소로만 연결한다 — 연결할 때 이름을 다시 풀지 않는다(DNS 바꿔치기 막기).
* IP 로 쓴 주소는 그 주소를 그대로 본다. 점이 없는 이름(`mail` · `db` 같은 도커 서비스 이름)은 풀어 보지도 않는다
* - 다시 가기(redirect 301 · 302 · 303 · 307 · 308)는 3번까지 — 매번 위 검사를 처음부터 다시
* - 전체 10초(이름 풀기 · 연결 · 다시 가기 · 본문 모두), 받는 크기 5MB — 넘으면 끊는다
* - 형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP) — 바깥의 Content-Type 은 믿지 않는다
* - 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다 (연결 · 머리글은 전송(transport)이 만든다)
*
* 이름 풀기(MAIL_IMAGE_RESOLVER)와 전송(MAIL_IMAGE_TRANSPORT)은 주입받는다 — 단위 테스트는 가짜로, 앱은 node-image-transport.ts 의 구현으로
*/
/** 이름을 풀어 나온 주소 하나 */
export interface ResolvedAddress {
readonly address: string;
readonly family: 4 | 6;
}
/** 이름 풀기 — 나온 주소를 모두 준다(A · AAAA). 풀지 못하면 던진다. signal 이 끊기면 멈춘다 */
export interface MailImageResolver {
resolve(hostname: string, signal: AbortSignal): Promise<ResolvedAddress[]>;
}
export const MAIL_IMAGE_RESOLVER = Symbol('MAIL_IMAGE_RESOLVER');
/** 한 번 묻는 것 — 확인한 주소로만 연결한다 */
export interface MailImageRequest {
readonly url: URL;
/** 이 주소들로만 연결한다 (이름을 다시 풀지 않는다) — 모두 공인 주소로 확인한 것 */
readonly addresses: readonly ResolvedAddress[];
readonly headers: Readonly<Record<string, string>>;
readonly signal: AbortSignal;
}
/** 받은 응답 — 본문은 다 읽거나 close() 로 닫는다 */
export interface MailImageResponse {
readonly status: number;
/** Location 머리글 (없으면 null) */
readonly location: string | null;
/** Content-Length (없거나 읽을 수 없으면 null) */
readonly contentLength: number | null;
readonly body: AsyncIterable<Uint8Array>;
close(): void;
}
/** 전송 — GET 한 번. 연결하지 못하면 던진다 */
export interface MailImageTransport {
get(request: MailImageRequest): Promise<MailImageResponse>;
}
export const MAIL_IMAGE_TRANSPORT = Symbol('MAIL_IMAGE_TRANSPORT');
/** 받지 못한 까닭 — 로그(debug)에만 쓴다. 응답은 모두 404 */
export type MailImageFailureReason =
| 'bad-url'
| 'single-label-host'
| 'not-public-address'
| 'dns-failed'
| 'connect-failed'
| 'bad-status'
| 'bad-redirect'
| 'too-many-redirects'
| 'too-large'
| 'bad-length'
| 'not-an-image'
| 'timeout'
| 'canceled';
export type MailImageFetchResult =
| {
readonly ok: true;
readonly type: ProxiedImageType;
readonly data: Buffer;
}
| {
readonly ok: false;
readonly reason: MailImageFailureReason;
/** 그때 묻던 바깥 호스트 이름 (주소 전체는 남기지 않는다) — 모르면 null */
readonly host: string | null;
};
/** 다시 가기 응답 */
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([
301, 302, 303, 307, 308,
]);
const HTTP_OK = 200;
/** 바깥으로 보내는 머리글 — 이것뿐이다 (쿠키 · Referer · 브라우저의 머리글은 넘기지 않는다) */
const REQUEST_HEADERS: Readonly<Record<string, string>> = Object.freeze({
'user-agent': MAIL_IMAGE_USER_AGENT,
accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1',
// 압축하지 않은 바이트로 받는다 — 형식은 받은 바이트의 앞부분으로 정한다
'accept-encoding': 'identity',
});
/** 실패를 결과로 — 던지지 않는다 */
class FetchFailure extends Error {
constructor(
readonly reason: MailImageFailureReason,
readonly host: string | null,
) {
super(reason);
}
}
@Injectable()
export class MailImageFetcher {
constructor(
@Inject(MAIL_IMAGE_RESOLVER) private readonly resolver: MailImageResolver,
@Inject(MAIL_IMAGE_TRANSPORT)
private readonly transport: MailImageTransport,
) {}
/**
* 바깥 이미지를 받는다 — 받으면 형식 · 바이트, 못 받으면 까닭 (던지지 않는다).
* @param signal 클라이언트가 끊으면 멈춘다 (전체 10초 제한은 여기서 더한다)
*/
async fetch(
source: string,
signal: AbortSignal,
): Promise<MailImageFetchResult> {
const deadline = AbortSignal.timeout(MAIL_IMAGE_FETCH_TIMEOUT_MS);
const combined = AbortSignal.any([signal, deadline]);
let host: string | null = null;
try {
let url = parseProxiedImageUrl(source);
if (url === null) throw new FetchFailure('bad-url', null);
for (let redirects = 0; ; redirects += 1) {
host = hostOf(url);
const addresses = await this.publicAddresses(url, combined);
const response = await this.request(url, addresses, combined);
if (REDIRECT_STATUSES.has(response.status)) {
response.close();
if (redirects >= MAIL_IMAGE_MAX_REDIRECTS) {
throw new FetchFailure('too-many-redirects', host);
}
url = this.redirectTarget(url, response.location);
continue;
}
if (response.status !== HTTP_OK) {
response.close();
throw new FetchFailure('bad-status', host);
}
const data = await readCapped(response, host);
const type = detectProxiedImageType(data);
if (type === null) throw new FetchFailure('not-an-image', host);
return { ok: true, type, data };
}
} catch (error) {
if (signal.aborted) return { ok: false, reason: 'canceled', host };
if (deadline.aborted) return { ok: false, reason: 'timeout', host };
if (error instanceof FetchFailure) {
return { ok: false, reason: error.reason, host: error.host };
}
return { ok: false, reason: 'connect-failed', host };
}
}
/** 연결해도 되는 주소 — IP 로 쓴 주소는 그대로, 이름은 풀어서. 하나라도 공인 주소가 아니면 실패 */
private async publicAddresses(
url: URL,
signal: AbortSignal,
): Promise<ResolvedAddress[]> {
const host = hostOf(url);
const family = isIP(host);
if (family === 4 || family === 6) {
if (!isPublicIpAddress(host)) {
throw new FetchFailure('not-public-address', host);
}
return [{ address: host, family }];
}
// 점이 없는 이름은 사내 · 도커 망의 이름이다(mail · db · localhost) — 풀어 보지 않는다
if (!host.includes('.')) throw new FetchFailure('single-label-host', host);
let addresses: ResolvedAddress[];
try {
addresses = await this.resolver.resolve(host, signal);
} catch {
throw new FetchFailure('dns-failed', host);
}
if (addresses.length === 0) throw new FetchFailure('dns-failed', host);
if (!addresses.every(({ address }) => isPublicIpAddress(address))) {
throw new FetchFailure('not-public-address', host);
}
return addresses;
}
private async request(
url: URL,
addresses: readonly ResolvedAddress[],
signal: AbortSignal,
): Promise<MailImageResponse> {
try {
return await this.transport.get({
url,
addresses,
headers: REQUEST_HEADERS,
signal,
});
} catch {
throw new FetchFailure('connect-failed', hostOf(url));
}
}
/** 다시 갈 주소 — 지금 주소에 이어 푼 뒤 처음과 같은 검사를 한다 */
private redirectTarget(current: URL, location: string | null): URL {
if (location === null || location.trim() === '') {
throw new FetchFailure('bad-redirect', hostOf(current));
}
let next: URL;
try {
next = new URL(location.trim(), current);
} catch {
throw new FetchFailure('bad-redirect', hostOf(current));
}
const checked = checkProxiedImageUrl(next);
if (checked === null) {
throw new FetchFailure('bad-redirect', hostOf(current));
}
return checked;
}
}
/**
* 본문을 읽는다 — 5MB 를 넘으면(알려 준 길이든 읽는 중이든) 닫고 실패. 받은 조각을 두 번 베끼지 않는다(동시 16장 × 5MB — 2026-10-29 리뷰):
* - 길이를 알려 주면(Content-Length) 그만큼 한 번 잡아 조각을 바로 그 자리에 쓴다 — 알려 준 길이를 넘는 조각이 오면 깨진 응답(bad-length)
* - 모르면(chunked) 받은 조각을 그대로 모아 끝에 한 번만 잇는다
*/
async function readCapped(
response: MailImageResponse,
host: string,
): Promise<Buffer> {
const declared = response.contentLength;
if (declared !== null && declared > MAIL_IMAGE_MAX_BYTES) {
response.close();
throw new FetchFailure('too-large', host);
}
try {
return declared !== null
? await readDeclared(response, declared, host)
: await readChunked(response, host);
} catch (error) {
response.close();
throw error;
}
}
/** 알려 준 길이만큼 한 번 잡은 버퍼에 바로 쓴다 — 덜 오면(끊김 없이 끝남) 받은 만큼만 */
async function readDeclared(
response: MailImageResponse,
declared: number,
host: string,
): Promise<Buffer> {
const data = Buffer.allocUnsafe(declared);
let offset = 0;
for await (const chunk of response.body) {
if (offset + chunk.length > declared) {
throw new FetchFailure('bad-length', host);
}
data.set(chunk, offset);
offset += chunk.length;
}
return offset === declared ? data : data.subarray(0, offset);
}
/** 길이를 모르는 본문 — 조각을 베끼지 않고 모아 끝에 한 번 잇는다 */
async function readChunked(
response: MailImageResponse,
host: string,
): Promise<Buffer> {
const chunks: Uint8Array[] = [];
let total = 0;
for await (const chunk of response.body) {
total += chunk.length;
if (total > MAIL_IMAGE_MAX_BYTES) {
throw new FetchFailure('too-large', host);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
@@ -0,0 +1,49 @@
import {
gifBytes,
jpegBytes,
pngBytes,
webpBytes,
} from '@/../test/image-fixtures';
import { detectProxiedImageType } from './mail-image-format';
describe('detectProxiedImageType — 받은 바이트로 정하는 형식', () => {
it('PNG · JPEG · GIF · WebP(손실 · 무손실 · 확장)를 알아본다', () => {
expect(detectProxiedImageType(pngBytes(600, 200))).toBe('image/png');
expect(detectProxiedImageType(jpegBytes(640, 480))).toBe('image/jpeg');
expect(detectProxiedImageType(jpegBytes(640, 480, { sof: 0xc2 }))).toBe(
'image/jpeg',
);
expect(detectProxiedImageType(gifBytes(1, 1))).toBe('image/gif');
expect(detectProxiedImageType(gifBytes(1, 1, 'GIF87a'))).toBe('image/gif');
for (const chunk of ['VP8 ', 'VP8L', 'VP8X']) {
expect(detectProxiedImageType(webpBytes(chunk))).toBe('image/webp');
}
});
it.each([
[
'SVG',
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
),
],
['HTML', Buffer.from('<!doctype html><html><body>hi</body></html>')],
['빈 바이트', Buffer.alloc(0)],
[
'PNG 서명만',
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
],
[
'다른 RIFF(WAV)',
Buffer.concat([
Buffer.from('RIFF\0\0\0\0WAVEfmt ', 'latin1'),
Buffer.alloc(16),
]),
],
['모르는 WebP 덩어리', webpBytes('ABCD')],
['BMP', Buffer.from('BM\0\0\0\0\0\0\0\0\0\0\0\0', 'latin1')],
['gzip 으로 압축한 것', Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00])],
])('%s 는 그림으로 보지 않는다', (_label, data) => {
expect(detectProxiedImageType(data)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import { detectSignatureImage } from '@/modules/mail-signature/signature-image-format';
/**
* 바깥에서 받은 이미지의 형식 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 바이트의 앞부분으로 정한다.
* 바깥이 보낸 Content-Type 은 믿지 않는다 — PNG · JPEG · GIF · WebP 만 받고 SVG(스크립트를 담을 수 있다) · 그 밖은 받지 않는다.
* PNG · JPEG · GIF 는 서명 그림과 같은 판별(머리까지 맞아야 한다 — mail-signature/signature-image-format.ts)을 쓰고, WebP 는 RIFF 머리를 본다
*/
export type ProxiedImageType =
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/webp';
const RIFF = Buffer.from('RIFF', 'ascii');
const WEBP = Buffer.from('WEBP', 'ascii');
/** WebP 의 첫 덩어리 — 손실 · 무손실 · 확장 */
const WEBP_CHUNKS: ReadonlySet<string> = new Set(['VP8 ', 'VP8L', 'VP8X']);
/** 'RIFF' 크기(4) 'WEBP' 덩어리 이름(4) */
const WEBP_HEADER_BYTES = 16;
function isWebp(data: Buffer): boolean {
return (
data.length >= WEBP_HEADER_BYTES &&
data.subarray(0, 4).equals(RIFF) &&
data.subarray(8, 12).equals(WEBP) &&
WEBP_CHUNKS.has(data.subarray(12, 16).toString('latin1'))
);
}
/** 받은 바이트의 형식 — 그림이 아니거나 머리가 망가졌으면 null */
export function detectProxiedImageType(data: Buffer): ProxiedImageType | null {
const known = detectSignatureImage(data);
if (known) return known.type;
return isWebp(data) ? 'image/webp' : null;
}
@@ -0,0 +1,456 @@
import { Logger } from '@nestjs/common';
import { pngBytes } from '@/../test/image-fixtures';
import {
MAIL_IMAGE_CONCURRENCY,
MAIL_IMAGE_CONCURRENCY_PER_USER,
MAIL_IMAGE_MAX_WAITING_PER_USER,
MAIL_IMAGE_QUEUE_WAIT_MS,
} from './mail-image.constants';
import type {
MailImageFetcher,
MailImageFetchResult,
} from './mail-image-fetcher';
import { MailImageProxyService } from './mail-image-proxy.service';
import { MailImageTokens } from './mail-image-token';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const SOURCE = 'https://news.example.com/open.gif?u=8812';
const PNG = pngBytes(10, 10);
/** 테스트가 열 때까지 끝나지 않는 받기 */
interface PendingFetch {
readonly source: string;
readonly signal: AbortSignal;
finish(result: MailImageFetchResult): void;
}
function setup() {
const tokens = new MailImageTokens();
const pending: PendingFetch[] = [];
const fetcher = {
fetch: jest.fn(
(source: string, signal: AbortSignal) =>
new Promise<MailImageFetchResult>((resolve) => {
pending.push({ source, signal, finish: resolve });
}),
),
};
const service = new MailImageProxyService(
tokens,
fetcher as unknown as MailImageFetcher,
);
return { tokens, fetcher, pending, service };
}
const OK: MailImageFetchResult = { ok: true, type: 'image/png', data: PNG };
/** 기다리던 일(마이크로태스크)이 돌게 한다 */
const flush = async () => {
for (let i = 0; i < 5; i += 1) await Promise.resolve();
};
/** 직원 n 명의 주소 */
const employee = (n: number) => `user${n}@arasystem.test`;
/**
* 남은 받기를 모두 끝내고 받은 차례를 모두 돌려준다 — 돌려주면 기다리던 것이 들어와 새 받기가 생기므로 다 끝날 때까지 되풀이한다
* @returns 끝난 수 (모두 끝났는지는 부른 쪽이 본다)
*/
async function drain(
pending: PendingFetch[],
opens: Promise<{ release(): void } | null>[],
): Promise<number> {
const settled = new Set<number>();
opens.forEach((open, index) => {
void open.then((image) => {
image?.release();
settled.add(index);
});
});
for (let round = 0; settled.size < opens.length && round < 100; round += 1) {
for (const fetch of pending) fetch.finish(OK);
await flush();
}
return settled.size;
}
describe('MailImageProxyService — 외부 이미지 중계', () => {
let debugSpy: jest.SpyInstance;
beforeEach(() => {
debugSpy = jest
.spyOn(Logger.prototype, 'debug')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('표를 풀어 원래 주소로 받는다 — 받으면 형식 · 바이트와 차례(release 할 때까지 쥔다 · 두 번 불러도 한 번)', async () => {
const { tokens, pending, service } = setup();
const token = tokens.issue(ARA, SOURCE);
const opened = service.open(ARA, token, new AbortController().signal);
await flush();
expect(pending.map(({ source }) => source)).toEqual([SOURCE]);
pending[0].finish(OK);
const image = await opened;
expect(image).toEqual({
type: 'image/png',
data: PNG,
release: expect.any(Function) as unknown,
});
// 받은 뒤에도 응답을 다 보낼 때까지(release) 자리를 쥔다
expect(service.load(ARA)).toMatchObject({ active: 1, totalActive: 1 });
image?.release();
image?.release();
expect(service.load(ARA)).toEqual({
active: 0,
waiting: 0,
totalActive: 0,
totalWaiting: 0,
});
});
it('다른 직원의 표 · 고친 표는 바깥에 묻지 않고 null', async () => {
const { tokens, fetcher, service } = setup();
const token = tokens.issue(ARA, SOURCE);
await expect(
service.open(DOYUN, token, new AbortController().signal),
).resolves.toBeNull();
await expect(
service.open(ARA, `${token}x`, new AbortController().signal),
).resolves.toBeNull();
expect(fetcher.fetch).not.toHaveBeenCalled();
});
it('받지 못하면 null 이고 차례를 바로 돌려준다 — 로그(debug)에는 까닭과 바깥 호스트 이름만, 원래 주소는 남기지 않는다', async () => {
const { tokens, pending, service } = setup();
const opened = service.open(
ARA,
tokens.issue(ARA, SOURCE),
new AbortController().signal,
);
await flush();
expect(service.load(ARA).active).toBe(1);
pending[0].finish({
ok: false,
reason: 'not-public-address',
host: 'news.example.com',
});
await expect(opened).resolves.toBeNull();
expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
const logged = debugSpy.mock.calls.map(([message]) => String(message));
expect(logged.some((line) => line.includes('not-public-address'))).toBe(
true,
);
expect(logged.some((line) => line.includes('news.example.com'))).toBe(true);
for (const line of logged) {
expect(line).not.toContain('open.gif');
expect(line).not.toContain('u=8812');
expect(line).not.toContain(ARA);
}
});
it('받는 중에 클라이언트가 끊으면(받기가 canceled 로 끝남) 차례를 돌려준다', async () => {
const { tokens, pending, service } = setup();
const leaving = new AbortController();
const opened = service.open(ARA, tokens.issue(ARA, SOURCE), leaving.signal);
await flush();
expect(pending[0].signal).toBe(leaving.signal);
leaving.abort();
pending[0].finish({ ok: false, reason: 'canceled', host: null });
await expect(opened).resolves.toBeNull();
expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
});
it('받기가 던져도 차례를 돌려준다', async () => {
const { tokens, fetcher, service } = setup();
fetcher.fetch.mockRejectedValueOnce(new Error('뜻밖의 오류'));
await expect(
service.open(
ARA,
tokens.issue(ARA, SOURCE),
new AbortController().signal,
),
).rejects.toThrow('뜻밖의 오류');
expect(service.load(ARA)).toMatchObject({ active: 0, totalActive: 0 });
});
it(`한 직원은 동시에 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지 받고 나머지는 기다린다 — 하나를 돌려주면 다음이 들어간다, 다른 직원은 따로 센다`, async () => {
const { tokens, pending, service } = setup();
const token = tokens.issue(ARA, SOURCE);
const opens = Array.from(
{ length: MAIL_IMAGE_CONCURRENCY_PER_USER + 2 },
() => service.open(ARA, token, new AbortController().signal),
);
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER);
expect(service.load(ARA)).toMatchObject({
active: MAIL_IMAGE_CONCURRENCY_PER_USER,
waiting: 2,
});
const other = service.open(
DOYUN,
tokens.issue(DOYUN, SOURCE),
new AbortController().signal,
);
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1);
// 받기만 끝나서는 자리가 나지 않는다 — 응답을 다 보내고 돌려줘야(release) 다음이 들어간다
pending[0].finish(OK);
const first = await opens[0];
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 1);
first?.release();
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER + 2);
expect(service.load(ARA)).toMatchObject({
active: MAIL_IMAGE_CONCURRENCY_PER_USER,
waiting: 1,
});
// 나머지를 모두 받고 돌려준다
const rest = [...opens.slice(1), other];
await expect(drain(pending, rest)).resolves.toBe(rest.length);
expect(service.load(ARA)).toEqual({
active: 0,
waiting: 0,
totalActive: 0,
totalWaiting: 0,
});
expect(service.load(DOYUN)).toMatchObject({ active: 0, waiting: 0 });
});
it(`서버 전체는 동시에 ${MAIL_IMAGE_CONCURRENCY}개까지 — ${MAIL_IMAGE_CONCURRENCY + 1}번째는 자기 직원의 자리가 남아도 기다리고, 하나를 돌려주면 들어간다`, async () => {
const { tokens, pending, service } = setup();
// 직원 하나가 하나씩 — 직원마다의 상한에는 걸리지 않는다
const opens = Array.from({ length: MAIL_IMAGE_CONCURRENCY }, (_, n) =>
service.open(
employee(n),
tokens.issue(employee(n), SOURCE),
new AbortController().signal,
),
);
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY);
const late = employee(MAIL_IMAGE_CONCURRENCY);
const waiting = service.open(
late,
tokens.issue(late, SOURCE),
new AbortController().signal,
);
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY);
expect(service.load(late)).toEqual({
active: 0,
waiting: 1,
totalActive: MAIL_IMAGE_CONCURRENCY,
totalWaiting: 1,
});
pending[0].finish(OK);
(await opens[0])?.release();
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY + 1);
expect(pending.at(-1)?.source).toBe(SOURCE);
expect(service.load(late)).toMatchObject({ active: 1, waiting: 0 });
for (const fetch of pending.slice(1)) fetch.finish(OK);
for (const image of await Promise.all([...opens.slice(1), waiting])) {
image?.release();
}
expect(service.load(late)).toEqual({
active: 0,
waiting: 0,
totalActive: 0,
totalWaiting: 0,
});
});
it('자기 직원의 상한에 막힌 직원은 건너뛴다 — 먼저 기다린 직원이 막혀 있으면 뒤에 온 다른 직원이 먼저 들어가고, 같은 직원끼리는 온 차례다', async () => {
const { tokens, pending, service } = setup();
const araToken = tokens.issue(ARA, SOURCE);
// 아라가 자기 상한까지 + 하나 더 기다린다
const araOpens = Array.from(
{ length: MAIL_IMAGE_CONCURRENCY_PER_USER + 1 },
() => service.open(ARA, araToken, new AbortController().signal),
);
// 전체 상한까지 다른 직원들로 채운다
const fillers = Array.from(
{ length: MAIL_IMAGE_CONCURRENCY - MAIL_IMAGE_CONCURRENCY_PER_USER },
(_, n) =>
service.open(
employee(n),
tokens.issue(employee(n), SOURCE),
new AbortController().signal,
),
);
await flush();
// 도윤은 아라의 기다림 뒤에 선다
const doyun = service.open(
DOYUN,
tokens.issue(DOYUN, SOURCE),
new AbortController().signal,
);
await flush();
expect(service.load(DOYUN)).toMatchObject({ waiting: 1, totalWaiting: 2 });
// 다른 직원(아라가 아닌) 하나가 돌려주면 — 앞의 아라는 자기 상한에 막혀 있으니 도윤이 들어간다
const fillerIndex = MAIL_IMAGE_CONCURRENCY_PER_USER;
pending[fillerIndex].finish(OK);
(await fillers[0])?.release();
await flush();
expect(service.load(DOYUN)).toMatchObject({ active: 1, waiting: 0 });
expect(service.load(ARA)).toMatchObject({
active: MAIL_IMAGE_CONCURRENCY_PER_USER,
waiting: 1,
});
// 아라 하나가 돌려주면 기다리던 아라가 들어간다
pending[0].finish(OK);
(await araOpens[0])?.release();
await flush();
expect(service.load(ARA)).toMatchObject({
active: MAIL_IMAGE_CONCURRENCY_PER_USER,
waiting: 0,
});
for (const fetch of pending) fetch.finish(OK);
for (const image of await Promise.all([...araOpens, ...fillers, doyun])) {
image?.release();
}
expect(service.load(ARA).totalActive).toBe(0);
});
it('전체 자리가 나면 지금 쥔 자리가 가장 적은 직원부터(같으면 먼저 기다린 쪽) — 이미지가 많은 메일을 연 직원들이 자리를 돌려 가며 차지하지 않는다 (9단계 리뷰 2)', async () => {
// 리뷰의 장면 그대로 — 전체 16 · 직원 6
expect([MAIL_IMAGE_CONCURRENCY, MAIL_IMAGE_CONCURRENCY_PER_USER]).toEqual([
16, 6,
]);
const { tokens, pending, service } = setup();
const [a, b, c, d] = [employee(1), employee(2), employee(3), employee(4)];
const openAs = (user: string, count: number) =>
Array.from({ length: count }, () =>
service.open(
user,
tokens.issue(user, SOURCE),
new AbortController().signal,
),
);
// A · B · C 가 이미지가 많은 메일을 연다 — 전체 16자리를 6 · 6 · 4로 채우고 각자 5장씩 기다린다
const aOpens = openAs(a, 6 + 5);
await flush();
const bOpens = openAs(b, 6 + 5);
await flush();
const cOpens = openAs(c, 4 + 5);
await flush();
expect(service.load(a)).toMatchObject({
active: 6,
waiting: 5,
totalActive: 16,
});
expect(service.load(b)).toMatchObject({ active: 6, waiting: 5 });
expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 });
// 뒤에 온 D 는 한 장
const dOpens = openAs(d, 1);
await flush();
expect(service.load(d)).toMatchObject({ active: 0, waiting: 1 });
/** 받는 중인 요청 하나를 끝내고 차례를 돌려준다 — pending 은 들어간 차례(A 0~5 · B 6~11 · C 12~15) */
const finishAndRelease = async (
fetchIndex: number,
open: Promise<{ release(): void } | null>,
) => {
pending[fetchIndex].finish(OK);
(await open)?.release();
await flush();
};
// A 가 한 장을 돌려준다 — 자리를 비운 A 의 다음 요청(먼저 기다렸다)이 아니라 쥔 자리가 없는 D 가 들어간다
await finishAndRelease(0, aOpens[0]);
expect(service.load(d)).toMatchObject({ active: 1, waiting: 0 });
expect(service.load(a)).toMatchObject({ active: 5, waiting: 5 });
// A 가 또 하나 — 쥔 자리가 A 4 · C 4 로 같다: 먼저 기다린 A
await finishAndRelease(1, aOpens[1]);
expect(service.load(a)).toMatchObject({ active: 5, waiting: 4 });
expect(service.load(c)).toMatchObject({ active: 4, waiting: 5 });
// B 가 하나 — A 5 · B 5 · C 4 → C
await finishAndRelease(6, bOpens[0]);
expect(service.load(c)).toMatchObject({ active: 5, waiting: 4 });
expect(service.load(b)).toMatchObject({ active: 5, waiting: 5 });
const all = [...aOpens, ...bOpens, ...cOpens, ...dOpens];
expect(await drain(pending, all)).toBe(all.length);
expect(service.load(a)).toEqual({
active: 0,
waiting: 0,
totalActive: 0,
totalWaiting: 0,
});
});
it('기다리다 클라이언트가 끊으면 차례에서 빠지고 바깥에 묻지 않는다', async () => {
const { tokens, pending, service } = setup();
const token = tokens.issue(ARA, SOURCE);
const holders = Array.from(
{ length: MAIL_IMAGE_CONCURRENCY_PER_USER },
() => service.open(ARA, token, new AbortController().signal),
);
const leaving = new AbortController();
const waiting = service.open(ARA, token, leaving.signal);
await flush();
expect(service.load(ARA).waiting).toBe(1);
leaving.abort();
await expect(waiting).resolves.toBeNull();
expect(service.load(ARA)).toMatchObject({ waiting: 0, totalWaiting: 0 });
for (const fetch of pending) fetch.finish(OK);
for (const image of await Promise.all(holders)) image?.release();
await flush();
expect(pending).toHaveLength(MAIL_IMAGE_CONCURRENCY_PER_USER);
expect(service.load(ARA).totalActive).toBe(0);
});
it(`${MAIL_IMAGE_QUEUE_WAIT_MS / 1000}초를 넘게 기다리면 받지 않는다 · 기다리는 수가 ${MAIL_IMAGE_MAX_WAITING_PER_USER}을 넘으면 바로 null`, async () => {
jest.useFakeTimers();
const { tokens, pending, service } = setup();
const token = tokens.issue(ARA, SOURCE);
for (let i = 0; i < MAIL_IMAGE_CONCURRENCY_PER_USER; i += 1) {
void service.open(ARA, token, new AbortController().signal);
}
const waiters = Array.from(
{ length: MAIL_IMAGE_MAX_WAITING_PER_USER },
() => service.open(ARA, token, new AbortController().signal),
);
await flush();
await expect(
service.open(ARA, token, new AbortController().signal),
).resolves.toBeNull();
jest.advanceTimersByTime(MAIL_IMAGE_QUEUE_WAIT_MS);
await expect(Promise.all(waiters)).resolves.toEqual(
waiters.map(() => null),
);
expect(service.load(ARA)).toMatchObject({
active: MAIL_IMAGE_CONCURRENCY_PER_USER,
waiting: 0,
totalWaiting: 0,
});
for (const fetch of pending) fetch.finish(OK);
});
});
@@ -0,0 +1,237 @@
import { Injectable, Logger } from '@nestjs/common';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
MAIL_IMAGE_CONCURRENCY,
MAIL_IMAGE_CONCURRENCY_PER_USER,
MAIL_IMAGE_MAX_WAITING_PER_USER,
MAIL_IMAGE_QUEUE_WAIT_MS,
} from './mail-image.constants';
import { MailImageFetcher } from './mail-image-fetcher';
import type { ProxiedImageType } from './mail-image-format';
import { MailImageTokens } from './mail-image-token';
/**
* 중계해 줄 이미지 — 받은 차례를 쥐고 있다. 응답을 다 보냈거나 연결이 닫혔을 때 release() 를 부른다
* (그때까지의 버퍼도 동시 수에 든다 — 느린 브라우저에 보내는 동안의 메모리까지 센다). 여러 번 불러도 한 번만 돌려준다
*/
export interface ProxiedImage {
readonly type: ProxiedImageType;
readonly data: Buffer;
readonly release: () => void;
}
/** 차례를 기다리는 요청 하나 — 그 직원의 줄(온 차례)에 선다 */
interface Waiter {
readonly key: string;
/** 줄에 선 차례 — 쥔 자리가 같은 직원끼리는 먼저 온 쪽 */
readonly seq: number;
readonly admit: () => void;
}
/**
* 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — 표를 풀어 원래 주소를 얻고 바깥에서 받는다.
* - 동시 수: 프로세스 전체 MAIL_IMAGE_CONCURRENCY(16)개 · 한 직원 MAIL_IMAGE_CONCURRENCY_PER_USER(6)개 — 넘으면 **직원마다의 줄**(온 차례)에서 기다린다.
* 자리가 나면 기다리는 직원 가운데 **지금 쥔 자리가 가장 적은 직원**의 맨 앞을 들인다(같으면 먼저 온 쪽, 자기 6개에 막힌 직원은 건너뛴다) —
* 이미지가 많은 뉴스레터를 연 직원 몇이 전체 자리를 돌려 가며 차지해 다른 직원의 그림이 30초 기다림으로 비지 않게(2026-10-29 리뷰 2).
* 한 장이 5MB 까지라 전체 상한이 없으면 여러 직원이 함께 열 때 메모리가 수 GB 로 는다(2026-10-29 리뷰 — 운영 backend 한도 1g)
* - 차례는 받기부터 응답을 다 보내거나 연결이 닫힐 때까지 쥔다(ProxiedImage.release — 컨트롤러가 부른다). 받지 못하면 여기서 돌려준다
* - 실패는 까닭을 가리지 않고 null — 컨트롤러가 모두 404 RES_001 로 답한다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님)
* - 로그: 원래 주소는 남기지 않는다(추적 번호 · 개인 정보가 든다) — 실패 까닭과 바깥 호스트 이름만 debug
* - 기다림: 30초를 넘거나 한 직원의 기다리는 수가 200 을 넘으면 받지 않는다(null). 클라이언트가 끊으면 기다리던 자리에서 빠진다
*
* ⚠️ 동시 수는 프로세스 메모리에서 센다 — 인스턴스를 늘리면 인스턴스마다 16 · 6개다 (바깥으로 나가는 양이 그만큼 는다 — 필요하면 공유 셈)
*/
@Injectable()
export class MailImageProxyService {
private readonly logger = new Logger(MailImageProxyService.name);
/** 받는 중(차례를 쥔) 수 — 전체 · 직원마다 */
private active = 0;
private readonly activeByUser = new Map<string, number>();
/** 기다리는 줄 — 직원마다 온 차례 (빈 줄은 두지 않는다) */
private readonly lines = new Map<string, Waiter[]>();
private waitingTotal = 0;
private nextSeq = 0;
constructor(
private readonly tokens: MailImageTokens,
private readonly fetcher: MailImageFetcher,
) {}
/**
* 표의 이미지를 받는다 — 받지 못하면 null(차례는 이미 돌려줬다). 받았으면 차례를 쥔 채 돌려준다 — 부른 쪽이 release() 한다.
* @param signal 클라이언트가 끊으면 기다림 · 받기를 멈춘다
*/
async open(
user: string,
token: string,
signal: AbortSignal,
): Promise<ProxiedImage | null> {
const source = this.tokens.open(token, user);
if (source === null) {
this.debug(user, 'bad-token', null);
return null;
}
const release = await this.acquire(user, signal);
if (release === null) {
this.debug(user, signal.aborted ? 'canceled' : 'queue-full', null);
return null;
}
let handedOver = false;
try {
const result = await this.fetcher.fetch(source, signal);
if (!result.ok) {
this.debug(user, result.reason, result.host);
return null;
}
handedOver = true;
return { type: result.type, data: result.data, release };
} finally {
if (!handedOver) release();
}
}
/** 지금 받는 수 · 기다리는 수 — 그 직원 · 전체 (테스트 · 진단용) */
load(user: string): {
readonly active: number;
readonly waiting: number;
readonly totalActive: number;
readonly totalWaiting: number;
} {
const key = slotKey(user);
return {
active: this.activeByUser.get(key) ?? 0,
waiting: this.lines.get(key)?.length ?? 0,
totalActive: this.active,
totalWaiting: this.waitingTotal,
};
}
/** 차례를 얻는다 — 얻으면 돌려줄 함수(한 번만 돈다), 못 얻으면(기다림이 넘침 · 너무 오래 · 끊김) null */
private acquire(
user: string,
signal: AbortSignal,
): Promise<(() => void) | null> {
const key = slotKey(user);
if (signal.aborted) return Promise.resolve(null);
// 비어 있는 자리가 있는데도 줄에 남은 사람은 자기 직원의 상한에 막힌 사람뿐이다(자리가 날 때마다 들일 수 있는 만큼 들인다) — 앞지르지 않는다
if (this.canStart(key)) {
this.start(key);
return Promise.resolve(this.releaser(key));
}
const line = this.lines.get(key) ?? [];
if (line.length >= MAIL_IMAGE_MAX_WAITING_PER_USER) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const leave = () => {
clearTimeout(timer);
signal.removeEventListener('abort', giveUp);
const index = line.indexOf(waiter);
if (index < 0) return;
line.splice(index, 1);
this.waitingTotal -= 1;
if (line.length === 0 && this.lines.get(key) === line) {
this.lines.delete(key);
}
};
const giveUp = () => {
leave();
resolve(null);
};
const waiter: Waiter = {
key,
seq: this.nextSeq++,
admit: () => {
leave();
this.start(key);
resolve(this.releaser(key));
},
};
const timer = setTimeout(giveUp, MAIL_IMAGE_QUEUE_WAIT_MS);
timer.unref();
signal.addEventListener('abort', giveUp, { once: true });
line.push(waiter);
this.waitingTotal += 1;
this.lines.set(key, line);
});
}
/** 전체 · 그 직원의 자리가 남았나 */
private canStart(key: string): boolean {
return (
this.active < MAIL_IMAGE_CONCURRENCY &&
(this.activeByUser.get(key) ?? 0) < MAIL_IMAGE_CONCURRENCY_PER_USER
);
}
private start(key: string): void {
this.active += 1;
this.count(this.activeByUser, key, 1);
}
/** 차례를 돌려주는 함수 — 여러 번 불러도 한 번만 돌려준다 */
private releaser(key: string): () => void {
let released = false;
return () => {
if (released) return;
released = true;
this.active = Math.max(0, this.active - 1);
this.count(this.activeByUser, key, -1);
this.admitWaiting();
};
}
/** 자리가 남는 동안 다음 사람을 들인다 — 전체 자리가 차거나 들일 수 있는 사람이 없으면 멈춘다 */
private admitWaiting(): void {
while (this.active < MAIL_IMAGE_CONCURRENCY) {
const next = this.nextWaiter();
if (next === null) return;
// admit 이 줄에서 빼고 자리를 잡는다
next.admit();
}
}
/**
* 다음에 들일 요청 — 자기 상한(6)에 막히지 않은 직원 가운데 지금 쥔 자리가 가장 적은 직원의 맨 앞(같으면 먼저 온 쪽).
* 기다리는 직원 수에 비례한다 (직원마다 줄의 맨 앞만 본다)
*/
private nextWaiter(): Waiter | null {
let best: Waiter | null = null;
let bestHeld = Number.POSITIVE_INFINITY;
for (const [key, line] of this.lines) {
const held = this.activeByUser.get(key) ?? 0;
if (held >= MAIL_IMAGE_CONCURRENCY_PER_USER) continue;
const head = line[0];
if (
best === null ||
held < bestHeld ||
(held === bestHeld && head.seq < best.seq)
) {
best = head;
bestHeld = held;
}
}
return best;
}
/** 직원마다의 셈 — 0 이 되면 지운다 (줄지 않는 표를 두지 않는다) */
private count(map: Map<string, number>, key: string, delta: number): void {
const next = (map.get(key) ?? 0) + delta;
if (next > 0) map.set(key, next);
else map.delete(key);
}
private debug(user: string, reason: string, host: string | null): void {
this.logger.debug(
`${traceTag()} 바깥 이미지를 주지 않았습니다: user=${maskEmail(user)}, 까닭=${reason}${host ? `, host=${host}` : ''}`,
);
}
}
function slotKey(user: string): string {
return user.trim().toLowerCase();
}
@@ -0,0 +1,93 @@
import {
MAIL_IMAGE_TOKEN_TTL_MS,
MAX_MAIL_IMAGE_TOKEN_LENGTH,
MAX_PROXIED_IMAGE_URL_LENGTH,
} from './mail-image.constants';
import { MailImageTokens } from './mail-image-token';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const URL = 'https://news.example.com/open.gif?u=8812&c=newsletter-10';
const NOW = Date.parse('2026-10-29T06:00:00.000Z');
/** base64url 표의 한 바이트를 바꾼다 */
function tamper(token: string, index: number): string {
const raw = Buffer.from(token, 'base64url');
raw[index] ^= 0x01;
return raw.toString('base64url');
}
describe('MailImageTokens — 외부 이미지 중계 표', () => {
it('만든 표를 그 직원이 1시간 안에 풀면 원래 주소다 (주소의 대소문자는 가리지 않는다)', () => {
const tokens = new MailImageTokens();
const token = tokens.issue(ARA, URL, NOW);
expect(tokens.open(token, ARA, NOW)).toBe(URL);
expect(tokens.open(token, 'ARA.Kim@ArASystem.test', NOW + 1000)).toBe(URL);
expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS - 1)).toBe(
URL,
);
});
it('표에는 원래 주소가 보이지 않는다 — 암호화한 불투명 글(base64url)이고, 같은 주소도 표마다 다르다', () => {
const tokens = new MailImageTokens();
const token = tokens.issue(ARA, URL, NOW);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
const decoded = Buffer.from(token, 'base64url').toString('latin1');
for (const piece of ['news.example.com', 'open.gif', 'u=8812', 'ara.kim']) {
expect(token).not.toContain(piece);
expect(decoded).not.toContain(piece);
}
expect(tokens.issue(ARA, URL, NOW)).not.toBe(token);
});
it('지난 표 · 다른 직원 · 다른 서버(키)의 표는 풀리지 않는다', () => {
const tokens = new MailImageTokens();
const token = tokens.issue(ARA, URL, NOW);
expect(tokens.open(token, ARA, NOW + MAIL_IMAGE_TOKEN_TTL_MS)).toBeNull();
expect(tokens.open(token, DOYUN, NOW)).toBeNull();
expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull();
});
it('한 바이트라도 고친 표는 풀리지 않는다 (인증 암호 — 판 · iv · 암호문 · 태그 어디든)', () => {
const tokens = new MailImageTokens();
const token = tokens.issue(ARA, URL, NOW);
const length = Buffer.from(token, 'base64url').length;
for (const index of [0, 1, 12, 13, 20, length - 17, length - 1]) {
expect(tokens.open(tamper(token, index), ARA, NOW)).toBeNull();
}
});
it.each([
['빈 글', ''],
['base64url 이 아닌 글자', 'abc.def'],
['슬래시', 'abc/def'],
['너무 짧음', 'AQ'],
['너무 긺', 'A'.repeat(MAX_MAIL_IMAGE_TOKEN_LENGTH + 1)],
])('모양이 틀린 표(%s)는 풀어 보지 않는다', (_label, token) => {
expect(new MailImageTokens().open(token, ARA, NOW)).toBeNull();
});
it(`가장 긴 주소(${MAX_PROXIED_IMAGE_URL_LENGTH}자)의 표도 표 길이 상한 안이다 — 한글이 든 주소도 정규화하면 퍼센트 인코딩이라 같은 상한`, () => {
const tokens = new MailImageTokens();
const longUrl = `https://img.example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH - 24)}`;
expect(longUrl).toHaveLength(MAX_PROXIED_IMAGE_URL_LENGTH);
const token = tokens.issue(
'very.long.local.part.of.an.employee@arasystem.test',
longUrl,
NOW,
);
expect(token.length).toBeLessThanOrEqual(MAX_MAIL_IMAGE_TOKEN_LENGTH);
expect(
tokens.open(
token,
'very.long.local.part.of.an.employee@arasystem.test',
NOW,
),
).toBe(longUrl);
});
});
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
MAIL_IMAGE_TOKEN_TTL_MS,
MAX_MAIL_IMAGE_TOKEN_LENGTH,
} from './mail-image.constants';
/**
* 외부 이미지 중계 표 (계약 2절 "외부 이미지 보기" — 9단계) — `/api/mail-images/<표>` 의 표.
*
* 표는 직원 · 원래 이미지 주소 · 끝나는 시각(1시간)을 **서버 비밀로 암호화한** 불투명 글이다 — 인증 암호 AES-256-GCM.
* - 서명만 하면 표를 풀어 원래 주소(추적 번호 · 개인 정보가 든다)를 읽을 수 있어, 접근 로그 · 주소창 · 브라우저 기록에 그대로 남는다 — 그래서 암호화한다
* - 인증 암호라 고친 표는 풀리지 않는다(태그가 맞지 않음) — 다른 직원 · 다른 주소로 바꿔 쓸 수 없다
* - 키는 서버가 뜰 때 무작위로 만든다(JWT 키와 따로) — 다시 띄우면 옛 표는 풀리지 않고(404), 메일을 다시 열면 새 표를 받는다.
* ⚠️ 서버를 여러 대로 늘리면 키를 공유 비밀로 옮긴다 (다른 인스턴스가 만든 표를 풀지 못한다)
* - 모양: base64url( 판 1바이트 | iv 12바이트 | 암호문 | 태그 16바이트 ), 추가 인증 자료(AAD)에 용도 이름을 넣어 다른 용도의 암호문과 섞이지 않게 한다
*/
const ALGORITHM = 'aes-256-gcm';
const KEY_BYTES = 32;
const IV_BYTES = 12;
const TAG_BYTES = 16;
/** 표 모양의 판 — 모양을 바꾸면 올린다 (옛 판은 풀지 않는다) */
const TOKEN_VERSION = 1;
const AAD = Buffer.from('arasystem/mail-image-token/v1', 'utf8');
/**
* base64url 글자만(채움 `=` 없이) — 그 밖의 글자가 든 경로는 풀어 보지 않는다.
* 짝: frontend `src/shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`(정화가 남기는 중계 주소의 표 글자) — 한쪽을 고치면 둘 다
*/
const TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/;
/** 표 안의 것 — 짧은 키로 (표 길이를 줄인다) */
interface TokenPayload {
/** 직원 주소 (소문자) */
readonly u: string;
/** 원래 이미지 주소 (정규화한 http · https 주소) */
readonly s: string;
/** 끝나는 시각 (ms) */
readonly e: number;
}
function userKey(user: string): string {
return user.trim().toLowerCase();
}
function isPayload(value: unknown): value is TokenPayload {
if (typeof value !== 'object' || value === null) return false;
const { u, s, e } = value as Partial<Record<keyof TokenPayload, unknown>>;
return (
typeof u === 'string' &&
typeof s === 'string' &&
typeof e === 'number' &&
Number.isSafeInteger(e)
);
}
@Injectable()
export class MailImageTokens {
private readonly key = randomBytes(KEY_BYTES);
/** 그 직원이 그 주소의 이미지를 받을 표 — 지금부터 1시간 */
issue(user: string, url: string, nowMs: number = Date.now()): string {
const payload: TokenPayload = {
u: userKey(user),
s: url,
e: nowMs + MAIL_IMAGE_TOKEN_TTL_MS,
};
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, this.key, iv, {
authTagLength: TAG_BYTES,
});
cipher.setAAD(AAD);
const sealed = Buffer.concat([
cipher.update(JSON.stringify(payload), 'utf8'),
cipher.final(),
]);
return Buffer.concat([
Buffer.from([TOKEN_VERSION]),
iv,
sealed,
cipher.getAuthTag(),
]).toString('base64url');
}
/**
* 표를 푼다 — 그 직원의 것이고 끝나지 않았으면 원래 주소, 아니면 null.
* 까닭(모양이 틀림 · 다른 키 · 고침 · 지남 · 다른 직원)을 가리지 않는다 — 부르는 쪽은 모두 404 로 답한다
*/
open(token: string, user: string, nowMs: number = Date.now()): string | null {
if (token.length > MAX_MAIL_IMAGE_TOKEN_LENGTH) return null;
if (!TOKEN_PATTERN.test(token)) return null;
const raw = Buffer.from(token, 'base64url');
if (raw.length < 1 + IV_BYTES + TAG_BYTES + 1) return null;
if (raw[0] !== TOKEN_VERSION) return null;
const iv = raw.subarray(1, 1 + IV_BYTES);
const tag = raw.subarray(raw.length - TAG_BYTES);
const sealed = raw.subarray(1 + IV_BYTES, raw.length - TAG_BYTES);
let plain: string;
try {
const decipher = createDecipheriv(ALGORITHM, this.key, iv, {
authTagLength: TAG_BYTES,
});
decipher.setAAD(AAD);
decipher.setAuthTag(tag);
plain = Buffer.concat([
decipher.update(sealed),
decipher.final(),
]).toString('utf8');
} catch {
// 다른 키 · 고친 표 — 태그가 맞지 않는다
return null;
}
let payload: unknown;
try {
payload = JSON.parse(plain);
} catch {
return null;
}
if (!isPayload(payload)) return null;
if (payload.u !== userKey(user)) return null;
if (payload.e <= nowMs) return null;
return payload.s;
}
}
@@ -0,0 +1,80 @@
import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants';
import {
checkProxiedImageUrl,
hostOf,
parseProxiedImageUrl,
} from './mail-image-url';
describe('parseProxiedImageUrl — 중계할 수 있는 바깥 이미지 주소', () => {
it.each([
['https://news.example.com/a.png', 'https://news.example.com/a.png'],
['http://news.example.com/a.png?x=1', 'http://news.example.com/a.png?x=1'],
[' https://news.example.com/a.png ', 'https://news.example.com/a.png'],
['//cdn.example.com/a.gif', 'https://cdn.example.com/a.gif'],
['HTTPS://News.Example.COM/A.png', 'https://news.example.com/A.png'],
['http://news.example.com:80/a.png', 'http://news.example.com/a.png'],
['https://news.example.com:443/a.png', 'https://news.example.com/a.png'],
[
'https://뉴스.example.com/그림.png',
'https://xn--vg1b002a.example.com/%EA%B7%B8%EB%A6%BC.png',
],
[
'https://news.example.com/a.png#frag',
'https://news.example.com/a.png#frag',
],
])('%j → %s', (src, href) => {
expect(parseProxiedImageUrl(src)?.href).toBe(href);
});
it.each([
['빈 글', ''],
['상대 주소', '/images/a.png'],
['data:', 'data:image/png;base64,AAAA'],
['cid:', 'cid:logo@x'],
['javascript:', 'javascript:alert(1)'],
['ftp:', 'ftp://example.com/a.png'],
['file:', 'file:///etc/passwd'],
['기본 포트가 아님', 'https://example.com:8443/a.png'],
['http 에 443', 'http://example.com:443/a.png'],
['메일 엔진 관리 포트', 'http://mail:8080/api'],
['사용자 정보', 'https://user:pass@example.com/a.png'],
['사용자 이름만', 'https://user@example.com/a.png'],
['깨진 주소', 'https://'],
[
'너무 긺',
`https://example.com/${'a'.repeat(MAX_PROXIED_IMAGE_URL_LENGTH)}`,
],
])('%s(%j)은 중계하지 않는다', (_label, src) => {
expect(parseProxiedImageUrl(src)).toBeNull();
});
it('정규화해서 2,048자를 넘는 주소(퍼센트 인코딩으로 늘어남)도 중계하지 않는다', () => {
const src = `https://example.com/${'가'.repeat(400)}`;
expect(src.length).toBeLessThan(MAX_PROXIED_IMAGE_URL_LENGTH);
expect(parseProxiedImageUrl(src)).toBeNull();
});
it('숫자로 쓴 IP 주소는 정규화한다 — 사설 · 루프백인지는 받을 때 본다 (10진 · 16진 표기도 같은 주소)', () => {
expect(parseProxiedImageUrl('http://2130706433/a.png')?.hostname).toBe(
'127.0.0.1',
);
expect(parseProxiedImageUrl('http://0x7f.1/a.png')?.hostname).toBe(
'127.0.0.1',
);
expect(hostOf(parseProxiedImageUrl('http://[::1]/a.png') as URL)).toBe(
'::1',
);
});
it('다시 가기 대상도 같은 검사 — 다른 스킴 · 포트 · 사용자 정보는 막는다', () => {
const base = new URL('https://news.example.com/a.png');
expect(checkProxiedImageUrl(new URL('/b.png', base))?.href).toBe(
'https://news.example.com/b.png',
);
expect(checkProxiedImageUrl(new URL('http://127.0.0.1:8080/'))).toBeNull();
expect(checkProxiedImageUrl(new URL('gopher://example.com/'))).toBeNull();
expect(
checkProxiedImageUrl(new URL('https://a:b@example.com/')),
).toBeNull();
});
});
@@ -0,0 +1,46 @@
import { MAX_PROXIED_IMAGE_URL_LENGTH } from './mail-image.constants';
/**
* 중계할 수 있는 바깥 이미지 주소 (순수 함수 — 계약 2절 "외부 이미지 보기") — 받은 메일의 `<img src>` 를 중계 주소로 바꿀 때와,
* 바깥이 다시 가라고 한 주소(redirect)를 따라갈 때 같은 검사를 한다.
* - http · https 만 — `//host/…`(프로토콜 상대)는 https 로 본다
* - 기본 포트만(80 · 443) — WHATWG URL 은 기본 포트를 빈 글로 둔다(`http://a:80` → 포트 없음). 그 밖의 포트는 사내 서비스 포트 찌르기에 쓰인다
* - 사용자 정보(`user:pass@`) 없음
* - 길이: 원래 글 · 정규화한 주소 모두 2,048자 이하
* 이름(호스트)이 공인 주소로 풀리는지는 받을 때 본다 (mail-image-fetcher.ts — 이름은 그때그때 다른 주소로 풀릴 수 있다)
*/
const ALLOWED_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
/** 이미 만든 주소(다시 가기 대상 등)가 중계할 수 있는 주소인가 — 그렇다면 그 주소, 아니면 null */
export function checkProxiedImageUrl(url: URL): URL | null {
if (!ALLOWED_PROTOCOLS.has(url.protocol)) return null;
if (url.port !== '') return null;
if (url.username !== '' || url.password !== '') return null;
if (url.hostname === '') return null;
if (url.href.length > MAX_PROXIED_IMAGE_URL_LENGTH) return null;
return url;
}
/** `<img src>` 의 글 → 중계할 주소 (정규화한 URL) — 중계할 수 없으면 null */
export function parseProxiedImageUrl(src: string): URL | null {
const trimmed = src.trim();
if (trimmed === '' || trimmed.length > MAX_PROXIED_IMAGE_URL_LENGTH) {
return null;
}
let url: URL;
try {
url = new URL(trimmed.startsWith('//') ? `https:${trimmed}` : trimmed);
} catch {
return null;
}
return checkProxiedImageUrl(url);
}
/** 주소의 호스트 — IPv6 는 대괄호를 뗀 모양 (연결 · 주소 검사에 쓴다) */
export function hostOf(url: URL): string {
const { hostname } = url;
return hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
}
@@ -0,0 +1,58 @@
/**
* 외부 이미지 보기의 한도 (계약 2절 "외부 이미지 보기" — 9단계) — 숫자를 한 곳에 모은다.
*/
/** 메일 한 통에서 중계 주소로 바꾸는 이미지 수 — 그 뒤의 외부 이미지는 지운다 */
export const MAX_PROXIED_IMAGES_PER_MESSAGE = 100;
/** 중계할 이미지 주소의 길이 (JS 문자열 길이 — 원래 글과 정규화한 주소 모두) — 넘으면 지운다 */
export const MAX_PROXIED_IMAGE_URL_LENGTH = 2_048;
/** 중계 표가 유효한 시간 — 메일을 다시 열면 새 표를 받는다 */
export const MAIL_IMAGE_TOKEN_TTL_MS = 60 * 60_000;
/**
* 표 글의 최대 길이 — 주소 2,048자에 직원 주소 · 끝 시각 · 암호 덧붙임(iv · 인증 태그)이 base64url 로 늘어난 것보다 넉넉히.
* 넘는 경로는 풀어 보지 않고 404 다
*/
export const MAX_MAIL_IMAGE_TOKEN_LENGTH = 4_096;
/** 바깥 이미지를 받는 전체 시간 — 이름 풀기 · 연결 · 다시 가기 · 본문 읽기 모두 합쳐 */
export const MAIL_IMAGE_FETCH_TIMEOUT_MS = 10_000;
/** 받는 크기 상한 — 넘으면 끊는다 */
export const MAIL_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 다시 가기(redirect)를 따라가는 횟수 — 매번 주소 · 이름 풀기 · 공인 주소 검사를 다시 한다 */
export const MAIL_IMAGE_MAX_REDIRECTS = 3;
/** 한 직원이 동시에 받는 수 — 넘으면 기다린다 */
export const MAIL_IMAGE_CONCURRENCY_PER_USER = 6;
/**
* 프로세스 전체에서 동시에 받는 수 — 넘으면 기다리고, 자리가 나면 지금 받는 수가 가장 적은 직원부터 들인다(mail-image-proxy.service.ts). 한 장이 MAIL_IMAGE_MAX_BYTES(5MB)까지라
* 전체로 대략 16 × 5MB 를 넘게 쥐지 않는다 (운영 backend 메모리 한도 1g — 2026-10-29 리뷰). 차례는 응답을 다 보낼 때까지 쥔다
*/
export const MAIL_IMAGE_CONCURRENCY = 16;
/**
* 차례를 기다리는 최대 시간 — 넘으면 받지 않고 404 (앞단 nginx 의 /api 응답 대기 60초보다 짧게).
* 바깥 서버가 느린 이미지가 많은 메일에서 기다림이 끝없이 쌓이지 않게 둔다
*/
export const MAIL_IMAGE_QUEUE_WAIT_MS = 30_000;
/** 한 직원이 차례를 기다릴 수 있는 수 — 넘으면 바로 404 (뉴스레터 한 통의 이미지 수보다 넉넉히) */
export const MAIL_IMAGE_MAX_WAITING_PER_USER = 200;
/** 이 경로의 요청 수 한도 — IP 마다 1분 600회 (뉴스레터 한 통에 이미지가 수십 개, 사무실 NAT) */
export const MAIL_IMAGE_THROTTLE_TTL_MS = 60_000;
export const MAIL_IMAGE_THROTTLE_LIMIT = 600;
/** 바깥으로 묻는 고정 User-Agent — 브라우저의 것을 넘기지 않는다 */
export const MAIL_IMAGE_USER_AGENT = 'ARA-SYSTEM-Mail-Image-Proxy/1.0';
/** 응답의 브라우저 캐시 — 표(주소)마다 1시간, 공유 캐시에는 두지 않는다 */
export const MAIL_IMAGE_CACHE_CONTROL = 'private, max-age=3600';
/** 응답의 CSP — 그림을 문서로 열어도 아무것도 불러오거나 실행하지 않게 */
export const MAIL_IMAGE_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
@@ -0,0 +1,168 @@
import { EventEmitter } from 'events';
import type { Request, Response } from 'express';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { pngBytes } from '@/../test/image-fixtures';
import { MailImageController, releaseWhenDone } from './mail-image.controller';
import type {
MailImageProxyService,
ProxiedImage,
} from './mail-image-proxy.service';
const ARA: AuthenticatedUser = {
email: 'ara.kim@arasystem.test',
name: '김아라',
};
const PNG = pngBytes(4, 4);
/** express 응답 대역 — 머리글 · 끝 · 닫힘만 흉내 낸다 (finish 는 테스트가 낸다 — 느린 브라우저) */
class FakeResponse extends EventEmitter {
statusCode = 200;
headersSent = false;
writableEnded = false;
writableFinished = false;
destroyed = false;
readonly headers = new Map<string, string>();
body: unknown = null;
status(code: number): this {
this.statusCode = code;
return this;
}
setHeader(name: string, value: string): this {
this.headers.set(name.toLowerCase(), value);
return this;
}
end(data?: unknown): this {
this.headersSent = true;
this.writableEnded = true;
this.body = data ?? null;
return this;
}
json(data: unknown): this {
return this.end(data);
}
/** 다 보냈다 (커널에 넘겼다) */
finish(): void {
this.writableFinished = true;
this.emit('finish');
this.emit('close');
}
/** 브라우저가 끊었다 */
hangUp(): void {
this.destroyed = true;
this.emit('close');
}
}
const request = {
header: () => 'trace-1',
} as unknown as Request;
function lease(): ProxiedImage & { release: jest.Mock } {
return { type: 'image/png', data: PNG, release: jest.fn() };
}
function controllerWith(open: MailImageProxyService['open']) {
const proxy = { open: jest.fn(open) };
return {
proxy,
controller: new MailImageController(
proxy as unknown as MailImageProxyService,
),
};
}
describe('MailImageController — 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 (9단계 리뷰 뒤)', () => {
it('보내면 끝(finish)에서 돌려준다 — 그 전에는 돌려주지 않는다, 끝과 닫힘이 둘 다 와도 한 번', async () => {
const image = lease();
const { controller } = controllerWith(() => Promise.resolve(image));
const res = new FakeResponse();
await controller.get(ARA, 'tok', request, res as unknown as Response);
expect(res.statusCode).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(res.headers.get('content-length')).toBe(String(PNG.length));
expect(res.body).toBe(PNG);
// 느린 브라우저 — 아직 다 보내지 못했다
expect(image.release).not.toHaveBeenCalled();
res.finish();
expect(image.release).toHaveBeenCalledTimes(1);
});
it('보내는 중에 브라우저가 끊으면(close) 돌려준다', async () => {
const image = lease();
const { controller } = controllerWith(() => Promise.resolve(image));
const res = new FakeResponse();
await controller.get(ARA, 'tok', request, res as unknown as Response);
expect(image.release).not.toHaveBeenCalled();
res.hangUp();
expect(image.release).toHaveBeenCalledTimes(1);
});
it('받는 사이 브라우저가 끊었으면 아무것도 쓰지 않고 바로 돌려준다 — 받기에도 끊김을 알린다', async () => {
const image = lease();
const res = new FakeResponse();
let signal: AbortSignal | undefined;
const { controller } = controllerWith((_user, _token, abort) => {
signal = abort;
res.hangUp();
return Promise.resolve(image);
});
await controller.get(ARA, 'tok', request, res as unknown as Response);
expect(signal?.aborted).toBe(true);
expect(res.body).toBeNull();
expect(image.release).toHaveBeenCalledTimes(1);
});
it('받지 못하면(null) 표준 실패 형식의 404 RES_001 — 돌려줄 차례가 없다(서비스가 이미 돌려줬다)', async () => {
const { controller } = controllerWith(() => Promise.resolve(null));
const res = new FakeResponse();
await controller.get(ARA, 'tok', request, res as unknown as Response);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({
success: false,
error: {
code: 'RES_001',
message: expect.any(String) as unknown,
traceId: 'trace-1',
},
});
});
});
describe('releaseWhenDone', () => {
it('이미 다 보냈거나 닫혔으면 바로 돌려준다', () => {
for (const state of [
{ writableFinished: true, destroyed: false },
{ writableFinished: false, destroyed: true },
]) {
const res = Object.assign(new FakeResponse(), state);
const release = jest.fn();
releaseWhenDone(res as unknown as Response, release);
expect(release).toHaveBeenCalledTimes(1);
expect(res.listenerCount('finish')).toBe(0);
expect(res.listenerCount('close')).toBe(0);
}
});
it('끝이나 닫힘을 기다렸다 돌려주고 듣기를 거둔다', () => {
const res = new FakeResponse();
const release = jest.fn();
releaseWhenDone(res as unknown as Response, release);
expect(release).not.toHaveBeenCalled();
res.hangUp();
expect(release).toHaveBeenCalledTimes(1);
expect(res.listenerCount('finish')).toBe(0);
expect(res.listenerCount('close')).toBe(0);
});
});
@@ -0,0 +1,165 @@
import {
Controller,
Get,
HttpStatus,
Param,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { getErrorDefinition } from '@/common/errors/error-codes';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
import { MAIL_IMAGES_ROUTE } from '@/modules/mail/mail.constants';
import {
MAIL_IMAGE_CACHE_CONTROL,
MAIL_IMAGE_CONCURRENCY,
MAIL_IMAGE_CONCURRENCY_PER_USER,
MAIL_IMAGE_CONTENT_SECURITY_POLICY,
MAIL_IMAGE_FETCH_TIMEOUT_MS,
MAIL_IMAGE_MAX_BYTES,
MAIL_IMAGE_MAX_REDIRECTS,
MAIL_IMAGE_THROTTLE_LIMIT,
MAIL_IMAGE_THROTTLE_TTL_MS,
} from './mail-image.constants';
import { MailImageProxyService } from './mail-image-proxy.service';
/**
* 응답을 다 보냈거나(finish) 연결이 닫혔을 때(close) release 를 부른다 — 이미 그렇다면 바로. 둘 다 와도 한 번 (release 가 한 번만 돈다)
*/
export function releaseWhenDone(res: Response, release: () => void): void {
if (res.writableFinished || res.destroyed) {
release();
return;
}
const done = () => {
res.removeListener('finish', done);
res.removeListener('close', done);
release();
};
res.once('finish', done);
res.once('close', done);
}
/**
* 외부 이미지 중계 (계약 2절 "외부 이미지 보기" — 9단계) — `GET /api/mail-images/{token}`.
* 받은 메일의 외부 이미지를 보일 때 서버가 그 메일의 `<img src>` 를 이 주소로 바꿔 넣는다(mail.service.ts — 표는 mail-image-token.ts).
*
* 실패는 모두 404 RES_001 — 까닭을 주지 않는다. 표준 실패 형식(JSON)은 같지만 전역 예외 필터를 거치지 않고 여기서 쓴다:
* 필터는 4xx 마다 warn 로그를 남기는데, 뉴스레터 한 통에도 받지 못하는 이미지가 여럿이라(바깥 404 · 그림이 아님) 로그가 넘친다 —
* 까닭은 서비스가 debug 로만 남긴다(주소 없이 바깥 호스트 이름만). 로그인 · 요청 수 한도의 실패(401 · 429)는 평소처럼 필터가 답한다
*/
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_IMAGES_ROUTE)
export class MailImageController {
constructor(private readonly proxy: MailImageProxyService) {}
@Get(':token')
@SkipTransform()
// 이 경로만 넉넉하게 — 뉴스레터 한 통에 이미지가 수십 개다 (사무실 NAT 하나 뒤에 여러 직원)
@Throttle({
default: {
ttl: MAIL_IMAGE_THROTTLE_TTL_MS,
limit: MAIL_IMAGE_THROTTLE_LIMIT,
},
})
@ApiOperation({
summary:
'외부 이미지 (표준 래퍼 없음 — 서버가 바깥 이미지를 대신 받아 준다)',
description:
'메일 상세(images=show 또는 설정)가 외부 이미지 주소를 이 주소로 바꿔 넣는다. 표는 직원 · 원래 주소 · 끝나는 시각(1시간)을 서버 비밀로 암호화한 글이다 — 그 직원만, 1시간 안에만 쓴다. ' +
`서버는 http · https(기본 포트만)이고 이름을 풀어 나온 모든 주소가 공인 주소일 때만 그 주소로 연결한다(다시 가기 ${MAIL_IMAGE_MAX_REDIRECTS}번까지 매번 같은 검사, 전체 ${MAIL_IMAGE_FETCH_TIMEOUT_MS / 1000}초, ${MAIL_IMAGE_MAX_BYTES / 1024 / 1024}MB). ` +
'형식은 받은 바이트로 정한다(PNG · JPEG · GIF · WebP 만 — SVG 등은 받지 않는다). 바깥으로는 쿠키 · Referer 없이 고정한 User-Agent 로 묻는다. ' +
`동시에 받는 수는 서버 전체 ${MAIL_IMAGE_CONCURRENCY}개 · 한 직원 ${MAIL_IMAGE_CONCURRENCY_PER_USER}개까지(넘으면 기다린다 — 자리가 나면 지금 받는 수가 가장 적은 직원부터, 응답을 다 보낼 때까지 센다). 요청 수 한도는 이 경로만 IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회. ` +
`응답: 판정한 Content-Type · Cache-Control: ${MAIL_IMAGE_CACHE_CONTROL} · X-Content-Type-Options: nosniff · Content-Disposition: inline · Content-Security-Policy: ${MAIL_IMAGE_CONTENT_SECURITY_POLICY}.`,
})
@ApiProduces('image/png', 'image/jpeg', 'image/gif', 'image/webp')
@ApiResponse({
status: HttpStatus.OK,
description: '바깥 이미지',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse(
'RES_001',
'받지 않음 — 까닭을 가리지 않는다 (표가 틀림 · 지남 · 다른 직원 · 막은 주소 · 받지 못함 · 너무 큼 · 그림이 아님). 화면은 그 자리를 비운다',
)
@ApiErrorResponse(
'REQ_001',
`IP 마다 1분 ${MAIL_IMAGE_THROTTLE_LIMIT}회 초과`,
)
async get(
@CurrentUser() user: AuthenticatedUser,
@Param('token') token: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 클라이언트가 끊으면 바깥에서 받던 것도 멈춘다 (기다리던 차례에서도 빠진다) — 응답의 'close' 는 다 보내기 전에 연결이 닫혀도 온다
// (요청의 'close' 는 본문을 다 읽으면 오므로 쓰지 않는다 — mail-events.controller.ts 와 같다)
const disconnected = new AbortController();
const abort = () => {
if (!res.writableEnded) disconnected.abort();
};
res.once('close', abort);
// 처리기에 닿기 전에 끊겼으면 'close' 는 이미 지나갔다
if (res.destroyed) disconnected.abort();
try {
const image = await this.proxy.open(
user.email,
token,
disconnected.signal,
);
// 받은 차례는 응답을 다 보내거나 연결이 닫힐 때까지 쥔다 — 느린 브라우저에 보내는 동안의 버퍼도 동시 수에 든다
if (image !== null) releaseWhenDone(res, image.release);
if (res.headersSent || res.destroyed) return;
if (image === null) {
this.notFound(req, res);
return;
}
res.status(HttpStatus.OK);
res.setHeader('Content-Type', image.type);
res.setHeader('Content-Length', String(image.data.length));
res.setHeader('Cache-Control', MAIL_IMAGE_CACHE_CONTROL);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', 'inline');
res.setHeader(
'Content-Security-Policy',
MAIL_IMAGE_CONTENT_SECURITY_POLICY,
);
res.end(image.data);
} finally {
res.removeListener('close', abort);
}
}
/** 표준 실패 형식의 404 RES_001 — 전역 필터와 같은 모양(머리말의 까닭으로 여기서 쓴다) */
private notFound(req: Request, res: Response): void {
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
res.status(HttpStatus.NOT_FOUND).json({
success: false,
error: {
code: 'RES_001',
message: getErrorDefinition('RES_001').message,
...(traceId ? { traceId } : {}),
},
});
}
}
@@ -0,0 +1,241 @@
import { createSocket, type Socket as UdpSocket } from 'dgram';
import { Resolver } from 'dns/promises';
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from 'http';
import type { AddressInfo } from 'net';
import { pngBytes } from '@/../test/image-fixtures';
import type { MailImageRequest } from './mail-image-fetcher';
import {
DnsMailImageResolver,
NodeMailImageTransport,
} from './node-image-transport';
/**
* 실제 연결 고정 구현 (9단계 리뷰 뒤) — 127.0.0.1 에 띄운 HTTP 서버 · 답하지 않는 DNS 서버로 본다.
* 풀리지 않는 이름(`*.pinned.test`)으로 묻는다 — 연결이 되면 그 이름을 다시 풀지 않고 넘겨준 주소로 이었다는 뜻이다
* (전송을 fetch(undici — lookup 을 받지 않는다)로 바꾸거나 lookup 을 빠뜨리면 여기서 실패한다)
*/
const PINNED_HOST = 'images.pinned.test';
const LOOPBACK = { address: '127.0.0.1', family: 4 } as const;
const PNG = pngBytes(8, 8, 1_000);
/** 바깥으로 보내는 머리글 — fetcher 가 주는 것과 같은 모양 */
const HEADERS = {
'user-agent': 'ARA-SYSTEM-Mail-Image-Proxy/1.0',
accept: 'image/png,image/jpeg,image/gif,image/webp;q=0.9,*/*;q=0.1',
'accept-encoding': 'identity',
};
/** 서버가 받은 요청 하나 — 처리기가 받은 것과 응답 */
interface Received {
readonly request: IncomingMessage;
readonly response: ServerResponse;
}
/** 127.0.0.1 의 빈 포트로 연 HTTP 서버 — 받은 요청을 차례로 기다릴 수 있다 */
async function openServer(): Promise<{
readonly server: Server;
readonly port: number;
next(): Promise<Received>;
}> {
const arrived: Received[] = [];
const waiting: ((received: Received) => void)[] = [];
const server = createServer((request, response) => {
const received = { request, response };
const waiter = waiting.shift();
if (waiter) waiter(received);
else arrived.push(received);
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', () => resolve()),
);
return {
server,
port: (server.address() as AddressInfo).port,
next: () => {
const ready = arrived.shift();
return ready
? Promise.resolve(ready)
: new Promise((resolve) => waiting.push(resolve));
},
};
}
function closeServer(server: Server): Promise<void> {
server.closeAllConnections();
return new Promise((resolve) => server.close(() => resolve()));
}
/** 소켓이 닫힐 때 풀리는 약속 */
function closed(request: IncomingMessage): Promise<void> {
return new Promise((resolve) => {
if (request.socket.destroyed) resolve();
else request.socket.once('close', () => resolve());
});
}
/** 원래 주소는 기본 포트(80)다 — 실제로 잇는 포트는 전송의 테스트 전용 자리(forTesting)가 정한다 */
function requestFor(
signal: AbortSignal,
path = '/logo.png?u=1',
): MailImageRequest {
return {
url: new URL(`http://${PINNED_HOST}${path}`),
addresses: [LOOPBACK],
headers: HEADERS,
signal,
};
}
async function readAll(body: AsyncIterable<Uint8Array>): Promise<Buffer> {
const chunks: Uint8Array[] = [];
for await (const chunk of body) chunks.push(chunk);
return Buffer.concat(chunks);
}
describe('NodeMailImageTransport — 넘겨준 주소로만 잇는다 (127.0.0.1 의 서버)', () => {
let opened: Awaited<ReturnType<typeof openServer>>;
beforeEach(async () => {
opened = await openServer();
});
afterEach(async () => {
await closeServer(opened.server);
});
it('풀리지 않는 이름이어도 넘겨준 주소로 잇고, 원래 이름을 Host 로 보낸다 — 머리글은 넘겨준 것과 Host · Connection 뿐(쿠키 · Referer 없음)', async () => {
const transport = NodeMailImageTransport.forTesting({ http: opened.port });
const response = transport.get(requestFor(new AbortController().signal));
const { request, response: reply } = await opened.next();
expect(request.url).toBe('/logo.png?u=1');
expect(request.headers.host).toBe(`${PINNED_HOST}:${opened.port}`);
expect(request.headers.cookie).toBeUndefined();
expect(request.headers.referer).toBeUndefined();
expect(Object.keys(request.headers).sort()).toEqual(
['accept', 'accept-encoding', 'connection', 'host', 'user-agent'].sort(),
);
expect(request.headers['user-agent']).toBe(HEADERS['user-agent']);
reply.writeHead(200, {
'content-type': 'text/html',
'content-length': String(PNG.length),
});
reply.end(PNG);
const got = await response;
expect(got.status).toBe(200);
expect(got.contentLength).toBe(PNG.length);
expect(got.location).toBeNull();
await expect(readAll(got.body)).resolves.toEqual(PNG);
});
it('다시 가기 응답의 Location · 길이 없는 응답(chunked)을 그대로 넘긴다', async () => {
const transport = NodeMailImageTransport.forTesting({ http: opened.port });
const redirect = transport.get(requestFor(new AbortController().signal));
const first = await opened.next();
first.response.writeHead(302, { location: '/elsewhere.png' });
first.response.end();
const moved = await redirect;
expect(moved.status).toBe(302);
expect(moved.location).toBe('/elsewhere.png');
moved.close();
const chunked = transport.get(requestFor(new AbortController().signal));
const second = await opened.next();
second.response.writeHead(200, { 'content-type': 'image/png' });
second.response.write(PNG.subarray(0, 10));
second.response.end(PNG.subarray(10));
const streamed = await chunked;
expect(streamed.contentLength).toBeNull();
await expect(readAll(streamed.body)).resolves.toEqual(PNG);
});
it('응답을 기다리는 사이 끊으면(signal) 연결이 닫히고 약속은 거절된다', async () => {
const transport = NodeMailImageTransport.forTesting({ http: opened.port });
const controller = new AbortController();
const pending = transport.get(requestFor(controller.signal));
const { request } = await opened.next();
const socketClosed = closed(request);
controller.abort();
await expect(pending).rejects.toThrow();
await socketClosed;
expect(request.socket.destroyed).toBe(true);
});
it('본문을 읽는 중에 끊으면(signal) 연결이 닫히고 읽기가 멈춘다 · close() 도 연결을 닫는다', async () => {
const transport = NodeMailImageTransport.forTesting({ http: opened.port });
const controller = new AbortController();
const reading = transport.get(requestFor(controller.signal));
const first = await opened.next();
first.response.writeHead(200, { 'content-length': '100000' });
first.response.write(PNG);
const got = await reading;
const firstClosed = closed(first.request);
controller.abort();
await expect(readAll(got.body)).rejects.toThrow();
await firstClosed;
const closing = transport.get(requestFor(new AbortController().signal));
const second = await opened.next();
second.response.writeHead(200, { 'content-length': '100000' });
second.response.write(PNG);
const secondClosed = closed(second.request);
(await closing).close();
await secondClosed;
expect(second.request.socket.destroyed).toBe(true);
});
it('앱의 전송은 기본 포트(http 80) 로 잇는다 — 테스트 전용 자리가 아니면 바뀌지 않는다', () => {
const transport = new NodeMailImageTransport();
expect(
(transport as unknown as { ports: { http: number; https: number } })
.ports,
).toEqual({ http: 80, https: 443 });
});
});
describe('DnsMailImageResolver — 끊으면 이름 풀기를 취소한다 (127.0.0.1 의 답하지 않는 DNS)', () => {
let dns: UdpSocket;
let port: number;
let queries: number;
let firstQuery: Promise<void>;
beforeEach(async () => {
queries = 0;
dns = createSocket('udp4');
firstQuery = new Promise((resolve) =>
dns.on('message', () => {
queries += 1;
resolve();
}),
);
await new Promise<void>((resolve) => dns.bind(0, '127.0.0.1', resolve));
port = dns.address().port;
});
afterEach(async () => {
jest.restoreAllMocks();
await new Promise<void>((resolve) => dns.close(() => resolve()));
});
it('묻는 사이 끊으면 Resolver 를 취소하고 곧바로 거절한다 (제한 시간을 기다리지 않는다)', async () => {
const cancel = jest.spyOn(Resolver.prototype, 'cancel');
const resolver = DnsMailImageResolver.forTesting([`127.0.0.1:${port}`]);
const controller = new AbortController();
const resolving = resolver.resolve('black.hole.test', controller.signal);
await firstQuery;
expect(queries).toBeGreaterThan(0);
controller.abort();
// 취소하지 않으면 Resolver 는 제한 시간(3초 × 2번)까지 기다린다 — jest 의 테스트 시간(5초)을 넘겨 실패한다.
// 거절 값은 Node 의 ECANCELLED 다 (jest 의 다른 realm 에서는 instanceof Error 가 아니라 'dns-failed' 로 감싸 보인다)
await expect(resolving).rejects.toThrow();
expect(cancel).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { Resolver } from 'dns/promises';
import type { LookupAddress, LookupOptions } from 'dns';
import { request as httpRequest, type IncomingMessage } from 'http';
import { request as httpsRequest } from 'https';
import type {
MailImageRequest,
MailImageResolver,
MailImageResponse,
MailImageTransport,
ResolvedAddress,
} from './mail-image-fetcher';
import { hostOf } from './mail-image-url';
/**
* 바깥 이미지 받기의 실제 이름 풀기 · 전송 (mail-image-fetcher.ts 가 주입받는다) — fetcher 의 단위 테스트는 가짜를 쓰고,
* 이 파일은 127.0.0.1 에 띄운 서버 · DNS 로 따로 본다(node-image-transport.spec.ts). 그래서 테스트 전용 자리(forTesting)가 둘 있다 —
* 연결 포트와 DNS 서버. 앱(Nest 의 useClass)은 쓰지 않는다: 포트는 80 · 443 고정이고 env · 설정으로 열지 않는다
*/
/** 이름 풀기 한 번의 제한 시간 · 다시 묻는 횟수 — 전체 10초 안에서 */
const DNS_TIMEOUT_MS = 3_000;
const DNS_TRIES = 2;
/**
* DNS 로 A · AAAA 를 함께 묻는다 — c-ares(Resolver)로 묻는다. `dns.lookup`(getaddrinfo)은 libuv 스레드 풀(기본 4개)을 막고 기다려,
* 느린 이름 여러 개가 메일 엔진 · DB 연결의 이름 풀기까지 붙잡을 수 있다. 요청마다 새 Resolver — 끊으면 그것만 취소한다.
* 한쪽만 풀려도 그 주소들을 쓴다 (나온 주소는 모두 공인인지 fetcher 가 본다). 둘 다 못 풀면 던진다
*/
@Injectable()
export class DnsMailImageResolver implements MailImageResolver {
/** 물을 DNS 서버 — 앱은 null(운영체제 설정 그대로), 테스트만 준다 */
private servers: readonly string[] | null = null;
/** 테스트 전용 — 이 DNS 서버(127.0.0.1 의 가짜)에 묻는다 */
static forTesting(servers: readonly string[]): DnsMailImageResolver {
const resolver = new DnsMailImageResolver();
resolver.servers = [...servers];
return resolver;
}
async resolve(
hostname: string,
signal: AbortSignal,
): Promise<ResolvedAddress[]> {
const resolver = new Resolver({
timeout: DNS_TIMEOUT_MS,
tries: DNS_TRIES,
});
if (this.servers !== null) resolver.setServers([...this.servers]);
const cancel = () => resolver.cancel();
signal.addEventListener('abort', cancel, { once: true });
try {
const [v4, v6] = await Promise.allSettled([
resolver.resolve4(hostname),
resolver.resolve6(hostname),
]);
const addresses: ResolvedAddress[] = [
...(v4.status === 'fulfilled'
? v4.value.map((address) => ({ address, family: 4 as const }))
: []),
...(v6.status === 'fulfilled'
? v6.value.map((address) => ({ address, family: 6 as const }))
: []),
];
if (addresses.length === 0 && v4.status === 'rejected') {
throw v4.reason instanceof Error ? v4.reason : new Error('dns-failed');
}
return addresses;
} finally {
signal.removeEventListener('abort', cancel);
}
}
}
/** Node 의 lookup 자리 — 확인한 주소만 돌려준다 (연결할 때 이름을 다시 풀지 않는다). 주소 고르기(happy eyeballs)는 `all` 로 */
function pinnedLookup(addresses: readonly ResolvedAddress[]) {
return (
_hostname: string,
options: LookupOptions,
callback: (
error: NodeJS.ErrnoException | null,
address: string | LookupAddress[],
family?: number,
) => void,
): void => {
if (options.all) {
callback(
null,
addresses.map(({ address, family }) => ({ address, family })),
);
return;
}
const [first] = addresses;
callback(null, first.address, first.family);
};
}
function headerValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) return value[0] ?? null;
return value ?? null;
}
function contentLengthOf(value: string | string[] | undefined): number | null {
const raw = headerValue(value);
if (raw === null || !/^\d{1,15}$/.test(raw.trim())) return null;
return Number(raw.trim());
}
/** 연결 포트 — 주소의 체계마다 */
interface TransportPorts {
readonly http: number;
readonly https: number;
}
/** http 80 · https 443 — 바꾸지 않는다 (mail-image-url.ts 가 기본 포트의 주소만 받는다). 테스트만 forTesting 으로 */
const DEFAULT_PORTS: TransportPorts = Object.freeze({ http: 80, https: 443 });
/**
* Node 의 http · https 로 GET 한 번 — 연결은 확인한 주소로만(lookup 자리), 연결을 모아 두지 않는다(agent: false).
* TLS 는 원래 이름으로 확인한다(SNI · 인증서 — 이름은 그대로 두고 주소만 정해 준다). 머리글은 fetcher 가 준 것뿐이다(Host 는 원래 이름)
*/
@Injectable()
export class NodeMailImageTransport implements MailImageTransport {
private ports: TransportPorts = DEFAULT_PORTS;
/** 테스트 전용 — 127.0.0.1 에 띄운 서버의 포트로 잇는다 (앱은 기본 포트 고정) */
static forTesting(ports: Partial<TransportPorts>): NodeMailImageTransport {
const transport = new NodeMailImageTransport();
transport.ports = { ...DEFAULT_PORTS, ...ports };
return transport;
}
get(request: MailImageRequest): Promise<MailImageResponse> {
const { url } = request;
const secure = url.protocol === 'https:';
const send = secure ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const outgoing = send(
{
protocol: url.protocol,
hostname: hostOf(url),
port: secure ? this.ports.https : this.ports.http,
path: `${url.pathname}${url.search}`,
method: 'GET',
headers: { ...request.headers },
agent: false,
lookup: pinnedLookup(request.addresses),
signal: request.signal,
},
(incoming: IncomingMessage) => {
resolve({
status: incoming.statusCode ?? 0,
location: headerValue(incoming.headers.location),
contentLength: contentLengthOf(incoming.headers['content-length']),
body: incoming,
close: () => incoming.destroy(),
});
},
);
// 응답을 받은 뒤의 오류(끊김 · 취소)도 받아 둔다 — 듣는 곳이 없는 'error' 는 프로세스를 멈춘다. 본문 오류는 읽는 쪽에서 난다
outgoing.on('error', reject);
outgoing.end();
});
}
}
@@ -1,10 +1,10 @@
import type { RecipientFields } from '@/modules/mail/mail.types';
import {
DELIVERED_TO_ALL,
classifySmtpReply,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
submittedEnvelope,
toRejectedRecipients,
} from './delivery-status';
import type { JmapEmailSubmission } from './jmap.types';
@@ -197,13 +197,115 @@ describe('받는 사람별 발송 결과 (delivery-status)', () => {
});
});
it('받지 않은 사람이 없는 결과(DELIVERED_TO_ALL)는 여러 요청이 함께 쓰므로 얼려 둔다 — 배열까지', () => {
expect(DELIVERED_TO_ALL).toEqual({
rejected: [],
isEveryoneRejected: false,
describe('엔진이 envelope 에서 받지 않은 주소 (9단계 리뷰 뒤 — envelope.ts)', () => {
const recipients: RecipientFields = {
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
cc: [{ name: null, email: 'typo@example.cmo' }],
bcc: [{ name: null, email: 'Idn@한국.example.com' }],
};
it('낸 envelope 에 없는 받는 사람은 not-found — 결과가 없어도(붙잡은 바깥 받는 사람) 정한다, 요청의 차례 그대로', () => {
expect(
readDeliveryResult(
recipients,
[],
['doyun.lee@arasystem.test', 'idn@xn--3e0b707e.example.com'],
),
).toEqual({
rejected: [
{
email: 'typo@example.cmo',
reason: 'not-found',
smtpStatus: null,
envelopeRefused: true,
},
],
isEveryoneRejected: false,
});
});
it('envelope 에서 빠진 사람과 SMTP 로 거절된 사람을 함께 — 모두면 모두 거절(MAIL_010)', () => {
const result = readDeliveryResult(
recipients,
[
submission({
'doyun.lee@arasystem.test': REFUSED_NOT_FOUND,
'idn@xn--3e0b707e.example.com': REFUSED_RATE_LIMIT,
}),
],
['doyun.lee@arasystem.test', 'Idn@한국.example.com'],
);
expect(
result.rejected.map(({ email, reason }) => [email, reason]),
).toEqual([
['doyun.lee@arasystem.test', 'not-found'],
['typo@example.cmo', 'not-found'],
['Idn@한국.example.com', 'temporary'],
]);
expect(result.isEveryoneRejected).toBe(true);
expect(everyoneRejectedCode(result.rejected)).toBe('MAIL_010');
});
it('국제화 도메인은 엔진의 결과 열쇠(xn--)와 맞춘다 — 요청의 유니코드 주소 그대로 돌려준다', () => {
expect(
readDeliveryResult(only('idn@한국.example.com'), [
submission({ 'idn@xn--3e0b707e.example.com': REFUSED_NOT_FOUND }),
]).rejected,
).toEqual([
{
email: 'idn@한국.example.com',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
]);
});
it('envelope 을 모르면(null) 그 규칙을 쓰지 않는다 — 지금처럼 결과만 본다', () => {
expect(readDeliveryResult(recipients, [], null)).toEqual({
rejected: [],
isEveryoneRejected: false,
});
});
it('같은 키의 재시도 — 제출들의 envelope 받는 사람을 모은다, 하나라도 envelope 을 모르면 null', () => {
expect(
submittedEnvelope([
{
id: 's1',
envelope: {
rcptTo: [{ email: 'a@x.example' }, { email: 'b@y.example' }],
},
},
{ id: 's2', envelope: { rcptTo: [{ email: 'c@z.example' }] } },
]),
).toEqual(['a@x.example', 'b@y.example', 'c@z.example']);
expect(submittedEnvelope([{ id: 's1' }])).toBeNull();
expect(submittedEnvelope([{ id: 's1', envelope: null }])).toBeNull();
expect(submittedEnvelope([])).toBeNull();
});
it('로그에는 envelope 에서 빠진 사람을 따로 적는다', () => {
expect(
describeRejections([
{
email: 'typo@example.cmo',
reason: 'not-found',
smtpStatus: null,
envelopeRefused: true,
},
]),
).toBe('받지 않은 사람 1명: t***@example.cmo(엔진이 주소를 받지 않음)');
expect(
toRejectedRecipients([
{
email: 'typo@example.cmo',
reason: 'not-found',
smtpStatus: null,
envelopeRefused: true,
},
]),
).toEqual([{ email: 'typo@example.cmo', reason: 'not-found' }]);
});
expect(Object.isFrozen(DELIVERED_TO_ALL)).toBe(true);
expect(Object.isFrozen(DELIVERED_TO_ALL.rejected)).toBe(true);
});
it('응답에는 주소와 까닭만 싣는다 — SMTP 상태 코드는 로그 전용', () => {
@@ -5,6 +5,7 @@ import type {
RejectedRecipient,
} from '@/modules/mail/mail.types';
import { maskEmail } from '@/shared/utils/mask';
import { envelopeKey } from './envelope';
import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
/**
@@ -16,13 +17,20 @@ import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
* - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도)
* - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다.
* 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다
* 판단은 deliveryStatus 로만 한다 — undoStatus 는 모두 거절이면 final, 하나라도 대기열에 들어가면 pending 일 뿐이다.
* 판단은 deliveryStatus 로 한다 — undoStatus 는 모두 거절이면 final(붙잡아도 — 2026-10-29 확인), 하나라도 대기열에 들어가면 pending 일 뿐이다.
* 붙잡은 제출도 받는 사람마다 그 자리에서 `delivered: "queued"` · `250 2.1.5 Queued` 로 남는다(사내 · 바깥 모두 — 같은 날 확인).
*
* 엔진이 envelope 에서 받지 않은 주소(9단계 리뷰 뒤 — envelope.ts)는 제출에 들지 않아 결과가 없다 — 그래서 제출의 envelope(rcptTo)을 알면
* **그 envelope 에 없는 받는 사람은 not-found** 다. 결과가 없는 것으로 정하지 않는다: 붙잡은 바깥 받는 사람의 결과가 늘 먼저 있다고 기대하지 않는다.
* 처음 발송(보낸 envelope)과 같은 키의 재시도(제출을 다시 읽은 envelope)가 같은 답을 내게 하는 규칙이다
*/
/** 받지 않은 사람 하나 — 로그에 남길 SMTP 상태 코드를 함께 둔다 (응답에는 email · reason 만 — toRejectedRecipients) */
export interface RecipientRejection extends RejectedRecipient {
/** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 null */
/** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 · envelope 에서 빠졌으면 null */
readonly smtpStatus: string | null;
/** 엔진이 envelope 에서 받지 않아 제출에 들지 않았다 (SMTP 로 묻지도 않았다 — 로그에만 쓴다) */
readonly envelopeRefused?: boolean;
}
/** 한 번 보낸 결과 */
@@ -33,12 +41,6 @@ export interface DeliveryResult {
readonly isEveryoneRejected: boolean;
}
/** 받지 않은 사람이 없다 — 결과를 읽지 못했을 때도 이것으로 본다 (제출은 이미 되었다). 여러 요청이 함께 쓰는 값이라 얼려 둔다 */
export const DELIVERED_TO_ALL: DeliveryResult = Object.freeze({
rejected: Object.freeze([]),
isEveryoneRejected: false,
});
/**
* SMTP 응답 맨 앞의 상태 — 기본 코드(`550`)와 확장 상태(`5.1.2` — RFC 3463), 둘 다 없을 수 있다.
* 여러 줄 응답의 이음표(`550-5.1.1`)도 받는다. 맨 앞만 보므로 응답 글 안의 숫자(판 번호 등)는 읽지 않는다
@@ -52,11 +54,6 @@ const ADDRESS_STATUS_SUBJECT = '1';
/** 로그 한 줄에 적는 받지 않은 사람 수 — 넘으면 "외 N명" */
const LOGGED_REJECTIONS_MAX = 10;
/** 주소를 맞출 때의 열쇠 — 대소문자와 앞뒤 공백을 무시한다 */
function addressKey(email: string): string {
return email.trim().toLowerCase();
}
/**
* 받지 않은 SMTP 응답의 까닭 — 4xx 는 temporary, 확장 상태 5.1.x 는 not-found, 그 밖(5xx · 읽을 수 없는 응답)은 refused.
* 기본 코드가 없으면 확장 상태의 첫 자리로 본다 (`delivered: "no"` 라 어느 쪽이든 이 사람에게는 가지 않는다)
@@ -86,13 +83,16 @@ export function classifySmtpReply(reply: string | null | undefined): {
}
/**
* 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
* `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다.
* 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다.
* 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 envelopeKey(대소문자 무시 · 국제화 도메인은 `xn--`)로 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
* - envelope(제출에 실은 받는 사람)을 알면 그 안에 없는 사람은 not-found (엔진이 envelope 에서 받지 않았다 — 머리말)
* - `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다
* - 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다
* @param envelope 제출에 실은 받는 사람 주소 — 모르면 null (envelope 규칙을 쓰지 않는다)
*/
export function readDeliveryResult(
recipients: RecipientFields,
submissions: readonly JmapEmailSubmission[],
envelope: readonly string[] | null = null,
): DeliveryResult {
const refusals = new Map<string, JmapDeliveryStatus>();
const accepted = new Set<string>();
@@ -100,7 +100,7 @@ export function readDeliveryResult(
for (const [address, status] of Object.entries(
submission.deliveryStatus ?? {},
)) {
const key = addressKey(address);
const key = envelopeKey(address);
if (status?.delivered !== 'no') {
accepted.add(key);
} else if (!refusals.has(key)) {
@@ -108,6 +108,8 @@ export function readDeliveryResult(
}
}
}
const submitted =
envelope === null ? null : new Set(envelope.map(envelopeKey));
const seen = new Set<string>();
const rejected: RecipientRejection[] = [];
@@ -116,10 +118,19 @@ export function readDeliveryResult(
...recipients.cc,
...recipients.bcc,
]) {
const key = addressKey(email);
const key = envelopeKey(email);
if (seen.has(key)) continue;
seen.add(key);
if (submitted !== null && !submitted.has(key)) {
rejected.push({
email,
reason: 'not-found',
smtpStatus: null,
envelopeRefused: true,
});
continue;
}
const refusal = accepted.has(key) ? undefined : refusals.get(key);
if (refusal) {
rejected.push({ email, ...classifySmtpReply(refusal.smtpReply) });
@@ -131,6 +142,24 @@ export function readDeliveryResult(
};
}
/**
* 제출들의 envelope 받는 사람 (같은 키의 재시도가 제출을 다시 읽을 때) — 하나라도 envelope 을 읽지 못하면 null (envelope 규칙을 쓰지 않는다)
*/
export function submittedEnvelope(
submissions: readonly JmapEmailSubmission[],
): string[] | null {
if (submissions.length === 0) return null;
const addresses: string[] = [];
for (const submission of submissions) {
const rcptTo = submission.envelope?.rcptTo;
if (!Array.isArray(rcptTo)) return null;
for (const recipient of rcptTo) {
if (typeof recipient?.email === 'string') addresses.push(recipient.email);
}
}
return addresses;
}
/** 응답에 싣는 모양 (계약의 RejectedRecipient) — SMTP 상태 코드는 로그 전용이라 뺀다 */
export function toRejectedRecipients(
rejected: readonly RecipientRejection[],
@@ -157,8 +186,8 @@ export function describeRejections(
const listed = rejected
.slice(0, LOGGED_REJECTIONS_MAX)
.map(
({ email, smtpStatus }) =>
`${maskEmail(email)}(${smtpStatus ?? '상태 코드 없음'})`,
({ email, smtpStatus, envelopeRefused }) =>
`${maskEmail(email)}(${envelopeRefused ? '엔진이 주소를 받지 않음' : (smtpStatus ?? '상태 코드 없음')})`,
);
const more = rejected.length - listed.length;
return `받지 않은 사람 ${rejected.length}명: ${listed.join(', ')}${more > 0 ? ` 외 ${more}명` : ''}`;
@@ -0,0 +1,208 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
addressNamedIn,
envelopeKey,
envelopeRecipients,
isEnvelopeRefusal,
submissionEnvelope,
} from './envelope';
describe('envelope — 발송 제출의 envelope (9단계 리뷰 뒤 — 늘 직접 준다)', () => {
it('받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소는 처음 쓴 것 하나 (대소문자 · 국제화 도메인 모양 무시)', () => {
expect(
envelopeRecipients({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
cc: [
{ name: null, email: ' DOYUN.LEE@arasystem.test ' },
{ name: null, email: 'kim@partner.example' },
{ name: null, email: 'idn@한국.example.com' },
],
bcc: [
{ name: null, email: 'seojun.park@arasystem.test' },
{ name: null, email: 'IDN@xn--3e0b707e.example.com' },
],
}),
).toEqual([
'doyun.lee@arasystem.test',
'kim@partner.example',
'idn@한국.example.com',
'seojun.park@arasystem.test',
]);
});
it('맞추는 열쇠 — 소문자, 도메인은 xn-- 꼴(엔진이 제출의 envelope 에 두는 모양), 바꿀 수 없는 도메인은 소문자 그대로', () => {
expect(envelopeKey(' Kim@Partner.EXAMPLE ')).toBe('kim@partner.example');
expect(envelopeKey('idn@한국.example.com')).toBe(
'idn@xn--3e0b707e.example.com',
);
expect(envelopeKey('한글@example.com')).toBe('한글@example.com');
expect(envelopeKey('x@[1.2.3.4]')).toBe('x@[1.2.3.4]');
expect(envelopeKey('no-at-sign')).toBe('no-at-sign');
});
it('제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(없으면 null), 받는 사람마다 매개변수 null', () => {
expect(
submissionEnvelope('ara.kim@arasystem.test', ['a@x.example'], {
HOLDFOR: '5',
}),
).toEqual({
mailFrom: {
email: 'ara.kim@arasystem.test',
parameters: { HOLDFOR: '5' },
},
rcptTo: [{ email: 'a@x.example', parameters: null }],
});
expect(
submissionEnvelope(
'ara.kim@arasystem.test',
['a@x.example', 'b@y.example'],
null,
),
).toEqual({
mailFrom: { email: 'ara.kim@arasystem.test', parameters: null },
rcptTo: [
{ email: 'a@x.example', parameters: null },
{ email: 'b@y.example', parameters: null },
],
});
});
it('엔진이 envelope 을 받지 않은 거절 — invalidProperties 의 속성이 envelope 이거나 그 안일 때만', () => {
expect(
isEnvelopeRefusal({
type: 'invalidProperties',
properties: ['envelope'],
}),
).toBe(true);
expect(
isEnvelopeRefusal({
type: 'invalidProperties',
properties: ['envelope/rcptTo/1/email'],
}),
).toBe(true);
expect(
isEnvelopeRefusal({ type: 'invalidProperties', properties: ['emailId'] }),
).toBe(false);
expect(
isEnvelopeRefusal({
type: 'forbiddenMailFrom',
properties: ['envelope'],
}),
).toBe(false);
expect(isEnvelopeRefusal(undefined)).toBe(false);
});
describe('거절 설명에 적힌 우리 주소 하나 (addressNamedIn) — 개발 엔진이 돌려준 설명의 모양 (Stalwart 0.16.27, 2026-10-29)', () => {
const addresses = [
'ara.kim@arasystem.test',
'typo1@example.cmo',
'Typo.Two@Example.CMO',
'idn@한국.cmo',
'"a b"@example.com',
];
it.each<[string, string, string | null]>([
[
'보낸 모양 그대로',
'Invalid e-mail address "typo1@example.cmo".',
'typo1@example.cmo',
],
[
'대소문자 무시',
'Invalid e-mail address "typo.two@example.cmo".',
'Typo.Two@Example.CMO',
],
[
'국제화 도메인(유니코드 그대로)',
'Invalid e-mail address "idn@한국.cmo".',
'idn@한국.cmo',
],
[
'국제화 도메인(xn-- 꼴)',
'Invalid e-mail address "idn@xn--3e0b707e.cmo".',
'idn@한국.cmo',
],
[
'따옴표 앞부분 — 따옴표 · 역슬래시를 이스케이프해 적는다',
'Invalid e-mail address "\\"a b\\"@example.com".',
'"a b"@example.com',
],
[
'따옴표 없이 문장 끝',
'Invalid e-mail address typo1@example.cmo.',
'typo1@example.cmo',
],
['우리 주소가 없음', 'Invalid e-mail address "other@example.cmo".', null],
['설명이 없음', '', null],
])('%s', (_label, description, named) => {
expect(addressNamedIn(description, addresses)).toBe(named);
});
it('다른 주소의 일부는 맞지 않는다 — a@x 는 ba@x 안에서, x@example.co 는 x@example.com 안에서 찾지 않는다', () => {
expect(
addressNamedIn('Invalid e-mail address "ba@x.example".', [
'a@x.example',
'ba@x.example',
]),
).toBe('ba@x.example');
expect(
addressNamedIn('Invalid e-mail address "x@example.com".', [
'x@example.co',
]),
).toBeNull();
expect(addressNamedIn(undefined, addresses)).toBeNull();
});
describe('따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다 (9단계 리뷰 2 — 발송 DTO 의 IsEmail 이 받는 모양)', () => {
const KIM = 'kim@example.com';
/** 엔진이 설명에 적는 모양 — 따옴표 · 역슬래시를 이스케이프 (envelope.ts 머리말) */
const escape = (address: string) =>
address.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
it.each([
'"Kim <kim@example.com>"@x.cmo',
'"kim@example.com "@x.cmo',
'"kim@example.com"@x.cmo',
'"(kim@example.com)"@x.cmo',
])('%s 가 거절되면 그 주소 하나만', (bad) => {
const description = `Invalid e-mail address "${escape(bad)}".`;
expect(addressNamedIn(description, [KIM, bad])).toBe(bad);
// 받는 사람의 차례가 바뀌어도 같다
expect(addressNamedIn(description, [bad, KIM])).toBe(bad);
});
it('설명의 모양이 달라 정확히 맞는 것이 없으면 경계로 찾는다 — 여럿이 걸리면 가장 긴 것 하나 (짧은 주소가 긴 주소의 글 안에 든 것이다)', () => {
const bad = '"Kim <kim@example.com>"@x.cmo';
expect(
addressNamedIn(`Invalid e-mail address: '${bad}'`, [KIM, bad]),
).toBe(bad);
});
it('경계로 찾은 것의 길이가 같으면 envelope 의 앞 차례 하나 — 한 차례에 하나만 뺀다', () => {
expect(
addressNamedIn('Invalid e-mail addresses a1@x.cmo, a2@x.cmo', [
'a2@x.cmo',
'a1@x.cmo',
]),
).toBe('a2@x.cmo');
});
});
it('설명 길이에 비례한다 — 받는 사람 100명 · 설명 1만 자에도 CPU 시간이 작다', () => {
const many = Array.from(
{ length: 100 },
(_, index) => `user${index}@x.example`,
);
const description = `${'user1@x.examplez '.repeat(600)}Invalid e-mail address "user99@x.example".`;
// 정확히 맞는 따옴표가 없을 때(경계로 찾기)도 — 따옴표를 600개 더한다
const unquoted = `${'"user1@x.examplez" '.repeat(600)}Invalid e-mail address user99@x.example.`;
const cpuMs = startCpuTimer();
for (let round = 0; round < 20; round += 1) {
expect(addressNamedIn(description, many)).toBe('user99@x.example');
expect(addressNamedIn(unquoted, many)).toBe('user99@x.example');
}
// 지금 수십 ms — 한도는 넉넉히 (CPU 시간, test/cpu-time.ts)
expect(cpuMs()).toBeLessThan(1_000);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
import { domainToASCII } from 'url';
import type { RecipientFields } from '@/modules/mail/mail.types';
import type { JmapError } from './jmap.types';
/**
* 발송 제출(EmailSubmission/set)의 envelope — 순수 함수 (계약 2절 "받는 사람별 결과").
*
* 발송은 늘 envelope 을 직접 준다(9단계 리뷰 뒤 — 붙잡든 아니든). 엔진(Stalwart 0.16.27, 2026-10-29 확인)은:
* - 직접 준 envelope 에 받지 않는 주소가 있으면(모르는 최상위 도메인 `x@example.cmo` · `x@k-….example`, 따옴표 앞부분 `"a b"@…` 등)
* 제출 **전체**를 `invalidProperties ["envelope"]` 로 거절하고, 설명에 그 가운데 **첫 주소 하나만** 적는다 —
* `Invalid e-mail address "<주소>".` (보낸 모양 그대로 · 대소문자도 그대로, 따옴표 · 역슬래시는 `\"` · `\\` 로 이스케이프). 붙잡든 아니든 같다.
* 그래서 어댑터는 설명에 적힌 우리 주소 하나(addressNamedIn)를 받지 않은 사람(not-found)으로 빼고 같은 메일을 다시 제출한다
* (jmap-mail.provider.ts 의 finishSubmission — 한 차례에 하나, 다시 내기는 mail.constants.ts 의 MAX_ENVELOPE_RESUBMITS 차례까지)
* - envelope 을 주지 않으면 같은 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 모두 그런 주소면 noRecipients (9단계 전의 길)
* - 국제화 도메인은 유니코드 · `xn--` 모두 받고 제출의 envelope 에는 `xn--` 로 둔다. 같은 주소(대소문자 무시)는 하나로 합친다
* - envelope 을 직접 줘도 · 주지 않아도 보내는 메일에서 Bcc 머리글은 뗀다 — 사내 배달 · 바깥(SMTP) 사본 모두 (보낸 사본에만 남는다).
* 개발 환경의 Mailpit 은 받은 envelope 의 받는 사람 가운데 머리글에 없는 주소를 `Bcc:` 머리글로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다
* (머리글이 없는 메일을 envelope 으로만 보내도 Mailpit 사본 첫 줄이 `Bcc:` — 같은 날 확인)
*/
/** envelope 을 받지 않았다는 SetError — 그 속성이 envelope(또는 그 안) 일 때 */
const INVALID_PROPERTIES = 'invalidProperties';
const ENVELOPE_PROPERTY = 'envelope';
/**
* 주소를 맞출 때의 열쇠 — 앞뒤 공백을 빼고 대소문자를 무시하고, 도메인은 `xn--` 꼴로 (엔진이 제출의 envelope · deliveryStatus 에 두는 모양).
* 바꿀 수 없는 도메인(빈 값)은 소문자 그대로 본다
*/
export function envelopeKey(email: string): string {
const trimmed = email.trim();
const at = trimmed.lastIndexOf('@');
if (at < 0) return trimmed.toLowerCase();
const domain = trimmed.slice(at + 1);
const ascii = domainToASCII(domain);
return `${trimmed.slice(0, at).toLowerCase()}@${(ascii || domain).toLowerCase()}`;
}
/**
* envelope 의 받는 사람 — 받는 사람 · 참조 · 숨은참조의 주소(앞뒤 공백만 뺀 모양), 같은 주소(envelopeKey)는 처음 쓴 것 하나.
* envelope 을 주지 않을 때 엔진이 머리글로 정하는 받는 사람과 같은 목록이다
*/
export function envelopeRecipients(recipients: RecipientFields): string[] {
const seen = new Set<string>();
const addresses: string[] = [];
for (const { email } of [
...recipients.to,
...recipients.cc,
...recipients.bcc,
]) {
const key = envelopeKey(email);
if (seen.has(key)) continue;
seen.add(key);
addresses.push(email.trim());
}
return addresses;
}
/** 제출의 envelope — 보내는 사람은 발신 ID 의 주소, MAIL FROM 매개변수는 붙잡을 때만(HOLDFOR · HOLDUNTIL — pending-send.ts) */
export function submissionEnvelope(
from: string,
rcptTo: readonly string[],
parameters: Readonly<Record<string, string>> | null,
): Record<string, unknown> {
return {
mailFrom: {
email: from,
parameters: parameters ? { ...parameters } : null,
},
rcptTo: rcptTo.map((email) => ({ email, parameters: null })),
};
}
/** 엔진이 envelope 을 받지 않은 거절인가 (invalidProperties 의 속성이 envelope 이거나 그 안) */
export function isEnvelopeRefusal(error: JmapError | undefined): boolean {
return (
error?.type === INVALID_PROPERTIES &&
(error.properties ?? []).some(
(property) =>
property === ENVELOPE_PROPERTY ||
property.startsWith(`${ENVELOPE_PROPERTY}/`),
)
);
}
/** 설명 안에서 주소의 앞 · 뒤에 올 수 있는 글자 — 따옴표 · 꺾쇠 · 괄호 · 쉼표 · 쌍점 · 쌍반점 · 빈칸 (주소 글자가 붙어 있으면 다른 주소의 일부다) */
const BOUNDARY = /[\s"'<>()[\],:;]/;
/** 주소의 끝 뒤 — 경계 글자, 또는 문장 끝의 마침표(뒤가 끝 · 경계) */
function endsAt(text: string, end: number): boolean {
if (end >= text.length) return true;
const next = text.charAt(end);
if (BOUNDARY.test(next)) return true;
return (
next === '.' &&
(end + 1 >= text.length || BOUNDARY.test(text.charAt(end + 1)))
);
}
/** text 안에 needle 이 앞뒤 경계를 두고 들어 있는가 (둘 다 소문자) — 다른 주소의 일부(`a@x` 가 `ba@x` 안)는 맞지 않는다 */
function containsBounded(text: string, needle: string): boolean {
if (needle === '') return false;
for (
let index = text.indexOf(needle);
index >= 0;
index = text.indexOf(needle, index + 1)
) {
const before = index === 0 || BOUNDARY.test(text.charAt(index - 1));
if (before && endsAt(text, index + needle.length)) return true;
}
return false;
}
/** 설명에 주소가 적힐 수 있는 모양 — 보낸 그대로 · 따옴표 · 역슬래시를 이스케이프한 것 · 도메인을 `xn--` 로 바꾼 것 */
function describedForms(address: string): string[] {
const escaped = address.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return [...new Set([address, escaped, envelopeKey(address)])].map((form) =>
form.toLowerCase(),
);
}
const QUOTE = '"';
const ESCAPE = '\\';
/**
* 설명의 큰따옴표 토큰 — `"…"` 안의 글, `\"` · `\\` 이스케이프를 푼 것 (엔진은 거절한 주소를 이렇게 적는다 — 머리말). 닫히지 않은 따옴표는 버린다.
* 설명 길이에 비례한다 (한 번 훑는다)
*/
function quotedTokens(description: string): string[] {
const tokens: string[] = [];
let token: string[] | null = null;
for (let index = 0; index < description.length; index += 1) {
const char = description.charAt(index);
if (token === null) {
if (char === QUOTE) token = [];
continue;
}
if (char === ESCAPE && index + 1 < description.length) {
index += 1;
token.push(description.charAt(index));
} else if (char === QUOTE) {
tokens.push(token.join(''));
token = null;
} else {
token.push(char);
}
}
return tokens;
}
/**
* 엔진의 거절 설명에 적힌 우리 envelope 주소 **하나** — 설명 자체는 남기지 않는다(로그 · 응답 모두). 한 차례에 하나만 뺀다(9단계 리뷰 2 —
* 다른 받는 사람을 함께 빼지 않게 · 빼는 쪽이 틀리면 받을 사람이 못 받는다, 덜 빼면 한 차례 더 낼 뿐이다):
* 1. 큰따옴표 토큰(이스케이프를 푼 것)과 맞추는 열쇠(envelopeKey)가 정확히 같은 주소 — 토큰 차례로 처음 맞는 것.
* 그래서 `"Kim <kim@example.com>"@x.cmo` 가 거절돼도 그 안에 글자로 든 kim@example.com 은 빼지 않는다
* 2. 정확히 맞는 것이 없을 때만(설명의 모양이 다름 — 따옴표 없이 적는 등) 대소문자를 무시하고 앞뒤 경계를 두고 찾는다 —
* 여럿이 걸리면 가장 긴 것 하나(짧은 주소가 긴 주소의 글 안에 든 것이다), 길이가 같으면 envelope 의 앞 차례
* 하나도 없으면 null — 그때는 지금처럼 오류 표대로 답한다 (설명의 모양이 바뀌어도 받는 사람을 함부로 빼지 않는다)
*/
export function addressNamedIn(
description: string | undefined,
addresses: readonly string[],
): string | null {
const text = description ?? '';
if (text === '') return null;
const byKey = new Map<string, string>();
for (const address of addresses) {
const key = envelopeKey(address);
if (!byKey.has(key)) byKey.set(key, address);
}
for (const token of quotedTokens(text)) {
if (!token.includes('@')) continue;
const exact = byKey.get(envelopeKey(token));
if (exact !== undefined) return exact;
}
const lower = text.toLowerCase();
let longest: string | null = null;
for (const address of addresses) {
if (longest !== null && address.length <= longest.length) continue;
if (describedForms(address).some((form) => containsBounded(lower, form))) {
longest = address;
}
}
return longest;
}
@@ -1,11 +1,18 @@
import {
MAIL_FILTER_VERSION_PATTERN,
MAX_MAIL_BLOCKS,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import type {
BlockedSender,
MailFilterRule,
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT,
FILTER_SOURCE_FORMAT_WITH_BLOCKS,
FILTER_SOURCE_MARKER,
LATEST_FILTER_SOURCE_FORMAT,
filterRulesVersion,
filterSourceLine,
readFilterSource,
@@ -44,6 +51,7 @@ const vacation: MailVacation = {
const source: FilterScriptSource = {
rules: [rule('r1'), rule('r2', { isEnabled: false, match: 'all' })],
vacation,
blocked: [],
};
/** 원본 줄을 스크립트 머리에 둔 글 — 생성기가 쓰는 모양 (설명 주석 → 원본 → Sieve) */
@@ -108,7 +116,11 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
} as unknown as MailFilterRule;
expect(
filterSourceLine({ rules: [shuffled, source.rules[1]], vacation }),
filterSourceLine({
rules: [shuffled, source.rules[1]],
vacation,
blocked: [],
}),
).toBe(filterSourceLine(source));
});
@@ -163,6 +175,7 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
}),
],
vacation: { ...vacation, subject: '부재\ud83d중' },
blocked: [],
};
const line = filterSourceLine(lonely);
expect(line).toContain('반쪽\\ud800글자');
@@ -172,19 +185,25 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
expect(sourceOf(uploaded)).toEqual(lonely);
});
it('새 판(이 서버의 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
expect(
readFilterSource(scriptWithJson(stored({ v: FILTER_SOURCE_FORMAT + 1 }))),
).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 1 });
it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
expect(
readFilterSource(
scriptWithJson({ v: FILTER_SOURCE_FORMAT + 5, entries: '새 모양' }),
scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })),
),
).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 5 });
).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 1 });
expect(
readFilterSource(
scriptWithJson({
v: LATEST_FILTER_SOURCE_FORMAT + 5,
entries: '새 모양',
}),
),
).toEqual({ status: 'newer', format: LATEST_FILTER_SOURCE_FORMAT + 5 });
// 정수가 아니거나 · 판이 아닌 값 · 옛 판(0 이하)은 깨진 원본이다 — 저장하면 덮어쓴다
for (const v of [
FILTER_SOURCE_FORMAT + 0.5,
`${FILTER_SOURCE_FORMAT + 1}`,
`${LATEST_FILTER_SOURCE_FORMAT + 1}`,
0,
-1,
null,
@@ -299,6 +318,110 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
});
});
describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => {
const blocked: BlockedSender[] = [
{ address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' },
{ address: '@ads.example.org', blockedAt: '2026-10-28T00:00:00.000Z' },
];
const withBlocks: FilterScriptSource = { ...source, blocked };
const storedV2 = (overrides: Record<string, unknown> = {}) =>
stored({ v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, blocked, ...overrides });
it('차단한 주소가 있을 때만 판 2 로 쓴다 — 없으면 판 1 그대로(blocked 칸 없이), 판 2 를 모르는 서버도 읽는다', () => {
const v1 = JSON.parse(
filterSourceLine(source).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>;
expect(v1.v).toBe(FILTER_SOURCE_FORMAT);
expect('blocked' in v1).toBe(false);
const v2 = JSON.parse(
filterSourceLine(withBlocks).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>;
expect(v2.v).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
expect(v2.blocked).toEqual(blocked);
});
it('판 2 를 그대로 읽는다 — 차례(최근 것부터)도 그대로, 판 1 은 차단 없음으로', () => {
expect(readFilterSource(scriptWith(filterSourceLine(withBlocks)))).toEqual({
status: 'ok',
source: withBlocks,
});
expect(sourceOf(scriptWithJson(stored()))?.blocked).toEqual([]);
// 판 1 에 blocked 가 적혀 있어도 보지 않는다 — 판 1 을 쓰는 서버는 그 칸을 모른다
expect(
sourceOf(scriptWithJson(stored({ blocked: [{ address: 'x' }] })))
?.blocked,
).toEqual([]);
// 판 2 에 빈 목록 — 읽는다(쓸 때는 판 1 로 돌아간다)
expect(
sourceOf(scriptWithJson(storedV2({ blocked: [] })))?.blocked,
).toEqual([]);
});
it('차단한 시각은 읽을 때 ISO 8601 UTC(소수 초 3자리)로 맞추고, 칸은 주소 · 시각만 쓴다', () => {
const read = sourceOf(
scriptWithJson(
storedV2({
blocked: [{ address: 'a@b.co', blockedAt: '2026-10-29T01:02:03Z' }],
}),
),
);
expect(read?.blocked).toEqual([
{ address: 'a@b.co', blockedAt: '2026-10-29T01:02:03.000Z' },
]);
const extra = [
{ ...blocked[0], note: '버린다' },
] as unknown as BlockedSender[];
expect(filterSourceLine({ ...source, blocked: extra })).toBe(
filterSourceLine({ ...source, blocked: [blocked[0]] }),
);
});
it.each<[string, unknown]>([
['배열이 아님', { address: 'a@b.co' }],
['칸 없음', undefined],
['주소가 글이 아님', [{ address: 1, blockedAt: blocked[0].blockedAt }]],
['시각 없음', [{ address: 'a@b.co' }]],
['시각을 읽을 수 없음', [{ address: 'a@b.co', blockedAt: '어제' }]],
[
'대문자 — 저장 모양이 아님',
[{ address: 'A@b.co', blockedAt: blocked[0].blockedAt }],
],
['앞뒤 공백', [{ address: ' a@b.co', blockedAt: blocked[0].blockedAt }]],
['주소 모양이 아님', [{ address: 'a b', blockedAt: blocked[0].blockedAt }]],
[
'${ 가 든 주소',
[{ address: '${x}@b.co', blockedAt: blocked[0].blockedAt }],
],
['겹친 주소', [blocked[0], { ...blocked[0] }]],
[
`${MAX_MAIL_BLOCKS + 1}개`,
Array.from({ length: MAX_MAIL_BLOCKS + 1 }, (_, i) => ({
address: `s${i}@example.net`,
blockedAt: blocked[0].blockedAt,
})),
],
])(
'판 2 의 차단 목록이 계약에 맞지 않으면 깨진 원본 — %s',
(_label, value) => {
expect(
readFilterSource(scriptWithJson(storedV2({ blocked: value }))),
).toEqual(BROKEN);
},
);
it(`${MAX_MAIL_BLOCKS}개까지는 읽는다`, () => {
const many = Array.from({ length: MAX_MAIL_BLOCKS }, (_, i) => ({
address: `s${i}@example.net`,
blockedAt: blocked[0].blockedAt,
}));
expect(
sourceOf(scriptWithJson(storedV2({ blocked: many })))?.blocked,
).toHaveLength(MAX_MAIL_BLOCKS);
});
});
describe('filterRulesVersion — 규칙의 지문(version)', () => {
const rules = source.rules;
+86 -15
View File
@@ -1,13 +1,18 @@
import { createHash } from 'crypto';
import { parseBlockTarget } from '@/modules/mail/mail-blocks';
import {
MAIL_FILTER_FIELDS,
MAIL_FILTER_MATCHES,
MAIL_FILTER_OPERATORS,
findFilterRulesProblem,
} from '@/modules/mail/mail-filters';
import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants';
import {
MAX_MAIL_BLOCKS,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
import type {
BlockedSender,
MailFilterActions,
MailFilterCondition,
MailFilterRule,
@@ -30,10 +35,20 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
/**
* 원본 모양의 판 — 모양을 바꾸면 올린다.
* 이보다 큰 판(새 판)은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 —
* 읽기는 빈 규칙 · 동작하지 않음으로 보이고, 쓰기(필터 · 부재중 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게
* 이 서버가 아는 가장 새 판(LATEST_FILTER_SOURCE_FORMAT)보다 큰 판은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`):
* 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 — 읽기는 빈 규칙 · 동작하지 않음으로 보이고,
* 쓰기(필터 · 부재중 · 차단 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게.
*
* - 1: 규칙 · 부재중 (6단계). 차단한 주소가 없으면 9단계부터도 이 판으로 쓴다 — 판 2 를 모르는 옛 서버도 그대로 읽고 쓴다
* - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다.
* 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획:
* 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다)
*/
export const FILTER_SOURCE_FORMAT = 1;
/** 차단 목록이 든 원본의 판 (9단계) */
export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2;
/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */
export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS;
/**
* 스크립트 글에서 원본을 읽은 결과
@@ -47,10 +62,11 @@ export type FilterSourceReading =
| { readonly status: 'broken' }
| { readonly status: 'newer'; readonly format: number };
/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로)과 부재중 자동 응답 */
/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로) · 부재중 자동 응답 · 차단한 보낸 사람(9단계 — 최근에 차단한 것부터) */
export interface FilterScriptSource {
readonly rules: readonly MailFilterRule[];
readonly vacation: MailVacation;
readonly blocked: readonly BlockedSender[];
}
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
@@ -62,10 +78,11 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = {
textBody: '',
};
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
};
/** 정해진 키 순서의 사본 — 들어온 객체(DTO 등)의 키 순서 · 다른 속성과 상관없이 같은 규칙이면 같은 JSON 이 된다 */
@@ -112,13 +129,26 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
.digest('base64url');
}
/** 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중) */
/**
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다
*/
export function filterSourceLine(source: FilterScriptSource): string {
return `${FILTER_SOURCE_MARKER}${JSON.stringify({
v: FILTER_SOURCE_FORMAT,
const base = {
rules: canonicalRules(source.rules),
vacation: canonicalVacation(source.vacation),
})}`;
};
return `${FILTER_SOURCE_MARKER}${JSON.stringify(
source.blocked.length === 0
? { v: FILTER_SOURCE_FORMAT, ...base }
: {
v: FILTER_SOURCE_FORMAT_WITH_BLOCKS,
...base,
blocked: source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
})),
},
)}`;
}
/** 스크립트 맨 앞의 주석들에서 원본 줄을 찾는다 — 주석이 아닌 줄이 나오면 멈춘다 */
@@ -246,13 +276,45 @@ function toVacation(raw: unknown): MailVacation | null {
};
}
/**
* 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각.
* 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다
*/
function toBlocked(raw: unknown): BlockedSender[] | null {
if (!Array.isArray(raw) || raw.length > MAX_MAIL_BLOCKS) return null;
const seen = new Set<string>();
const blocked: BlockedSender[] = [];
for (const entry of raw) {
if (
!isRecord(entry) ||
typeof entry.address !== 'string' ||
typeof entry.blockedAt !== 'string'
) {
return null;
}
const target = parseBlockTarget(entry.address);
const blockedAt = toUtcIso(entry.blockedAt);
if (
target === null ||
target.value !== entry.address ||
blockedAt === null ||
seen.has(target.value)
) {
return null;
}
seen.add(target.value);
blocked.push({ address: target.value, blockedAt });
}
return blocked;
}
const BROKEN: FilterSourceReading = { status: 'broken' };
/**
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
* 판(`v`)이 이 서버의 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
* 같은 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등) — 어긋나면 broken.
* 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
* 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken.
* 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
*/
export function readFilterSource(script: string): FilterSourceReading {
const line = findSourceLine(script);
@@ -269,12 +331,13 @@ export function readFilterSource(script: string): FilterSourceReading {
if (
typeof format === 'number' &&
Number.isSafeInteger(format) &&
format > FILTER_SOURCE_FORMAT
format > LATEST_FILTER_SOURCE_FORMAT
) {
return { status: 'newer', format };
}
if (
format !== FILTER_SOURCE_FORMAT ||
(format !== FILTER_SOURCE_FORMAT &&
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) ||
!Array.isArray(parsed.rules) ||
parsed.rules.length > MAX_MAIL_FILTER_RULES
) {
@@ -289,6 +352,14 @@ export function readFilterSource(script: string): FilterSourceReading {
}
if (findFilterRulesProblem(rules)) return BROKEN;
const blocked =
format === FILTER_SOURCE_FORMAT_WITH_BLOCKS
? toBlocked(parsed.blocked)
: [];
if (blocked === null) return BROKEN;
const vacation = toVacation(parsed.vacation);
return vacation ? { status: 'ok', source: { rules, vacation } } : BROKEN;
return vacation
? { status: 'ok', source: { rules, vacation, blocked } }
: BROKEN;
}
@@ -197,7 +197,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
});
const HANBIT = ' "한빛" <sales@hanbit-partners.co.kr>';
/** 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) */
/**
* 한 차례 — 조회(자리 · 전체 수) + 읽기(그때의 상태) + 옮기는 규칙이면 같은 요청의 계정 기다리는 제출(9단계 — held, 없으면 빈 목록).
* 옮기지 않는 규칙의 요청에는 제출 조회가 없다 — 남는 응답은 쓰이지 않는다
*/
const page = (
ids: string[],
list: Record<string, unknown>[],
@@ -205,10 +208,22 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
state = 's1',
total = ids.length,
position = 0,
}: { state?: string; total?: number; position?: number } = {},
held = [],
}: {
state?: string;
total?: number;
position?: number;
held?: Record<string, unknown>[];
} = {},
): JmapInvocation[] => [
['Email/query', { ids, position, total }, 'q'],
['Email/get', { state, list, notFound: [] }, 'g'],
[
'EmailSubmission/query',
{ ids: held.map((submission) => submission.id), total: held.length },
'hq',
],
['EmailSubmission/get', { list: held }, 'hg'],
];
const updated = (
ids: string[],
@@ -285,12 +300,18 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
m3: { 'mailboxIds/a': null, 'mailboxIds/j': true },
},
});
// 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간
// 메일함 목록은 다시 읽지 않는다 · 영구 삭제는 보내지 않는다 · 엔진 호출마다 남은 시간 안의 제한 시간.
// 옮기는 규칙이라 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 (붙잡힌 메일은 옮기지 않는다 — 9단계)
expect(methodsOf(recorded)).toEqual([
'Email/query',
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
'Email/set',
]);
expect(argsOf(recorded[0], 2)).toMatchObject({
filter: { after: expect.stringMatching(/Z$/) as unknown },
});
for (const record of recorded) {
for (const [, args] of record.calls) {
expect(args).not.toHaveProperty('destroy');
@@ -925,6 +946,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
[
['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
['Email/get', { list: [mail('m1', HANBIT)] }, 'g'],
['EmailSubmission/query', { ids: [], total: 0 }, 'hq'],
['EmailSubmission/get', { list: [] }, 'hg'],
],
]);
expect(
@@ -932,6 +955,154 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
).toMatchObject({ code: 'MAIL_001', reason: 'inbox-state-unknown' });
});
it('붙잡힌 메일(9단계 — 받은편지함으로 옮겨 둔 것)은 옮기지 않고 표시만 한다 — 받은편지함에 남은 메일로 이어 간다', async () => {
const later = new Date(Date.now() + 3_600_000)
.toISOString()
.replace(/\.\d+Z$/, 'Z');
const { runner, recorded } = fakeRunner([
page(['m1', 'm2'], [mail('m1', HANBIT), mail('m2', HANBIT)], {
held: [
{
id: 'sb1',
emailId: 'm2',
undoStatus: 'pending',
sendAt: later,
},
],
}),
updated(['m1', 'm2']),
]);
await expect(
runner.run(ARA, request(), context()),
).resolves.toMatchObject({
scanned: 2,
matched: 2,
remaining: 0,
cursor: null,
});
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
ifInState: 's1',
update: {
m1: {
'keywords/$seen': true,
'mailboxIds/a': null,
'mailboxIds/j': true,
},
m2: { 'keywords/$seen': true },
},
});
});
it('계정의 기다리는 제출을 다 받지 못했으면(total 이 더 큼) 그 차례의 메일 id 로 다시 묻는다 — 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건으로 (9단계 리뷰 2)', async () => {
const later = new Date(Date.now() + 3_600_000)
.toISOString()
.replace(/\.\d+Z$/, 'Z');
const { runner, recorded } = fakeRunner([
[
['Email/query', { ids: ['m1', 'm2'], position: 0, total: 2 }, 'q'],
[
'Email/get',
{
state: 's1',
list: [mail('m1', HANBIT), mail('m2', HANBIT)],
notFound: [],
},
'g',
],
// 상한(500)까지 왔고 더 있다 — m2 의 제출은 그 뒤
[
'EmailSubmission/query',
{
ids: Array.from({ length: 500 }, (_, i) => `s${i}`),
position: 0,
total: 1200,
},
'hq',
],
['EmailSubmission/get', { list: [] }, 'hg'],
],
[
['EmailSubmission/query', { ids: ['sb1'], total: 1 }, 'hq'],
[
'EmailSubmission/get',
{
list: [
{
id: 'sb1',
emailId: 'm2',
undoStatus: 'pending',
sendAt: later,
},
],
},
'hg',
],
],
updated(['m1', 'm2']),
]);
await expect(
runner.run(ARA, request(), context()),
).resolves.toMatchObject({ scanned: 2, matched: 2 });
expect(argsOf(recorded[1])).toMatchObject({
filter: {
emailIds: ['m1', 'm2'],
after: (argsOf(recorded[0], 2).filter as { after: string }).after,
},
});
expect(argsOf(recorded[2])).toEqual({
accountId: 'd',
ifInState: 's1',
update: {
m1: {
'keywords/$seen': true,
'mailboxIds/a': null,
'mailboxIds/j': true,
},
m2: { 'keywords/$seen': true },
},
});
});
it('옮기는 규칙인데 기다리는 제출을 읽지 못하면 아무것도 바꾸지 않고 MAIL_001 (fail closed) — 표시만 하는 규칙은 묻지 않는다', async () => {
const broken = fakeRunner([
[
['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'],
['error', { type: 'serverFail' }, 'hq'],
['error', { type: 'serverFail' }, 'hg'],
],
]);
expect(
await failureOf(broken.runner.run(ARA, request(), context())),
).toMatchObject({ code: 'MAIL_001', reason: 'held-send-check-failed' });
expect(broken.recorded).toHaveLength(1);
const markOnly = fakeRunner([
[
['Email/query', { ids: ['m1'], position: 0, total: 1 }, 'q'],
['Email/get', { state: 's1', list: [mail('m1', HANBIT)] }, 'g'],
],
updated(['m1']),
]);
await expect(
markOnly.runner.run(
ARA,
request({
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
}),
context(),
),
).resolves.toMatchObject({ scanned: 1, matched: 1 });
expect(methodsOf(markOnly.recorded)).toEqual([
'Email/query',
'Email/get',
'Email/set',
]);
});
it('엔진이 차례의 할 일을 모두 거절하면 — 확인한 것이 없으면 그 거절의 코드, 그사이 지워진 메일(notFound)은 거절로 보지 않는다', async () => {
const refused = fakeRunner([
page(['m1'], [mail('m1', HANBIT)]),
@@ -1044,6 +1215,8 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
readonly queries: SimQuery[] = [];
/** 엔진 요청 하나를 처리하기 전에 — 요청 도중에 일어나는 일(새 메일 · 다른 탭의 옮기기) */
readonly beforeCall: ((methods: readonly string[]) => void)[] = [];
/** 붙잡힌 메일(9단계) — 보낼 시각이 지금 뒤인 pending 제출이 있는 메일 */
readonly held = new Set<string>();
private state = 1;
private nextDoc = 0;
@@ -1138,6 +1311,30 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
},
callId,
]);
} else if (method === 'EmailSubmission/query') {
const ids = [...this.held].map((emailId) => `sub-${emailId}`);
queried.set(callId, ids);
responses.push([
'EmailSubmission/query',
{ ids, total: ids.length },
callId,
]);
} else if (method === 'EmailSubmission/get') {
const later = new Date(Date.now() + 3_600_000)
.toISOString()
.replace(/\.\d+Z$/, 'Z');
responses.push([
'EmailSubmission/get',
{
list: [...this.held].map((emailId) => ({
id: `sub-${emailId}`,
emailId,
undoStatus: 'pending',
sendAt: later,
})),
},
callId,
]);
} else if (method === 'Email/set') {
expect(args).not.toHaveProperty('destroy');
if (
@@ -1486,5 +1683,28 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
expect(left).toHaveLength(6);
expect(left.every((email) => email.receivedAt >= T0 + 20_000)).toBe(true);
});
it('붙잡힌 메일(9단계)은 모두 맞는 규칙에도 옮기지 않는다 — 표시만 하고 받은편지함에 남긴 채 끝까지 간다', async () => {
const engine = new FakeEngine();
fill(engine, 700);
const kept = [...engine.emails.keys()].filter(
(_, index) => index % 300 === 5,
);
for (const id of kept) engine.held.add(id);
const always = request({
match: 'always',
conditions: [],
actions: { moveToMailboxId: TARGET, markAsRead: true, flag: false },
});
const result = await runAll(engine, always);
expect(result).toMatchObject({ done: true, scanned: 700, matched: 700 });
const left = engine.sorted(INBOX).map(({ id }) => id);
expect(left.sort()).toEqual([...kept].sort());
for (const id of kept) {
expect(engine.emails.get(id)?.keywords.has('$seen')).toBe(true);
}
expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length);
});
});
});
@@ -45,6 +45,15 @@ import type {
JmapQueryResult,
JmapSetResult,
} from './jmap.types';
import {
heldAmong,
heldEmailsFilter,
heldSubmissionFilter,
readHeldEmails,
submissionCalls,
type HeldEmails,
type HeldLookup,
} from './pending-send';
/**
* 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지,
@@ -79,6 +88,12 @@ const NOT_FOUND = 'notFound';
const MS_PER_SECOND = 1_000;
/** 붙잡힌 메일(9단계)을 묻는 호출 id 앞글자 — `hq` · `hg` (옮기는 규칙일 때만 쪽 읽기에 덧붙인다) */
const HELD_GUARD_CALLS = 'h';
/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */
const NO_HELD: HeldEmails = { ids: new Set(), complete: true };
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
@@ -110,11 +125,12 @@ interface RunPlan {
readonly flag: boolean;
}
/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */
/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 · 붙잡힌 메일을 함께 묻는지(옮기는 규칙) */
interface PageRead {
readonly properties: readonly string[];
readonly needsBody: boolean;
readonly limit: number;
readonly withHeld: boolean;
}
/** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */
@@ -136,6 +152,8 @@ interface RoundPage {
readonly state: string | null;
/** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */
readonly from: RunState;
/** 같은 요청에서 물은 붙잡힌 메일(옮기는 규칙일 때만 — 아니면 없음) */
readonly held: HeldEmails;
}
/** 한 차례를 마친 결과 */
@@ -185,10 +203,14 @@ function runPlan(
};
}
/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */
/**
* 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null.
* 붙잡힌 메일(9단계 — held)은 옮기지 않는다 — 표시만 한다 (취소로만 보낸편지함을 떠난다)
*/
function emailPatch(
plan: RunPlan,
email: JmapEmail | undefined,
held: boolean,
): Record<string, unknown> | null {
const patch: Record<string, unknown> = {};
if (plan.markAsRead && email?.keywords?.$seen !== true) {
@@ -197,7 +219,7 @@ function emailPatch(
if (plan.flag && email?.keywords?.$flagged !== true) {
patch['keywords/$flagged'] = true;
}
if (plan.moveTo !== null) {
if (plan.moveTo !== null && !held) {
patch[`mailboxIds/${plan.inboxId}`] = null;
patch[`mailboxIds/${plan.moveTo}`] = true;
}
@@ -312,6 +334,8 @@ export class JmapFilterRunner {
properties: filterRunProperties(fields),
needsBody,
limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE,
// 옮기는 규칙이면 쪽 읽기에 계정의 기다리는 제출을 함께 묻는다 — 붙잡힌 메일은 옮기지 않는다(9단계)
withHeld: plan.moveTo !== null,
};
let scanned = 0;
@@ -408,35 +432,44 @@ export class JmapFilterRunner {
run: BulkRun,
): Promise<RoundPage> {
if (from.anchor !== null) {
const now = Date.now();
const responses = await this.queryPage(
user,
inboxId,
{ before: from.before, anchor: from.anchor },
read,
run.callOptions(),
now,
);
const query = findResult(responses, 'q', 'Email/query');
if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) {
return this.toPage(responses, from, read);
return this.toPage(responses, from, read, now);
}
}
const restart: RunState = { ...from, anchor: null };
const before =
restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before;
const now = Date.now();
const responses = await this.queryPage(
user,
inboxId,
{ before, fromTop: true },
read,
run.callOptions(),
now,
);
return this.toPage(responses, restart, read);
return this.toPage(responses, restart, read, now);
}
/**
* 쪽 읽기 결과 — 옮기는 규칙이면 같은 요청의 붙잡힌 메일도 읽는다(읽은 메일이 있을 때만, 읽지 못하면 MAIL_001 — fail closed)
* @param now 쪽을 물은 때 (붙잡힘 판정의 지금)
*/
private toPage(
responses: JmapInvocation[],
from: RunState,
read: PageRead,
now: number,
): RoundPage {
const query = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
const found = expectResult<JmapGetResult<JmapEmail>>(
@@ -447,6 +480,10 @@ export class JmapFilterRunner {
const ids = query.ids ?? [];
const start = query.position ?? 0;
const known = typeof query.total === 'number' ? query.total : null;
const held =
read.withHeld && ids.length > 0
? readHeldEmails(responses, HELD_GUARD_CALLS, now)
: NO_HELD;
return {
start,
ids,
@@ -457,6 +494,7 @@ export class JmapFilterRunner {
emails: new Map((found.list ?? []).map((email) => [email.id, email])),
state: isState(found.state) ? found.state : null,
from,
held,
};
}
@@ -470,6 +508,7 @@ export class JmapFilterRunner {
start: PageStart,
read: PageRead,
options: JmapCallOptions,
now: number,
): Promise<JmapInvocation[]> {
const filter: JmapEmailFilterCondition =
start.before === null
@@ -505,6 +544,14 @@ export class JmapFilterRunner {
},
'g',
],
// 옮기는 규칙 — 계정의 기다리는 제출(보낼 시각이 지금 뒤 — 엔진의 색인)을 같은 요청에서 묻는다 (pending-send.ts)
...(read.withHeld
? submissionCalls(
accountId,
heldSubmissionFilter(now),
HELD_GUARD_CALLS,
)
: []),
],
byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options,
);
@@ -565,13 +612,28 @@ export class JmapFilterRunner {
}
}
// 옮기는 규칙이면 맞은 메일 가운데 붙잡힌 메일(9단계)은 옮기지 않는다 — 표시만 한다
const matchedIds = page.ids.filter((id) => verdicts.get(id) === true);
const held =
plan.moveTo === null || matchedIds.length === 0
? new Set<string>()
: await heldAmong(
page.held,
matchedIds,
this.heldLookup(user, run.callOptions()),
);
const patches = new Map<string, Record<string, unknown>>();
/** 옮기기가 든 패치 — 이 메일은 바뀌면 받은편지함을 떠난다 */
const moving = new Set<string>();
let unchanged = 0;
for (const id of page.ids) {
if (verdicts.get(id) !== true) continue;
const patch = emailPatch(plan, page.emails.get(id));
if (patch === null) unchanged += 1;
else patches.set(id, patch);
for (const id of matchedIds) {
const patch = emailPatch(plan, page.emails.get(id), held.has(id));
if (patch === null) {
unchanged += 1;
continue;
}
patches.set(id, patch);
if (plan.moveTo !== null && !held.has(id)) moving.add(id);
}
const applied: Applied | null =
@@ -586,7 +648,7 @@ export class JmapFilterRunner {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = plan.moveTo !== null && applied.updated.has(id);
const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
@@ -602,6 +664,27 @@ export class JmapFilterRunner {
};
}
/**
* 다 받지 못한 계정 전체 조회를 메일 id 로 보충하는 묻기 (pending-send.ts 의 heldAmong — 읽지 못하면 MAIL_001).
* 첫 조회와 같은 "보낼 시각이 지금 뒤" 조건에 메일 id 를 더한다(heldEmailsFilter — 지난 제출이 상한을 채우지 않게)
*/
private heldLookup(user: string, options: JmapCallOptions): HeldLookup {
return async (emailIds) => {
const now = Date.now();
const responses = await this.jmap.call(
user,
(accountId) =>
submissionCalls(
accountId,
heldEmailsFilter(emailIds, now),
HELD_GUARD_CALLS,
),
options,
);
return readHeldEmails(responses, HELD_GUARD_CALLS, now);
};
}
/**
* 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState):
* 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다.
@@ -1,7 +1,11 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import type {
BlockedSender,
MailFilterRule,
MailVacation,
} from '@/modules/mail/mail.types';
import {
NEVER_CONFIGURED_VACATION,
filterRulesVersion,
@@ -22,6 +26,8 @@ import type {
import { buildFilterScript } from './sieve-script';
const ARA = 'ara.kim@arasystem.test';
/** 대역 엔진의 스팸메일함 */
const JUNK = { id: 'jk1', name: 'Junk Mail' };
const SIEVE = 'urn:ietf:params:jmap:sieve';
const VACATION = 'urn:ietf:params:jmap:vacationresponse';
@@ -88,6 +94,11 @@ class FakeSieveEngine {
missingDownloads = 0;
/** 저장 뒤 같은 요청의 SieveScript/get 을 메서드 오류로 */
failRereadAfterSet = false;
/** 메일함 목록 (Mailbox/get) — 차단이 있으면 스팸메일함을 찾는다 */
mailboxes: { id: string; name: string | null; role: string | null }[] = [
{ id: 'in1', name: 'Inbox', role: 'inbox' },
{ id: JUNK.id, name: JUNK.name, role: 'junk' },
];
private sequence = 0;
readonly client = {
@@ -147,11 +158,24 @@ class FakeSieveEngine {
addOurs(source: FilterScriptSource, isActive = true): FakeScript {
return this.addScript(
FILTER_SCRIPT_NAME,
buildFilterScript(source).text,
buildFilterScript(source, { junkMailbox: JUNK }).text,
isActive,
);
}
/** 우리 스크립트의 지금 글 */
ourText(): string | undefined {
const ours = this.ours();
return ours ? this.blobs.get(ours.blobId) : undefined;
}
/** 엔진이 받은 메서드 이름들 (차례대로) */
methods(): string[] {
return this.recorded.flatMap(({ calls }) =>
calls.map(([method]) => method),
);
}
/** 우리 스크립트의 지금 원본 */
ourSource(): FilterScriptSource | null {
const ours = this.scripts.find(
@@ -205,6 +229,12 @@ class FakeSieveEngine {
];
case 'SieveScript/set':
return this.set(args, callId);
case 'Mailbox/get':
return [
method,
{ list: this.mailboxes.map((mailbox) => ({ ...mailbox })) },
callId,
];
default:
return ['error', { type: 'unknownMethod' }, callId];
}
@@ -378,7 +408,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('우리 스크립트가 켜져 있으면 원본의 규칙 · 부재중 — 글은 엔진 한도(102,400바이트)까지만 내려받는다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
const ours = engine.addOurs({ rules, vacation: vacationOn });
const ours = engine.addOurs({ rules, vacation: vacationOn, blocked: [] });
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules,
@@ -399,7 +429,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('엔진에서 다른 스크립트가 켜져 있으면 isActive false — 규칙 · 부재중은 원본 그대로', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn });
engine.addOurs({ rules, vacation: vacationOn, blocked: [] });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
@@ -442,7 +472,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('내려받는 사이 글이 없어졌으면(404 — 그사이 바뀜) 다시 묻는다 — 세 번 모두면 MAIL_001', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.addOurs({
rules: [rule('r1')],
vacation: vacationOn,
blocked: [],
});
engine.missingDownloads = 2;
await expect(engine.store.getFilters(ARA)).resolves.toMatchObject({
isActive: true,
@@ -486,13 +520,19 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
}),
() => engine.store.setVacation(ARA, vacationOn),
() =>
engine.store.addBlock(ARA, {
address: 'kim@spam.example',
blockedAt: '2026-10-29T00:00:00.000Z',
}),
() => engine.store.removeBlock(ARA, 'kim@spam.example'),
]) {
expect(await failureOf(save())).toEqual({
code: 'MAIL_001',
context: {
reason: 'filter-source-newer',
format: 99,
supportedFormat: 1,
supportedFormat: 2,
},
});
}
@@ -510,7 +550,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules: [],
vacation: vacationOn,
blocked: [],
});
});
});
@@ -540,7 +584,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
isActive: true,
});
const expectedSource = { rules, vacation: vacationOn };
const expectedSource = { rules, vacation: vacationOn, blocked: [] };
expect(engine.client.upload).toHaveBeenCalledTimes(1);
const [, blob, type] = engine.client.upload.mock.calls[0];
expect(type).toBe('application/sieve');
@@ -575,7 +619,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
const engine = new FakeSieveEngine();
const before = [rule('r1')];
const ours = engine.addOurs(
{ rules: before, vacation: vacationOn },
{ rules: before, vacation: vacationOn, blocked: [] },
false,
);
engine.addScript('other', 'keep;\r\n', true);
@@ -599,13 +643,21 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
onSuccessActivateScript: ours.id,
},
]);
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
expect(engine.ours()?.isActive).toBe(true);
});
it('그사이 다른 곳에서 규칙이 바뀌었으면(지문이 다름) MAIL_012 — 올리지도 쓰지도 않는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.addOurs({
rules: [rule('r1')],
vacation: vacationOn,
blocked: [],
});
expect(
await failureOf(
@@ -625,7 +677,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('부재중 응답만 바뀐 것은 걸리지 않는다 — 다른 곳에서 바꾼 부재중을 그대로 둔다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
engine.addOurs({
rules: before,
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
const version = (await engine.store.getFilters(ARA)).version;
await engine.store.setVacation(ARA, vacationOn);
@@ -633,13 +689,17 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
await expect(
engine.store.setFilters(ARA, { rules, version }),
).resolves.toMatchObject({ rules });
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
});
it('저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 옛 지문이어도 그대로 저장한다 — 같은 요청을 다시 보낸 경우', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn }, false);
engine.addOurs({ rules, vacation: vacationOn, blocked: [] }, false);
engine.addScript('other', 'keep;\r\n', true);
await expect(
@@ -657,14 +717,22 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('쓰는 사이 상태가 바뀌면(stateMismatch) 다시 읽어 확인하고 쓴다 — 그사이 바뀐 부재중을 지킨다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
engine.addOurs({
rules: before,
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
// 다른 탭이 부재중을 켰다
engine.beforeSet.push(() => {
const ours = engine.ours()!;
const blobId = 'other-tab';
engine.blobs.set(
blobId,
buildFilterScript({ rules: before, vacation: vacationOn }).text,
buildFilterScript({
rules: before,
vacation: vacationOn,
blocked: [],
}).text,
);
ours.blobId = blobId;
engine.state += 1;
@@ -679,12 +747,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
).resolves.toMatchObject({ rules, isActive: true });
expect(engine.setCalls()).toHaveLength(2);
expect(engine.client.upload).toHaveBeenCalledTimes(2);
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
});
it('다시 읽은 글이 같으면 올린 것을 다시 쓴다 — 엔진에 같은 글을 두 번 올리지 않는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn });
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] });
engine.beforeSet.push(() => {
engine.state += 1;
});
@@ -699,13 +771,16 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('다시 읽었더니 다른 곳에서 규칙을 저장했으면 MAIL_012', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn });
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] });
engine.beforeSet.push(() => {
const ours = engine.ours()!;
engine.blobs.set(
'other-tab',
buildFilterScript({ rules: [rule('theirs')], vacation: vacationOn })
.text,
buildFilterScript({
rules: [rule('theirs')],
vacation: vacationOn,
blocked: [],
}).text,
);
ours.blobId = 'other-tab';
engine.state += 1;
@@ -728,7 +803,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
const engine = new FakeSieveEngine();
// 상태 확인을 지나 만들기에서 겹쳤다 — 다른 요청이 같은 이름으로 먼저 만들었다
engine.setReplies.push(() => {
engine.addOurs({ rules: [], vacation: vacationOn }, true);
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [] }, true);
return [
'SieveScript/set',
{ notCreated: { script: { type: 'alreadyExists' } } },
@@ -746,7 +821,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.setCalls()[1]).toMatchObject({
update: { [engine.ours()!.id]: expect.any(Object) as object },
});
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
});
it('세 번 모두 어긋나면 MAIL_001 — 쓰지 않았다', async () => {
@@ -943,20 +1022,32 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
it('규칙을 그대로 두고 부재중만 바꾼다 — 지문을 보지 않고, 규칙의 지문은 그대로다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION });
engine.addOurs({
rules,
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
const before = await engine.store.getFilters(ARA);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
await expect(engine.store.getFilters(ARA)).resolves.toEqual(before);
});
it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.addOurs({
rules: [rule('r1')],
vacation: vacationOn,
blocked: [],
});
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({
isActive: false,
@@ -974,7 +1065,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules: [],
vacation: vacationOn,
blocked: [],
});
expect(engine.ours()?.isActive).toBe(true);
});
@@ -983,12 +1078,20 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addScript(FILTER_SCRIPT_NAME, 'keep;\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
expect(engine.ourSource()).toEqual({
rules: [],
vacation: vacationOn,
blocked: [],
});
});
it('쓰는 사이 다른 곳에서 필터를 저장했으면 다시 읽어 그 규칙을 지킨다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: NEVER_CONFIGURED_VACATION });
engine.addOurs({
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
const theirs = [rule('theirs')];
engine.beforeSet.push(() => {
const ours = engine.ours()!;
@@ -997,6 +1100,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
buildFilterScript({
rules: theirs,
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
}).text,
);
ours.blobId = 'other-tab';
@@ -1007,6 +1111,292 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.ourSource()).toEqual({
rules: theirs,
vacation: vacationOn,
blocked: [],
});
});
});
describe('보낸 사람 차단 (getBlocks · addBlock · removeBlock — 9단계)', () => {
const at = '2026-10-29T01:00:00.000Z';
const kim: BlockedSender = { address: 'kim@spam.example', blockedAt: at };
const domain: BlockedSender = {
address: '@junk.example',
blockedAt: '2026-10-28T00:00:00.000Z',
};
const mailboxReads = (engine: FakeSieveEngine) =>
engine.methods().filter((method) => method === 'Mailbox/get').length;
it('우리 스크립트가 없으면 빈 목록 · 동작함, 있으면 저장한 차례(최근 것부터) · 필터와 같은 isActive', async () => {
const empty = new FakeSieveEngine();
await expect(empty.store.getBlocks(ARA)).resolves.toEqual({
senders: [],
isActive: true,
});
const engine = new FakeSieveEngine();
engine.addOurs(
{ rules: [rule('r1')], vacation: vacationOn, blocked: [kim, domain] },
false,
);
await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
senders: [kim, domain],
isActive: false,
});
// 읽기는 메일함 목록을 묻지 않는다
expect(mailboxReads(engine)).toBe(0);
});
it('처음 차단 — 맨 앞에 더하고 규칙 · 부재중은 그대로, 스팸메일함(id · 이름)을 엔진에서 찾아 넣고 판 2 로 쓴다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn, blocked: [domain] });
await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({
sender: kim,
created: true,
});
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [kim, domain],
});
const text = engine.ourText() ?? '';
expect(text).toContain('# arasystem-source: {"v":2,');
expect(text).toContain('fileinto :mailboxid "jk1" "Junk Mail";');
expect(text).toContain('address :all :is "from" ["kim@spam.example"]');
expect(mailboxReads(engine)).toBe(1);
expect(engine.setCalls()).toHaveLength(1);
expect(engine.setCalls()[0]).toMatchObject({
ifInState: expect.any(String) as unknown,
onSuccessActivateScript: expect.any(String) as unknown,
});
});
it('이미 있는 주소면 쓰지 않고 그 항목을 돌려준다 (created: false — 처음 차단한 시각)', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
await expect(
engine.store.addBlock(ARA, {
...kim,
blockedAt: '2026-10-30T00:00:00.000Z',
}),
).resolves.toEqual({ sender: kim, created: false });
expect(engine.client.upload).not.toHaveBeenCalled();
expect(engine.setCalls()).toEqual([]);
});
it('이미 있는 주소인데 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을 다시 써서 켠다 — 성공한 차단은 늘 "동작 중" (9단계 리뷰 뒤)', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn, blocked: [kim] }, false);
// 다른 프로그램(ManageSieve 등)이 켠 스크립트가 있다
engine.addScript('other', 'keep;', true);
await expect(engine.store.addBlock(ARA, kim)).resolves.toEqual({
sender: kim,
created: false,
});
// 원본은 그대로(차단한 시각도 처음 것) — 다시 써서 켰다
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [kim],
});
expect(engine.setCalls()).toHaveLength(1);
expect(engine.setCalls()[0]).toMatchObject({
onSuccessActivateScript: expect.any(String) as unknown,
});
await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
senders: [kim],
isActive: true,
});
});
it('500개면 VAL_001 — 쓰지 않는다', async () => {
const engine = new FakeSieveEngine();
const many = Array.from({ length: 500 }, (_, i) => ({
address: `s${i}@spam.example`,
blockedAt: at,
}));
engine.addOurs({ rules: [], vacation: vacationOn, blocked: many });
expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({
code: 'VAL_001',
context: { reason: 'too-many-mail-blocks', stored: 500 },
});
expect(engine.setCalls()).toEqual([]);
});
it('만든 글이 한도(90,000바이트)를 넘으면 MAIL_013 — 올리지 않는다, context 에는 수만 (주소는 남기지 않는다)', async () => {
const engine = new FakeSieveEngine();
// 주소 모양을 지키는 긴 주소 — @ 앞 64자 · 이름 마디 63자 · 모두 254자까지 (읽을 때 다시 본다)
const filler = (i: number) => ({
address: `${`s${i}`.padEnd(40, 'x')}@${'d'.repeat(50)}.example`,
blockedAt: at,
});
const longest = `${'l'.repeat(60)}@${'d'.repeat(60)}.${'e'.repeat(60)}.${'f'.repeat(60)}.example`;
const sourceWith = (count: number): FilterScriptSource => ({
rules: [],
vacation: vacationOn,
blocked: Array.from({ length: count }, (_, i) => filler(i)),
});
const bytesOf = (source: FilterScriptSource) =>
buildFilterScript(source, { junkMailbox: JUNK }).bytes;
// 한도 바로 아래까지 채운다 — 하나 더 채우면 넘는다. 긴 주소는 채우는 주소보다 길어 더하면 넘는다
let count = 1;
while (bytesOf(sourceWith(count + 1)) <= 90_000) count += 1;
const stored = sourceWith(count);
expect(bytesOf(stored)).toBeLessThanOrEqual(90_000);
expect(
bytesOf({
...stored,
blocked: [{ address: longest, blockedAt: at }, ...stored.blocked],
}),
).toBeGreaterThan(90_000);
engine.addOurs(stored);
await expect(engine.store.getBlocks(ARA)).resolves.toMatchObject({
isActive: true,
});
const failure = await failureOf(
engine.store.addBlock(ARA, { address: longest, blockedAt: at }),
);
expect(failure.code).toBe('MAIL_013');
expect(failure.context).toMatchObject({
reason: 'filter-script-too-large',
blocked: count + 1,
});
expect(JSON.stringify(failure.context)).not.toContain('example');
expect(engine.client.upload).not.toHaveBeenCalled();
});
it('스팸메일함이 없으면 MAIL_001(junk-mailbox-missing) — 쓰지 않는다', async () => {
const engine = new FakeSieveEngine();
engine.mailboxes = [{ id: 'in1', name: 'Inbox', role: 'inbox' }];
expect(await failureOf(engine.store.addBlock(ARA, kim))).toEqual({
code: 'MAIL_001',
context: { reason: 'junk-mailbox-missing' },
});
expect(engine.setCalls()).toEqual([]);
});
it.each<[string, string | null]>([
['이름 없음', null],
['빈 이름', ' '],
['${ 가 든 이름(다른 프로그램이 바꾼 이름)', '스팸${x}'],
])('스팸메일함의 대체 이름 — %s 이면 "Junk"', async (_label, name) => {
const engine = new FakeSieveEngine();
engine.mailboxes = [{ id: 'jk9', name, role: 'junk' }];
await engine.store.addBlock(ARA, kim);
expect(engine.ourText()).toContain('fileinto :mailboxid "jk9" "Junk";');
});
it('필터의 지문(version)을 묻지 않는다 — 그사이 다른 곳에서 바꾼 규칙을 다시 읽어 지키고 더한다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({
rules: [rule('r1')],
vacation: vacationOn,
blocked: [],
});
const theirs = [rule('theirs')];
engine.beforeSet.push(() => {
const ours = engine.ours()!;
engine.blobs.set(
'other-tab',
buildFilterScript({
rules: theirs,
vacation: vacationOn,
blocked: [],
}).text,
);
ours.blobId = 'other-tab';
engine.state += 1;
});
await engine.store.addBlock(ARA, kim);
expect(engine.ourSource()).toEqual({
rules: theirs,
vacation: vacationOn,
blocked: [kim],
});
expect(engine.setCalls()).toHaveLength(2);
});
it('차단을 푼다 — 나머지는 그대로, 마지막 하나를 풀면 판 1 로 돌아간다(그때는 메일함 목록을 묻지 않는다)', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn, blocked: [kim, domain] });
await engine.store.removeBlock(ARA, domain.address);
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [kim],
});
const readsBefore = mailboxReads(engine);
await engine.store.removeBlock(ARA, kim.address);
expect(engine.ourSource()).toEqual({
rules,
vacation: vacationOn,
blocked: [],
});
expect(engine.ourText()).toContain('# arasystem-source: {"v":1,');
expect(engine.ourText()).not.toContain('address :');
expect(mailboxReads(engine)).toBe(readsBefore);
});
it('없는 주소를 풀면 RES_001 — 쓰지 않는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
expect(
await failureOf(engine.store.removeBlock(ARA, 'lee@spam.example')),
).toEqual({
code: 'RES_001',
context: { reason: 'mail-block-not-found' },
});
expect(engine.setCalls()).toEqual([]);
});
it('필터 · 부재중 저장은 차단 목록을 그대로 둔다 — 쓸 때 스팸메일함을 다시 찾는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn, blocked: [kim] });
await engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
});
await engine.store.setVacation(ARA, NEVER_CONFIGURED_VACATION);
expect(engine.ourSource()).toEqual({
rules: [rule('r1')],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [kim],
});
expect(engine.ourText()).toContain('fileinto :mailboxid "jk1"');
expect(mailboxReads(engine)).toBe(2);
});
it('깨진 원본에 차단을 더하면 덮어쓴다 — 필터 저장과 같다', async () => {
const engine = new FakeSieveEngine();
engine.addScript(
FILTER_SCRIPT_NAME,
'# arasystem-source: {"v":2,"rules":[],"blocked":"깨짐"}\r\nkeep;\r\n',
true,
);
await expect(engine.store.getBlocks(ARA)).resolves.toEqual({
senders: [],
isActive: false,
});
await engine.store.addBlock(ARA, kim);
expect(engine.ourSource()).toEqual({
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [kim],
});
});
});
@@ -1,7 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants';
import type {
BlockedSender,
MailBlockAdded,
MailBlockList,
MailFilterSet,
MailFilterUpdate,
MailVacation,
@@ -11,7 +15,7 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
EMPTY_FILTER_SOURCE,
FILTER_SOURCE_FORMAT,
LATEST_FILTER_SOURCE_FORMAT,
canonicalRules,
filterRulesVersion,
readFilterSource,
@@ -29,6 +33,7 @@ import {
import type {
JmapError,
JmapGetResult,
JmapMailbox,
JmapSetResult,
JmapSieveScript,
JmapVacationResponse,
@@ -38,6 +43,7 @@ import {
SIEVE_STRING_MAX_BYTES,
buildFilterScript,
type BuiltFilterScript,
type JunkMailboxTarget,
} from './sieve-script';
/** JMAP for Sieve (RFC 9661) — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-28 확인) */
@@ -115,7 +121,7 @@ interface ScriptSnapshot extends ScriptView {
readonly newerFormat: number | null;
}
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 수만 (규칙의 글은 남기지 않는다) */
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */
function scriptSummary(
script: BuiltFilterScript,
source: FilterScriptSource,
@@ -125,6 +131,7 @@ function scriptSummary(
rules: source.rules.length,
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
vacation: source.vacation.isEnabled,
blocked: source.blocked.length,
};
}
@@ -141,8 +148,20 @@ function toVacationState(view: ScriptView): MailVacationState {
return { ...view.source.vacation, isActive: view.isActive };
}
/** 차단 목록의 응답 — 최근에 차단한 것부터(저장한 차례) + 필터와 같은 isActive (계약 2절 "보낸 사람 차단"의 MailBlockList) */
function toBlockList(view: ScriptView): MailBlockList {
return {
senders: view.source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
})),
isActive: view.isActive,
};
}
/**
* 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
* 메일 필터 · 부재중 자동 응답 · 보낸 사람 차단(9단계)의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
* 셋 가운데 하나를 바꿀 때도 나머지 둘은 다시 읽은 그대로 함께 쓴다.
* Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면
* 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다 — 그래서 한 스크립트에 함께 둔다. `vacation` 스크립트는 지우지도 고치지도 않는다(엔진이 forbidden).
*
@@ -184,7 +203,8 @@ export class JmapFilterScriptStore {
},
});
}
return { rules: update.rules, vacation: current.vacation };
// 부재중 · 차단 목록은 다시 읽은 그대로 둔다
return { ...current, rules: update.rules };
});
return toFilterSet(saved);
}
@@ -197,18 +217,68 @@ export class JmapFilterScriptStore {
return toVacationState(await this.snapshot(user));
}
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
/** 부재중 자동 응답만 바꾼다 — 규칙 · 차단 목록은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacationState> {
const saved = await this.write(user, (current) => ({
rules: current.rules,
...current,
vacation,
}));
return toVacationState(saved);
}
/** 차단 목록 (계약 2절 "보낸 사람 차단" — 9단계) — 원본을 읽을 수 없으면(깨짐 · 새 판) 빈 목록 · 동작하지 않음 */
async getBlocks(user: string): Promise<MailBlockList> {
return toBlockList(await this.snapshot(user));
}
/**
* 차단을 더한다 — 맨 앞(최근)에. 이미 있는 주소면 그 항목을 돌려준다(created: false) — 엔진에서 우리 규칙이 꺼져 있으면 같은 원본을
* 다시 써서 켠다(setFilters 가 같은 규칙을 다시 저장하는 것과 같다 — 성공한 차단은 늘 "동작 중"이다), 켜져 있으면 쓰지 않는다.
* 규칙 · 부재중은 다시 읽은 그대로 둔다 — 필터의 지문(version)을 보지 않는다(주소 하나를 더하는 일이라 그사이 바뀐 필터를 덮지 않는다).
* 이미 MAX_MAIL_BLOCKS 개면 VAL_001, 만든 글이 한도를 넘으면 MAIL_013
*/
async addBlock(user: string, sender: BlockedSender): Promise<MailBlockAdded> {
let added: MailBlockAdded = { sender, created: true };
await this.write(user, (current, isActive) => {
const existing = current.blocked.find(
({ address }) => address === sender.address,
);
if (existing) {
added = { sender: existing, created: false };
return isActive ? null : current;
}
if (current.blocked.length >= MAX_MAIL_BLOCKS) {
throw new BusinessException('VAL_001', {
context: {
reason: 'too-many-mail-blocks',
stored: current.blocked.length,
},
});
}
added = { sender, created: true };
return { ...current, blocked: [sender, ...current.blocked] };
});
return added;
}
/** 차단을 푼다 — 없는 주소면 RES_001(아무것도 쓰지 않는다). 규칙 · 부재중은 다시 읽은 그대로 */
async removeBlock(user: string, address: string): Promise<void> {
await this.write(user, (current) => {
if (!current.blocked.some((entry) => entry.address === address)) {
throw new BusinessException('RES_001', {
context: { reason: 'mail-block-not-found' },
});
}
return {
...current,
blocked: current.blocked.filter((entry) => entry.address !== address),
};
});
}
/**
* 지금 엔진 규칙을 읽는다 — 스크립트 목록과 엔진의 부재중 응답을 한 요청으로 묻고, 우리 스크립트가 있으면 글을 내려받는다.
* 내려받는 사이 글이 없어졌으면(그사이 바뀜) 다시 묻는다 — SCRIPT_ATTEMPTS 번을 넘으면 MAIL_001
@@ -248,7 +318,7 @@ export class JmapFilterScriptStore {
state,
scriptId: null,
isActive: true,
source: { rules: [], vacation: toVacation(list[0]) },
source: { rules: [], vacation: toVacation(list[0]), blocked: [] },
newerFormat: null,
};
}
@@ -297,16 +367,22 @@ export class JmapFilterScriptStore {
}
/**
* 다음 원본을 정해(change) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012) 그대로 던진다.
* 다음 원본을 정해(change — 지금 원본과 엔진에서 켜져 있는지를 받는다) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012 · VAL_001 · RES_001) 그대로 던진다.
* change 가 null 이면 바꿀 것이 없다 — 쓰지 않고 지금 것을 돌려준다(이미 있는 차단 · 켜져 있음). 같은 원본을 돌려주면 그대로 다시 쓰고 켠다.
* 만든 글이 한도를 넘으면 MAIL_013 (올리지 않는다). 같은 글을 다시 쓸 때는 올린 것을 다시 쓴다.
* 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다)
* 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중 · 차단을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다).
* 차단한 주소가 있으면 스팸메일함(id · 이름)을 엔진의 메일함 목록에서 찾아 넣는다 — 없으면 MAIL_001
*/
private async write(
user: string,
change: (current: FilterScriptSource) => FilterScriptSource,
change: (
current: FilterScriptSource,
isActive: boolean,
) => FilterScriptSource | null,
): Promise<ScriptView> {
let uploaded: { readonly text: string; readonly blobId: string } | null =
null;
let junkMailbox: JunkMailboxTarget | null = null;
for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) {
const current = await this.snapshot(user);
if (current.newerFormat !== null) {
@@ -314,7 +390,7 @@ export class JmapFilterScriptStore {
context: {
reason: 'filter-source-newer',
format: current.newerFormat,
supportedFormat: FILTER_SOURCE_FORMAT,
supportedFormat: LATEST_FILTER_SOURCE_FORMAT,
},
});
}
@@ -324,8 +400,14 @@ export class JmapFilterScriptStore {
context: { reason: 'filter-script-state-unknown' },
});
}
const next = change(current.source);
const script = buildFilterScript(next);
const next = change(current.source, current.isActive);
if (next === null) {
return { source: current.source, isActive: current.isActive };
}
if (next.blocked.length > 0 && junkMailbox === null) {
junkMailbox = await this.junkMailbox(user);
}
const script = buildFilterScript(next, { junkMailbox });
if (
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
@@ -347,7 +429,7 @@ export class JmapFilterScriptStore {
const saved = await this.save(user, current, blobId, script, next);
if (saved !== null) {
this.logger.debug(
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, ${attempt}번째`,
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`,
);
return { source: next, isActive: saved.isActive };
}
@@ -360,6 +442,36 @@ export class JmapFilterScriptStore {
});
}
/** 차단한 메일이 들어갈 스팸메일함 — 엔진의 메일함 목록에서 역할이 junk 인 것. 없으면 MAIL_001 (차단을 쓸 곳이 없다) */
private async junkMailbox(user: string): Promise<JunkMailboxTarget> {
const responses = await this.jmap.call(user, (accountId) => [
[
'Mailbox/get',
{ accountId, ids: null, properties: ['id', 'name', 'role'] },
'm',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
const junk = list.find(
(mailbox) => mailbox.role?.trim().toLowerCase() === 'junk',
);
if (!junk) {
throw new BusinessException('MAIL_001', {
context: { reason: 'junk-mailbox-missing' },
});
}
// 이름은 id 의 메일함이 없을 때만 쓰는 대체다. 다른 프로그램(IMAP)이 바꾼 이름에 `${` 가 있으면 엔진이 변수로 펼친다 — 그때는 기본 이름
const name = junk.name?.trim() ?? '';
return {
id: junk.id,
name: name !== '' && !name.includes('${') ? name : 'Junk',
};
}
private async upload(user: string, text: string): Promise<string> {
const { blobId } = await this.jmap.upload(
user,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import {
describeFailure,
isMissingAttachmentBlob,
isRequestRefused,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
@@ -147,3 +148,48 @@ describe('로그용 실패 요약 (describeFailure)', () => {
expect(describeFailure('문자열')).toBe('unknown');
});
});
describe('엔진이 요청을 실행하지 않았다고 답한 실패 (isRequestRefused — 9단계 리뷰 2)', () => {
const failure = (code: 'MAIL_001' | 'VAL_001' | 'REQ_001', context = {}) =>
new BusinessException(code, { context });
it('HTTP 4xx(요청 전체 거절 · 그 밖의 4xx) · 429 는 실행하지 않은 것이다', () => {
expect(
isRequestRefused(
failure('VAL_001', {
reason: 'request-rejected',
jmapType: 'notRequest',
}),
),
).toBe(true);
expect(
isRequestRefused(
failure('MAIL_001', { reason: 'http-error', call: 'api', status: 400 }),
),
).toBe(true);
expect(
isRequestRefused(
failure('MAIL_001', { reason: 'http-error', call: 'api', status: 413 }),
),
).toBe(true);
expect(
isRequestRefused(
failure('REQ_001', { reason: 'mail-engine-rate-limit', call: 'api' }),
),
).toBe(true);
});
it('시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 · 코드 없는 오류는 아니다 — 엔진이 실행했는지 모른다', () => {
for (const context of [
{ reason: 'timeout', call: 'api' },
{ reason: 'network', call: 'api' },
{ reason: 'http-error', call: 'api', status: 502 },
{ reason: 'malformed-response', call: 'api' },
{ reason: 'http-error', call: 'api' },
]) {
expect(isRequestRefused(failure('MAIL_001', context))).toBe(false);
}
expect(isRequestRefused(new Error('terminated'))).toBe(false);
expect(isRequestRefused('문자열')).toBe(false);
});
});
@@ -97,3 +97,25 @@ export function describeFailure(error: unknown): string {
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
/** 요청 거절의 HTTP 상태 범위 — 4xx */
const CLIENT_ERROR_MIN = 400;
const SERVER_ERROR_MIN = 500;
/**
* 엔진이 요청을 **실행하지 않았다고** 답한 실패인가 (jmap.client.ts 의 call · exchange 가 던진 것):
* HTTP 4xx — 요청 전체 거절(`request-rejected` — invalidRequestCode · 그 밖의 4xx `http-error`) · 429(REQ_001). JMAP 은 이런 요청의 메서드를 하나도 돌리지 않는다(RFC 8620 §3.6.1).
* 시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답은 아니다 — 엔진이 실행했는지 모른다 (발송이면 나갔을 수 있다)
*/
export function isRequestRefused(error: unknown): boolean {
if (!(error instanceof BusinessException)) return false;
if (error.code === 'REQ_001') return true;
const { reason, status } = error.context ?? {};
if (reason === 'request-rejected') return true;
return (
reason === 'http-error' &&
typeof status === 'number' &&
status >= CLIENT_ERROR_MIN &&
status < SERVER_ERROR_MIN
);
}
+21 -3
View File
@@ -10,6 +10,7 @@ import type {
MailVacation,
MessageDetailSource,
MessageSummary,
PendingSend,
} from '@/modules/mail/mail.types';
import type {
JmapBodyPart,
@@ -171,7 +172,13 @@ function trueKeys(record: Record<string, boolean> | undefined): string[] {
.map(([key]) => key);
}
export function toSummary(email: JmapEmail): MessageSummary {
/**
* 요약 — 기다리는 발송(pendingSend — 9단계)은 같은 요청의 제출 기록으로 어댑터가 정해 넘긴다 (jmap/pending-send.ts, 없으면 null)
*/
export function toSummary(
email: JmapEmail,
pendingSend: PendingSend | null = null,
): MessageSummary {
return {
id: email.id,
threadId: email.threadId ?? email.id,
@@ -185,6 +192,7 @@ export function toSummary(email: JmapEmail): MessageSummary {
isFlagged: email.keywords?.$flagged === true,
hasAttachment: hasRealAttachment(email.attachments ?? []),
size: email.size ?? 0,
pendingSend,
};
}
@@ -248,7 +256,16 @@ function joinBodyValues(
};
}
export function toDetailSource(email: JmapEmail): MessageDetailSource {
/**
* 상세 원본 — 기다리는 발송 · 스팸메일함에 들었는지는 같은 요청의 제출 · 메일함으로 어댑터가 정해 넘긴다 (없으면 null · false)
*/
export function toDetailSource(
email: JmapEmail,
extra: {
readonly pendingSend?: PendingSend | null;
readonly isInJunkMailbox?: boolean;
} = {},
): MessageDetailSource {
const attachments = downloadableAttachments(email);
const inlineParts: InlinePart[] = attachments.flatMap((part, index) =>
typeof part.cid === 'string' && part.cid.trim() !== ''
@@ -267,7 +284,8 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource {
const text = joinBodyValues(email, email.textBody, 'text/plain');
return {
...toSummary(email),
...toSummary(email, extra.pendingSend ?? null),
isInJunkMailbox: extra.isInJunkMailbox ?? false,
cc: toAddresses(email.cc),
bcc: toAddresses(email.bcc),
replyTo: toAddresses(email.replyTo),
+32 -1
View File
@@ -82,6 +82,8 @@ export interface JmapEmail {
hasAttachment?: boolean;
size?: number;
messageId?: string[] | null;
/** In-Reply-To 머리글의 Message-ID (꺾쇠 없이) */
inReplyTo?: string[] | null;
references?: string[] | null;
bodyValues?: Record<string, JmapBodyValue>;
htmlBody?: JmapBodyPart[];
@@ -146,8 +148,25 @@ export interface JmapDeliveryStatus {
/** RFC 8621 §7 EmailSubmission 중 이 어댑터가 읽는 부분 */
export interface JmapEmailSubmission {
id: string;
/** 이 제출이 보내는 메일 */
emailId?: string;
/**
* pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7).
* 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계)
*/
undoStatus?: string | null;
/** 엔진이 보낼(보낸) 시각 — UTCDate. 붙잡은 제출은 풀리는 시각 (HOLDFOR · HOLDUNTIL — 2026-10-29 확인) */
sendAt?: string | null;
/** 받는 사람 주소 → 결과 (엔진이 모르면 null) */
deliveryStatus?: Record<string, JmapDeliveryStatus | null> | null;
/**
* 이 제출의 envelope — 받는 사람(rcptTo)은 엔진이 받은 주소뿐이다(받지 않는 주소가 있으면 제출 전체를 거절한다 — envelope.ts).
* 엔진은 주소를 소문자 · 국제화 도메인은 `xn--` 꼴로 둔다 (2026-10-29 확인)
*/
envelope?: {
mailFrom?: { email?: string | null } | null;
rcptTo?: { email?: string | null }[] | null;
} | null;
}
export interface JmapGetResult<T> {
@@ -168,12 +187,21 @@ export interface JmapChangesResult {
destroyed?: string[];
}
/** set 의 created 항목 — 엔진이 만든 뒤 알려 주는 속성. id 는 늘 오고, 제출(EmailSubmission)은 보낼 시각 · 상태도 준다 (2026-10-29 확인) */
export interface JmapCreatedObject {
id?: string;
/** EmailSubmission — 엔진이 보낼 시각 (붙잡은 제출은 풀리는 시각) */
sendAt?: string | null;
/** EmailSubmission — pending · final · canceled */
undoStatus?: string | null;
}
export interface JmapSetResult {
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
oldState?: string | null;
/** 이 요청이 바꾼 뒤의 상태 */
newState?: string;
created?: Record<string, { id?: string }>;
created?: Record<string, JmapCreatedObject>;
updated?: Record<string, unknown>;
destroyed?: string[];
notCreated?: Record<string, JmapError>;
@@ -208,7 +236,10 @@ export type JmapEmailFilter =
export interface JmapQueryResult {
ids?: string[];
position?: number;
/** calculateTotal 을 물었을 때만 */
total?: number;
/** 엔진이 정한 한도 — 우리가 준 limit 을 줄였거나(RFC 8620 §5.5) 그 한도에 닿아 잘랐을 때만 온다 (Stalwart 0.16.27, 2026-10-29 확인) */
limit?: number;
}
/** RFC 9425 Quota */
@@ -0,0 +1,357 @@
import { BusinessException } from '@/common/errors/business.exception';
import {
CANCELED_SEND_KEYWORD,
HELD_SUBMISSIONS_MAX,
SCHEDULED_SEND_KEYWORD,
heldAmong,
heldEmailsFilter,
heldQueryFull,
heldSendTimes,
heldSubmissionFilter,
holdParameters,
isHeld,
pendingSendOf,
readHeldEmails,
readHeldSendTimes,
submissionCalls,
submittedPendingSend,
} from './pending-send';
import type { JmapEmailSubmission, JmapInvocation } from './jmap.types';
const NOW = Date.parse('2026-10-29T06:00:00.000Z');
const LATER = '2026-10-29T07:00:00Z';
const EARLIER = '2026-10-29T05:00:00Z';
const submission = (
extra: Partial<JmapEmailSubmission> = {},
): JmapEmailSubmission => ({
id: 'sb1',
emailId: 'm1',
undoStatus: 'pending',
sendAt: LATER,
...extra,
});
describe('pending-send — 붙잡아 둔 발송(9단계)의 순수 규칙', () => {
it('붙잡는 방법 → 엔진의 MAIL FROM 매개변수 — 보내기 취소는 HOLDFOR 초, 예약은 HOLDUNTIL(소수 초 없는 UTC)', () => {
expect(holdParameters({ kind: 'undo', seconds: 10 })).toEqual({
HOLDFOR: '10',
});
expect(
holdParameters({ kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' }),
).toEqual({ HOLDUNTIL: '2026-11-02T00:30:00Z' });
});
it('기다리는 제출은 보낼 시각(after — 엔진의 색인)으로 찾는다 — 같은 요청에 query → get(#ids), 보낼 시각 차례(정렬 이름은 sentAt), 500개까지, total 도 묻는다', () => {
expect(heldSubmissionFilter(NOW)).toEqual({
after: '2026-10-29T06:00:00Z',
});
const calls = submissionCalls('d', { emailIds: ['m1'] }, 'p');
expect(calls).toEqual([
[
'EmailSubmission/query',
{
accountId: 'd',
filter: { emailIds: ['m1'] },
// Stalwart 0.16.27 은 sendAt 을 unsupportedSort 로 거절하고 RFC 8621 §7.3 의 이름 sentAt 으로 sendAt 차례를 준다 (2026-10-29 확인)
sort: [{ property: 'sentAt', isAscending: true }],
limit: HELD_SUBMISSIONS_MAX,
// 다 받았는지는 응답의 total 로 정한다 (9단계 리뷰 2)
calculateTotal: true,
},
'pq',
],
[
'EmailSubmission/get',
{
accountId: 'd',
'#ids': {
resultOf: 'pq',
name: 'EmailSubmission/query',
path: '/ids',
},
properties: ['emailId', 'undoStatus', 'sendAt'],
},
'pg',
],
]);
});
it('메일 id 로 다시 묻는 조건 — 계정 전체 조회와 같은 "지금 뒤" 조건에 메일 id 를 더한다 (9단계 리뷰 2 — 지난 제출이 상한을 채우지 않게)', () => {
expect(heldEmailsFilter(['m1', 'm2'], NOW)).toEqual({
emailIds: ['m1', 'm2'],
after: '2026-10-29T06:00:00Z',
});
});
it('붙잡고 있는 제출 — pending 이고 보낼 시각이 아직 오지 않은 것만 (배달 중인 잠깐의 pending · final · canceled 는 아니다)', () => {
expect(isHeld(submission(), NOW)).toBe(true);
expect(isHeld(submission({ sendAt: EARLIER }), NOW)).toBe(false);
expect(isHeld(submission({ sendAt: '2026-10-29T06:00:00Z' }), NOW)).toBe(
false,
);
expect(isHeld(submission({ undoStatus: 'final' }), NOW)).toBe(false);
expect(isHeld(submission({ undoStatus: 'canceled' }), NOW)).toBe(false);
expect(isHeld(submission({ sendAt: null }), NOW)).toBe(false);
expect(isHeld(submission({ sendAt: '모름' }), NOW)).toBe(false);
});
it('메일 id → 보낼 시각(ISO 8601 UTC) — 붙잡은 것만, 한 메일에 여럿이면 가장 이른 것, 메일 id 없는 제출은 뺀다', () => {
const times = heldSendTimes(
[
submission({ id: 'a', emailId: 'm1', sendAt: '2026-10-29T08:00:00Z' }),
submission({ id: 'b', emailId: 'm1', sendAt: LATER }),
submission({ id: 'c', emailId: 'm2', undoStatus: 'final' }),
submission({ id: 'd', emailId: undefined }),
submission({ id: 'e', emailId: 'm3', sendAt: EARLIER }),
],
NOW,
);
expect([...times]).toEqual([['m1', '2026-10-29T07:00:00.000Z']]);
});
it('같은 요청의 제출 읽기 결과 — 엔진이 거절했거나 응답이 없으면 빈 표(목록 · 상세를 실패로 만들지 않는다)', () => {
const ok: JmapInvocation[] = [
['EmailSubmission/get', { list: [submission()] }, 'pg'],
];
expect([...readHeldSendTimes(ok, 'p', NOW)]).toEqual([
['m1', '2026-10-29T07:00:00.000Z'],
]);
expect(
readHeldSendTimes([['error', { type: 'forbidden' }, 'pg']], 'p', NOW)
.size,
).toBe(0);
expect(readHeldSendTimes([], 'p', NOW).size).toBe(0);
});
it('보이기 경로의 계정 전체 조회가 다 주지 못했는지 — 상한 · 엔진이 줄인 limit 에 닿았거나 total 보다 적으면 true(경고용), 다 받았거나 모르면 · 오류 · 응답 없음은 false', () => {
const ids = (count: number) =>
Array.from({ length: count }, (_, index) => `s${index}`);
const full = (answer: Record<string, unknown>) =>
heldQueryFull([['EmailSubmission/query', answer, 'pq']], 'p');
expect(full({ ids: ids(HELD_SUBMISSIONS_MAX) })).toBe(true);
expect(full({ ids: ids(HELD_SUBMISSIONS_MAX - 1) })).toBe(false);
// 엔진이 limit 을 줄였다 · total 보다 적게 왔다 (9단계 리뷰 2)
expect(full({ ids: ids(250), limit: 250 })).toBe(true);
expect(full({ ids: ids(250), total: 600 })).toBe(true);
expect(full({ ids: ids(3), total: 3 })).toBe(false);
expect(heldQueryFull([['error', { type: 'serverFail' }, 'pq']], 'p')).toBe(
false,
);
expect(heldQueryFull([], 'p')).toBe(false);
});
describe('막기 경로 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (fail closed)', () => {
/** 조회 · 읽기 응답 — 조회의 ids 는 count 개, total 은 엔진이 준 값(기본: 받은 수 그대로 — Stalwart 0.16.27 은 calculateTotal 이면 준다) */
const responses = (
list: JmapEmailSubmission[],
count = list.length,
extra: Record<string, unknown> = { total: count },
): JmapInvocation[] => [
[
'EmailSubmission/query',
{
ids: Array.from({ length: count }, (_, index) => `s${index}`),
position: 0,
...extra,
},
'hq',
],
['EmailSubmission/get', { list }, 'hg'],
];
it('붙잡힌 제출의 메일만 — pending 이고 보낼 시각이 아직 오지 않은 것, 조회가 맞는 제출을 다 주었으면(total) complete', () => {
const held = readHeldEmails(
responses([
submission({ id: 'a', emailId: 'm1' }),
submission({ id: 'b', emailId: 'm2', undoStatus: 'canceled' }),
submission({ id: 'c', emailId: 'm3', sendAt: EARLIER }),
submission({ id: 'd', emailId: undefined }),
submission({ id: 'e', emailId: 'm4', undoStatus: 'final' }),
]),
'h',
NOW,
);
expect([...held.ids]).toEqual(['m1']);
expect(held.complete).toBe(true);
// 상한(500)을 받았고 더 있다 — 다 받지 못했다. 맞는 것이 꼭 500개였으면 다 받은 것이다
expect(
readHeldEmails(
responses([], HELD_SUBMISSIONS_MAX, {
total: HELD_SUBMISSIONS_MAX + 1,
}),
'h',
NOW,
).complete,
).toBe(false);
expect(
readHeldEmails(responses([], HELD_SUBMISSIONS_MAX), 'h', NOW).complete,
).toBe(true);
});
it.each<[string, number, Record<string, unknown>]>([
// 엔진의 쿼리 최대가 우리 limit(500)보다 작다 — limit 을 줄여 알리고 그만큼만 준다 (RFC 8620 §5.5)
['엔진이 limit 을 줄여 알림', 250, { limit: 250, total: 600 }],
// 줄였다고 알리지 않아도 total 이 더 크면 더 있다
['엔진이 말없이 적게 줌 (total 이 더 큼)', 250, { total: 600 }],
// total 을 주지 않으면 다 받았는지 모른다 — 닫힌 쪽
['total 이 없음', 3, {}],
['total 은 없고 줄인 limit 에 닿음', 250, { limit: 250 }],
['처음부터가 아님 (position)', 3, { total: 3, position: 2 }],
])(
'다 받았는지를 요청한 limit 이 아니라 응답으로 정한다 — %s: complete 가 아니다 (9단계 리뷰 2)',
(_label, count, extra) => {
expect(readHeldEmails(responses([], count, extra), 'h', NOW)).toEqual({
ids: new Set(),
complete: false,
});
},
);
it('보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(닫힌 쪽) — 보이기 경로(heldSendTimes)는 그대로 뺀다 (9단계 리뷰 2)', () => {
const unreadable = [
submission({ id: 'a', emailId: 'm1', sendAt: null }),
submission({ id: 'b', emailId: 'm2', sendAt: '모름' }),
submission({ id: 'c', emailId: 'm3', sendAt: undefined }),
// 상태를 읽지 못한 것도 같은 쪽 — 끝난 제출(final · canceled)만 아니다
submission({ id: 'd', emailId: 'm4', undoStatus: undefined }),
submission({ id: 'e', emailId: 'm5', undoStatus: '?', sendAt: null }),
// 보낼 시각이 지난 pending(배달 중) · 끝난 제출은 막지 않는다
submission({ id: 'f', emailId: 'm6', sendAt: EARLIER }),
submission({
id: 'g',
emailId: 'm7',
undoStatus: 'final',
sendAt: null,
}),
submission({
id: 'h',
emailId: 'm8',
undoStatus: 'canceled',
sendAt: null,
}),
];
expect([...readHeldEmails(responses(unreadable), 'h', NOW).ids]).toEqual([
'm1',
'm2',
'm3',
'm4',
'm5',
]);
expect(heldSendTimes(unreadable, NOW).size).toBe(0);
});
it.each<[string, JmapInvocation[]]>([
[
'조회가 오류',
[
['error', { type: 'serverFail' }, 'hq'],
['error', { type: 'serverFail' }, 'hg'],
],
],
[
'읽기가 오류',
[
['EmailSubmission/query', { ids: [] }, 'hq'],
['error', { type: 'forbidden' }, 'hg'],
],
],
['응답 없음', []],
])(
'읽지 못하면(%s) 던진다 — MAIL_001 (보이기 경로의 readHeldSendTimes 처럼 삼키지 않는다)',
(_label, broken) => {
let thrown: unknown;
try {
readHeldEmails(broken, 'h', NOW);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(BusinessException);
expect((thrown as BusinessException).code).toBe('MAIL_001');
expect((thrown as BusinessException).context).toMatchObject({
reason: 'held-send-check-failed',
});
},
);
it('후보 가운데 붙잡힌 메일 — 계정 전체 조회가 다 봤으면 다시 묻지 않는다', async () => {
const lookup = jest.fn();
await expect(
heldAmong(
{ ids: new Set(['m1', 'm9']), complete: true },
['m1', 'm2'],
lookup,
),
).resolves.toEqual(new Set(['m1']));
expect(lookup).not.toHaveBeenCalled();
});
it('계정 전체 조회가 상한에 닿았으면 후보의 메일 id 로 다시 묻는다 — 그것도 닿으면 MAIL_001', async () => {
const lookup = jest
.fn()
.mockResolvedValueOnce({ ids: new Set(['m2']), complete: true });
await expect(
heldAmong(
{ ids: new Set(['m1']), complete: false },
['m1', 'm2', 'm3'],
lookup,
),
).resolves.toEqual(new Set(['m1', 'm2']));
expect(lookup).toHaveBeenCalledWith(['m1', 'm2', 'm3']);
lookup.mockResolvedValueOnce({ ids: new Set(), complete: false });
await expect(
heldAmong({ ids: new Set(), complete: false }, ['m4'], lookup),
).rejects.toMatchObject({ code: 'MAIL_001' });
// 후보가 없으면 묻지 않는다
lookup.mockClear();
await expect(
heldAmong({ ids: new Set(), complete: false }, [], lookup),
).resolves.toEqual(new Set());
expect(lookup).not.toHaveBeenCalled();
});
});
it('발송 응답의 pendingSend — 엔진이 만든 제출의 보낼 시각, 없으면 붙잡은 값으로 어림, 이미 final · canceled 면 null', () => {
const undo = { kind: 'undo', seconds: 10 } as const;
expect(
submittedPendingSend(undo, { sendAt: LATER, undoStatus: 'pending' }, NOW),
).toEqual({ kind: 'undo', sendAt: '2026-10-29T07:00:00.000Z' });
expect(submittedPendingSend(undo, {}, NOW)).toEqual({
kind: 'undo',
sendAt: '2026-10-29T06:00:10.000Z',
});
expect(
submittedPendingSend(
{ kind: 'scheduled', until: '2026-11-02T00:30:00.000Z' },
{ sendAt: null },
NOW,
),
).toEqual({ kind: 'scheduled', sendAt: '2026-11-02T00:30:00.000Z' });
expect(submittedPendingSend(undo, { undoStatus: 'final' }, NOW)).toBeNull();
expect(
submittedPendingSend(undo, { undoStatus: 'canceled' }, NOW),
).toBeNull();
});
it('메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 undo, 붙잡은 제출이 없으면 null', () => {
const times = new Map([['m1', '2026-10-29T07:00:00.000Z']]);
expect(
pendingSendOf(
{ id: 'm1', keywords: { [SCHEDULED_SEND_KEYWORD]: true } },
times,
),
).toEqual({ kind: 'scheduled', sendAt: '2026-10-29T07:00:00.000Z' });
expect(pendingSendOf({ id: 'm1', keywords: {} }, times)).toEqual({
kind: 'undo',
sendAt: '2026-10-29T07:00:00.000Z',
});
expect(pendingSendOf({ id: 'm2', keywords: {} }, times)).toBeNull();
});
it('표시 이름은 엔진 키워드 규칙($ · 영문 소문자 · -)을 지킨다', () => {
for (const keyword of [SCHEDULED_SEND_KEYWORD, CANCELED_SEND_KEYWORD]) {
expect(keyword).toMatch(/^\$[a-z-]+$/);
}
});
});
@@ -0,0 +1,344 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { PendingSend, SendHold } from '@/modules/mail/mail.types';
import { toJmapUtcDate, toUtcIso } from './jmap.mapper';
import { expectResult, findResult } from './jmap-results';
import type {
JmapEmail,
JmapEmailSubmission,
JmapGetResult,
JmapInvocation,
JmapQueryResult,
} from './jmap.types';
/**
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수. 엔진의 FUTURERELEASE(Stalwart 0.16.27, 2026-10-29 확인):
* - 제출(EmailSubmission/set create)의 `envelope.mailFrom.parameters` 에 `HOLDFOR: "<초>"` · `HOLDUNTIL: "<RFC 3339 UTC>"` → `undoStatus: pending`,
* `update { undoStatus: 'canceled' }` 로 취소(배달되지 않는다), 시간이 되면 보낸다(`final`)
* - 붙잡는 매개변수는 직접 준 envelope 의 MAIL FROM 에 싣는다 — 발송은 붙잡든 아니든 늘 envelope 을 직접 준다(받는 사람 · 참조 · 숨은참조의 주소,
* 엔진이 받지 않는 주소 · Bcc 머리글은 envelope.ts 머리말)
* - 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — "기다리는 발송"은 pending 이고 보낼 시각이 아직 오지 않은 것으로 가린다
* - 기다리는 제출은 보낼 시각(sendAt)으로 찾는다(`filter.after` — 엔진의 색인을 탄다, 제출 기록 462개에 4ms). `undoStatus` 조건은 모든 제출을
* 훑어 같은 계정에 65~140ms 라 쓰지 않는다 (같은 날 잼). 차례는 보낼 시각 오름차순 — 정렬 이름은 `sentAt` 이다(RFC 8621 §7.3 의 이름 —
* Stalwart 는 `sendAt` 을 unsupportedSort 로 거절하고 `sentAt` 으로 sendAt 차례를 준다, 2026-10-29 확인)
* - 붙잡힌 메일은 취소로만 보낸편지함을 떠난다 — 옮기기 · 지우기 경로는 readHeldEmails · heldAmong 으로 막는다(fail closed — 읽지 못하면 MAIL_001.
* 보낼 시각을 읽지 못한 기다리는 제출도 막고, 다 받았는지는 응답의 total 로 정한다 — 9단계 리뷰 2)
*/
/**
* 예약 발송 표시 (JMAP 키워드) — 보낸 사본에 붙여 보내기 취소를 기다리는 것과 가른다. 목록 · 화면에는 표시로 내보내지 않는다.
* 보낸 뒤에도 남는다(해가 없다 — 기다리는 발송인지는 제출로 정한다)
*/
export const SCHEDULED_SEND_KEYWORD = '$ara-scheduled';
/**
* 취소한 발송을 임시보관함으로 되돌린 초안 표시 (JMAP 키워드) — 그 메일에는 발송의 지문 머리글이 남아 있어(메일은 고칠 수 없다)
* 이 표시가 있어야 사용자의 임시 저장본으로 다룬다 (jmap-mail.provider.ts 의 isUserDraft). 다시 저장하면 새 초안이 되어 표시도 지문도 없어진다
*/
export const CANCELED_SEND_KEYWORD = '$ara-canceled';
/**
* 한 번에 읽는 기다리는 제출 수 — 엔진의 maxObjectsInGet(500). 보낼 시각이 지금 뒤인 제출에는 취소한 예약도 든다(보낼 시각이 7일 안에 남는다) —
* 넘으면(응답의 total 로 안다 — queryCoverage) 보이기 경로는 경고만 남기고(보낼 시각이 이른 것부터 500개), 막기 경로는 메일 id 로 다시 묻는다(heldAmong)
*/
export const HELD_SUBMISSIONS_MAX = 500;
/** 제출에서 읽는 속성 */
const SUBMISSION_PROPERTIES = ['emailId', 'undoStatus', 'sendAt'];
/** 제출 조회의 차례 — 보낼 시각 오름차순 (정렬 이름은 `sentAt` — 머리말) */
const BY_SEND_TIME = [{ property: 'sentAt', isAscending: true }];
const PENDING = 'pending';
const FINAL = 'final';
const CANCELED = 'canceled';
/** 붙잡는 방법 → 엔진의 MAIL FROM 매개변수 (HOLDUNTIL 은 초 단위 UTC — 소수 초 없이) */
export function holdParameters(hold: SendHold): Record<string, string> {
return hold.kind === 'undo'
? { HOLDFOR: String(hold.seconds) }
: { HOLDUNTIL: toJmapUtcDate(hold.until) };
}
/** 지금 이후에 보낼 제출을 묻는 조건 (보낼 시각으로 — 엔진의 색인) */
export function heldSubmissionFilter(nowMs: number): { after: string } {
return { after: toJmapUtcDate(new Date(nowMs).toISOString()) };
}
/**
* 메일 id 로 붙잡힌 제출을 묻는 조건 — 계정 전체 조회(heldSubmissionFilter)와 같은 "지금 뒤" 조건에 메일 id 를 더한다(한 조건 객체 — AND).
* 그 메일들의 지난 제출(final · 지난 취소)이 상한을 채우지 않게 (9단계 리뷰 2 — 엔진은 둘을 함께 받는다, 2026-10-29 확인)
*/
export function heldEmailsFilter(
emailIds: readonly string[],
nowMs: number,
): { emailIds: string[]; after: string } {
return { emailIds: [...emailIds], ...heldSubmissionFilter(nowMs) };
}
/**
* 같은 요청에 넣는 제출 조회 · 읽기 — 호출 id 는 `${prefix}q` · `${prefix}g`.
* 조회는 total 도 묻는다(calculateTotal) — 다 받았는지를 요청한 limit 이 아니라 응답으로 정한다(queryCoverage)
*/
export function submissionCalls(
accountId: string,
filter: Record<string, unknown>,
prefix: string,
): JmapInvocation[] {
return [
[
'EmailSubmission/query',
{
accountId,
filter,
sort: BY_SEND_TIME,
limit: HELD_SUBMISSIONS_MAX,
calculateTotal: true,
},
`${prefix}q`,
],
[
'EmailSubmission/get',
{
accountId,
'#ids': {
resultOf: `${prefix}q`,
name: 'EmailSubmission/query',
path: '/ids',
},
properties: SUBMISSION_PROPERTIES,
},
`${prefix}g`,
],
];
}
/** 붙잡고 있는 제출인가 — pending 이고 보낼 시각이 아직 오지 않음 (보이기 경로 — 읽지 못한 보낼 시각은 붙잡지 않은 것으로) */
export function isHeld(
submission: JmapEmailSubmission,
nowMs: number,
): boolean {
const sendAt = Date.parse(submission.sendAt ?? '');
return submission.undoStatus === PENDING && sendAt > nowMs;
}
/**
* 막기 경로의 판정 — 끝난 제출(final · canceled)이 아니고, 보낼 시각이 아직 오지 않았거나 **읽지 못한** 제출 (닫힌 쪽 — 9단계 리뷰 2).
* 보이기 경로의 isHeld 와 다르다: 화면에는 시각을 모르는 기다리는 발송을 싣지 않지만, 옮기기 · 지우기는 "읽지 못하면 바꾸지 않는다".
* 상태(undoStatus)를 읽지 못한 것도 같은 쪽이다. 배달 중인 잠깐의 pending 은 보낼 시각이 지난 값으로 읽히므로 막지 않는다
*/
function holdsForGuard(
submission: JmapEmailSubmission,
nowMs: number,
): boolean {
const { undoStatus } = submission;
if (undoStatus === FINAL || undoStatus === CANCELED) return false;
const sendAt = Date.parse(submission.sendAt ?? '');
return Number.isNaN(sendAt) || sendAt > nowMs;
}
/** 메일 id → 그 메일의 붙잡힌 제출이 보낼 시각(ISO 8601 UTC) — 여럿이면 가장 이른 것 */
export function heldSendTimes(
submissions: readonly JmapEmailSubmission[],
nowMs: number,
): Map<string, string> {
const times = new Map<string, string>();
for (const submission of submissions) {
const emailId = submission.emailId;
if (!emailId || !isHeld(submission, nowMs)) continue;
const sendAt = toUtcIso(submission.sendAt);
if (sendAt === null) continue;
const known = times.get(emailId);
if (known === undefined || Date.parse(sendAt) < Date.parse(known)) {
times.set(emailId, sendAt);
}
}
return times;
}
/**
* 같은 요청의 제출 읽기 결과 — 엔진이 거절했으면(메서드 오류 · 응답 없음) 빈 표: 기다리는 발송 표시가 빠질 뿐 목록 · 상세를 실패로 만들지 않는다.
* **보이기 경로 전용**이다 — 옮기기 · 지우기를 막는 데는 쓰지 않는다(실패를 삼켜 통과시킨다 — readHeldEmails 를 쓴다)
*/
export function readHeldSendTimes(
responses: JmapInvocation[],
prefix: string,
nowMs: number,
): Map<string, string> {
try {
const result = findResult(responses, `${prefix}g`, 'EmailSubmission/get');
if (!result.ok) return new Map();
const { list = [] } = result.args as JmapGetResult<JmapEmailSubmission>;
return heldSendTimes(list, nowMs);
} catch {
return new Map();
}
}
/**
* 제출 조회가 맞는 제출을 다 주었나 — **응답으로** 정한다(요청한 limit 이 아니라 — 9단계 리뷰 2). 엔진은 자기 최대가 더 작으면 limit 을 줄인다
* (RFC 8620 §5.5 — 줄였으면 응답에 limit 이 온다. Stalwart 0.16.27 은 limit 에 닿아 잘랐을 때도 limit 을 싣고, calculateTotal 이면 total 을 준다 —
* 2026-10-29 확인).
* - total 이 있으면: 처음부터(position 0) total 만큼 받았으면 'all', 아니면 'partial'
* - total 이 없으면: 응답의 limit(없으면 우리 상한)에 닿았으면 'partial', 닿지 않았으면 'unknown' — 엔진이 말없이 줄였을 수 있다
*/
function queryCoverage(query: JmapQueryResult): 'all' | 'partial' | 'unknown' {
const count = (query.ids ?? []).length;
if (typeof query.total === 'number') {
return (query.position ?? 0) === 0 && count >= query.total
? 'all'
: 'partial';
}
const limit =
typeof query.limit === 'number'
? Math.min(query.limit, HELD_SUBMISSIONS_MAX)
: HELD_SUBMISSIONS_MAX;
return count >= limit ? 'partial' : 'unknown';
}
/**
* 같은 요청의 제출 조회가 다 주지 못했나(상한 · 엔진이 줄인 limit · total 보다 적음) — 더 있다. 보이기 경로가 경고를 남길 때 (던지지 않는다).
* 다 받았는지 모를 때(total 없음)는 경고하지 않는다 — 막기 경로는 그때도 닫힌 쪽이다(readHeldEmails)
*/
export function heldQueryFull(
responses: JmapInvocation[],
prefix: string,
): boolean {
try {
const result = findResult(responses, `${prefix}q`, 'EmailSubmission/query');
if (!result.ok) return false;
return queryCoverage(result.args) === 'partial';
} catch {
return false;
}
}
/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */
export interface HeldEmails {
/** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */
readonly ids: ReadonlySet<string>;
/**
* 조회가 맞는 제출을 다 주었다고 응답으로 확인했다(total — queryCoverage) — 계정 전체 조회(heldSubmissionFilter)라면 붙잡힌 메일은 이것이 전부다.
* 모르면 false (닫힌 쪽)
*/
readonly complete: boolean;
}
/** 메일 id 로 물었는데도 다 받지 못했다 — 가릴 수 없다: 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */
export function heldOverLimit(candidates: number): BusinessException {
return new BusinessException('MAIL_001', {
context: { reason: 'held-submissions-over-limit', candidates },
});
}
/** 막기 경로가 판정하지 못했다 — 아무것도 바꾸지 않고 503 MAIL_001 (fail closed) */
function heldCheckFailed(cause: unknown): BusinessException {
const code = cause instanceof BusinessException ? cause.code : 'unknown';
const jmapType =
cause instanceof BusinessException ? cause.context?.jmapType : undefined;
return new BusinessException('MAIL_001', {
context: {
reason: 'held-send-check-failed',
cause: code,
...(jmapType !== undefined ? { jmapType } : {}),
},
});
}
/**
* 같은 요청의 제출 조회 · 읽기(submissionCalls)로 붙잡힌 메일 — **fail closed**: 조회 · 읽기를 엔진이 거절했거나 응답이 없으면 던진다(MAIL_001),
* 보낼 시각을 읽지 못한 기다리는 제출은 붙잡힌 것으로 센다(holdsForGuard), 다 받았는지는 응답으로 정한다(queryCoverage — 모르면 complete 가 아니다).
* 옮기기 · 지우기 경로(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다")가 쓴다 — 보이기 경로는 readHeldSendTimes
*/
export function readHeldEmails(
responses: JmapInvocation[],
prefix: string,
nowMs: number,
): HeldEmails {
let query: JmapQueryResult;
let list: JmapEmailSubmission[];
try {
query = expectResult<JmapQueryResult>(
responses,
`${prefix}q`,
'EmailSubmission/query',
);
list =
expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
`${prefix}g`,
'EmailSubmission/get',
).list ?? [];
} catch (error) {
throw heldCheckFailed(error);
}
const ids = new Set<string>();
for (const submission of list) {
if (submission.emailId && holdsForGuard(submission, nowMs)) {
ids.add(submission.emailId);
}
}
return { ids, complete: queryCoverage(query) === 'all' };
}
/**
* 메일 id 로 붙잡힌 메일을 다시 묻는다 (submissionCalls 의 heldEmailsFilter — 계정 전체 조회와 같은 "지금 뒤" 조건 + 메일 id, readHeldEmails)
* — 다 받지 못한 계정 전체 조회의 보충
*/
export type HeldLookup = (emailIds: readonly string[]) => Promise<HeldEmails>;
/**
* 후보 가운데 붙잡힌 메일 — 같은 요청에 덧붙여 읽은 계정 전체 조회(account)로 가린다. 그 조회가 다 주지 못했으면(complete 가 아님) 후보의 메일 id 로 다시 묻는다(lookup).
* 다시 물은 것도 다 받지 못하면 가릴 수 없다 — MAIL_001 (fail closed)
*/
export async function heldAmong(
account: HeldEmails,
candidates: readonly string[],
lookup: HeldLookup,
): Promise<ReadonlySet<string>> {
const held = new Set(candidates.filter((id) => account.ids.has(id)));
if (account.complete || candidates.length === 0) return held;
const exact = await lookup(candidates);
if (!exact.complete) throw heldOverLimit(candidates.length);
for (const id of candidates) {
if (exact.ids.has(id)) held.add(id);
}
return held;
}
/** 1초 (밀리초) */
const SECOND_MS = 1_000;
/**
* 붙잡아 만든 제출의 기다리는 발송 (발송 응답의 pendingSend) — 보낼 시각은 엔진이 만든 제출에 알려 준 값(없으면 붙잡은 값으로 어림).
* 엔진이 이미 final · canceled 로 답했으면 null
*/
export function submittedPendingSend(
hold: SendHold,
created: {
readonly sendAt?: string | null;
readonly undoStatus?: string | null;
},
submittedAtMs: number,
): PendingSend | null {
if (created.undoStatus && created.undoStatus !== PENDING) return null;
const sendAt =
toUtcIso(created.sendAt) ??
(hold.kind === 'undo'
? new Date(submittedAtMs + hold.seconds * SECOND_MS).toISOString()
: toUtcIso(hold.until));
return sendAt === null ? null : { sendAt, kind: hold.kind };
}
/** 메일의 기다리는 발송 — 예약 표시가 있으면 scheduled, 없으면 보내기 취소를 기다리는 중(undo) */
export function pendingSendOf(
email: Pick<JmapEmail, 'id' | 'keywords'>,
times: ReadonlyMap<string, string>,
): PendingSend | null {
const sendAt = times.get(email.id);
if (sendAt === undefined) return null;
return {
sendAt,
kind:
email.keywords?.[SCHEDULED_SEND_KEYWORD] === true ? 'scheduled' : 'undo',
};
}
@@ -1,4 +1,8 @@
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import type {
BlockedSender,
MailFilterRule,
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
@@ -82,6 +86,7 @@ const representative: FilterScriptSource = {
}),
],
vacation: vacationOn,
blocked: [],
};
/** 스크립트의 줄 — 머리 주석 두 줄(설명 · 원본)은 따로 본다 */
@@ -170,6 +175,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
}),
],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
expect(text).toContain(
'if allof (header :contains "subject" "a\\"b\\\\c 가$나{}") {',
@@ -186,6 +192,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
}),
],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
const uploaded = Buffer.from(built.text, 'utf8').toString('utf8');
@@ -200,6 +207,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
buildFilterScript({
rules: [rule('one')],
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
}).text,
);
expect(lines.slice(2)).toEqual([
@@ -218,6 +226,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
const { text } = buildFilterScript({
rules,
vacation: NEVER_CONFIGURED_VACATION,
blocked: [],
});
const lines = linesOf(text);
expect(lines).toHaveLength(3);
@@ -228,7 +237,9 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
describe('부재중', () => {
const vacationLines = (vacation: MailVacation) =>
linesOf(buildFilterScript({ rules: [], vacation }).text).slice(2);
linesOf(
buildFilterScript({ rules: [], vacation, blocked: [] }).text,
).slice(2);
it('날짜가 없으면 기간 검사 없이 — date 확장도 쓰지 않는다', () => {
const lines = vacationLines({
@@ -275,7 +286,11 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
it('꺼져 있으면 부재중이 없다 — 원본에는 남는다', () => {
const off = { ...vacationOn, isEnabled: false };
const { text } = buildFilterScript({ rules: [rule('a')], vacation: off });
const { text } = buildFilterScript({
rules: [rule('a')],
vacation: off,
blocked: [],
});
expect(text).not.toContain('vacation :mime');
expect(linesOf(text)[2]).toBe('require ["imap4flags"];');
expect(sourceOf(text)?.vacation).toEqual(off);
@@ -289,6 +304,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
subject: '부재 ${x} "중" \\',
textBody: '${unicode:41} \\ "따옴표"',
},
blocked: [],
});
const sieve = linesOf(text).slice(2).join('\r\n');
expect(sieve).not.toContain('${');
@@ -304,6 +320,7 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
subject: '가'.repeat(150),
textBody: '나'.repeat(600),
},
blocked: [],
});
expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8'));
// 내용 1,800바이트 → base64 2,400자 + 줄바꿈 · MIME 머리 · 따옴표
@@ -311,3 +328,105 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
expect(built.longestStringBytes).toBeLessThan(4_096);
});
});
describe('buildFilterScript — 보낸 사람 차단(9단계)', () => {
const at = '2026-10-29T00:00:00.000Z';
const blocked: BlockedSender[] = [
{ address: 'kim@spam.example', blockedAt: at },
{ address: '@junk.example', blockedAt: at },
{ address: 'lee@spam.example', blockedAt: at },
];
const junkMailbox = { id: 'jk1', name: 'Junk Mail' };
it('차단은 스팸 막기 감싸기 밖 맨 앞 — 주소(:all) · 도메인(:domain)을 anyof 로, 스팸메일함에 넣고 멈춘다. 그 뒤가 부재중 · 규칙', () => {
const source: FilterScriptSource = {
rules: [rule('r1')],
vacation: { ...vacationOn, fromDate: null, toDate: null },
blocked,
};
const { text } = buildFilterScript(source, { junkMailbox });
const lines = linesOf(text);
expect(lines[1]).toBe(filterSourceLine(source));
expect(lines.slice(2, 8)).toEqual([
'require ["fileinto", "imap4flags", "mailbox", "mailboxid", "vacation"];',
'if anyof (address :all :is "from" ["kim@spam.example", "lee@spam.example"], address :domain :is "from" ["junk.example"]) {',
' fileinto :mailboxid "jk1" "Junk Mail";',
' stop;',
'}',
'if not header :matches "X-Spam-Score" "spam*" {',
]);
expect(lines[8]).toMatch(/^ {2}vacation :mime /);
// 엔진에 없는 비교 방법(i;unicode-casemap)은 쓰지 않는다 — 기본 비교(ASCII 대소문자 무시)
expect(text).not.toContain(':comparator');
expect(sourceOf(text)?.blocked).toEqual(blocked);
});
it('주소만 · 도메인만이면 그 검사 하나 — anyof 없이', () => {
const only = (list: BlockedSender[]) =>
linesOf(
buildFilterScript(
{ rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: list },
{ junkMailbox },
).text,
).slice(2);
expect(only([blocked[0]])).toEqual([
'require ["fileinto", "mailbox", "mailboxid"];',
'if address :all :is "from" ["kim@spam.example"] {',
' fileinto :mailboxid "jk1" "Junk Mail";',
' stop;',
'}',
'',
]);
expect(only([blocked[1]])[1]).toBe(
'if address :domain :is "from" ["junk.example"] {',
);
});
it('차단만 있으면 스팸 막기 감싸기를 쓰지 않는다 — 켠 규칙 · 부재중이 없을 때', () => {
const { text } = buildFilterScript(
{
rules: [rule('off', { isEnabled: false })],
vacation: NEVER_CONFIGURED_VACATION,
blocked,
},
{ junkMailbox },
);
expect(text).not.toContain('X-Spam-Score');
expect(text).toContain('stop;');
});
it('차단이 없으면 지금과 같다 — 판 1 원본, 차단 검사 · 스팸메일함 없이 (스팸메일함을 받아도)', () => {
const plain = buildFilterScript(representative);
const withJunk = buildFilterScript(representative, { junkMailbox });
expect(withJunk.text).toBe(plain.text);
expect(plain.text).not.toContain('address :');
});
it('차단이 있는데 스팸메일함을 받지 못하면 만들지 않는다 (어댑터가 그 앞에서 MAIL_001)', () => {
const source: FilterScriptSource = {
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
blocked,
};
expect(() => buildFilterScript(source)).toThrow();
expect(() => buildFilterScript(source, { junkMailbox: null })).toThrow();
});
it('스팸메일함 이름의 따옴표 · 역슬래시는 이스케이프한다 — 바이트 수 · 가장 긴 문자열에 차단 목록도 든다', () => {
const many = Array.from({ length: 200 }, (_, i) => ({
address: `sender${i}@spam.example`,
blockedAt: at,
}));
const built = buildFilterScript(
{ rules: [], vacation: NEVER_CONFIGURED_VACATION, blocked: many },
{ junkMailbox: { id: 'jk1', name: '스팸 "메일" \\' } },
);
expect(built.text).toContain(
'fileinto :mailboxid "jk1" "스팸 \\"메일\\" \\\\";',
);
expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8'));
expect(built.bytes).toBeGreaterThan(200 * 'sender0@spam.example'.length);
});
});
+82 -5
View File
@@ -1,4 +1,5 @@
import type {
BlockedSender,
MailFilterCondition,
MailFilterField,
MailFilterRule,
@@ -15,12 +16,19 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* # 설명 한 줄
* # arasystem-source: {원본 JSON — filter-source.ts}
* require [쓰는 확장만];
* if anyof (address :all … :is "from" [차단한 주소들], address :domain … :is "from" [차단한 도메인들]) { ← 9단계 "보낸 사람 차단" — 있을 때만
* fileinto :mailboxid "<스팸메일함 id>" "<스팸메일함 이름>";
* stop;
* }
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
* }
* ```
*
* - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다.
* 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고,
* id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절)
* - 스팸: 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로 막는다 — 엔진의 **분류 표시**를 본다 (SPAM_VERDICT_HEADER).
* `spamtest` 의 점수는 쓰지 않는다: 점수를 정수로 내린 값(10에서 멈춤)이라 기준(엔진 설정 `scoreSpam` — 지금 5)과 상관없는 문턱을
* 우리가 따로 정하게 된다 — 9.70점 스팸이 "10 이상"을 지나 규칙대로 옮겨지고 위조한 보낸 사람에게 부재중 응답까지 나갔다
@@ -70,7 +78,19 @@ const INDENT = ' ';
/** 첫 줄 — 엔진의 관리 화면 등에서 이 스크립트를 보는 사람에게 */
const DESCRIPTION_LINE =
'# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
'# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 · 보낸 사람 차단 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
/** 차단한 메일이 들어갈 스팸메일함 — id 로 가리키고 없으면 이름으로 (어댑터가 엔진의 메일함 목록에서 찾아 넘긴다) */
export interface JunkMailboxTarget {
readonly id: string;
readonly name: string;
}
/** 스크립트를 만들 때 원본 밖에서 정하는 것 */
export interface FilterScriptOptions {
/** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */
readonly junkMailbox?: JunkMailboxTarget | null;
}
/**
* 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인).
@@ -233,11 +253,61 @@ function vacationBlock(
return lines;
}
/** 규칙 · 부재중 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES) */
/**
* 차단 — 머리글 From 의 주소(`:all`)나 도메인(`:domain`)이 차단 목록에 있으면 스팸메일함으로 넣고 멈춘다 (스팸 막기 감싸기 밖, 맨 앞).
* 주소 · 도메인이 한쪽만 있으면 그 검사 하나, 둘 다면 anyof.
* 비교는 엔진 기본(`i;ascii-casemap` — ASCII 대소문자 무시)이라 `:comparator` 를 쓰지 않는다. 엔진의 Sieve 확장 목록에 `comparator-i;unicode-casemap` 이 없다
* (모르는 비교 방법을 받아 주기는 하지만 기대지 않는다 — 2026-10-29 세션의 sieveExtensions 로 확인). 도메인은 ASCII(punycode)로만 받고
* 주소 · 도메인은 소문자로 저장해 두었다 — 머리글 쪽의 대소문자와 상관없이 맞는다 (실제 배달로 확인 — 계약 2절 "보낸 사람 차단")
*/
function blockSection(
writer: ScriptWriter,
blocked: readonly BlockedSender[],
junk: JunkMailboxTarget,
): string[] {
const addresses = blocked
.filter(({ address }) => !address.startsWith('@'))
.map(({ address }) => address);
const domains = blocked
.filter(({ address }) => address.startsWith('@'))
.map(({ address }) => address.slice(1));
const list = (values: readonly string[]) =>
`[${values.map((value) => writer.quote(value)).join(', ')}]`;
const tests = [
...(addresses.length > 0
? [`address :all :is "from" ${list(addresses)}`]
: []),
...(domains.length > 0
? [`address :domain :is "from" ${list(domains)}`]
: []),
];
writer.use('fileinto', 'mailbox', 'mailboxid');
return [
`if ${tests.length === 1 ? tests[0] : `anyof (${tests.join(', ')})`} {`,
indent(
1,
`fileinto :mailboxid ${writer.quote(junk.id)} ${writer.quote(junk.name)};`,
),
indent(1, 'stop;'),
'}',
];
}
/**
* 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES).
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001)
*/
export function buildFilterScript(
source: FilterScriptSource,
options: FilterScriptOptions = {},
): BuiltFilterScript {
const writer = new ScriptWriter();
let blocks: string[] = [];
if (source.blocked.length > 0) {
const junk = options.junkMailbox;
if (!junk) throw new Error('차단 목록을 쓰려면 스팸메일함이 있어야 한다');
blocks = blockSection(writer, source.blocked, junk);
}
const body: string[] = [];
if (source.vacation.isEnabled) {
body.push(...vacationBlock(writer, source.vacation, 1));
@@ -247,8 +317,15 @@ export function buildFilterScript(
}
const lines = [DESCRIPTION_LINE, filterSourceLine(source)];
if (body.length > 0) {
const spamCheck = `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`;
if (blocks.length > 0 || body.length > 0) {
const spamCheck =
body.length > 0
? [
`if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`,
...body,
'}',
]
: [];
const extensions = EXTENSION_ORDER.filter((extension) =>
writer.extensions.has(extension),
);
@@ -258,7 +335,7 @@ export function buildFilterScript(
`require [${extensions.map((extension) => writer.quote(extension)).join(', ')}];`,
);
}
lines.push(spamCheck, ...body, '}');
lines.push(...blocks, ...spamCheck);
}
const text = `${lines.join(CRLF)}${CRLF}`;
@@ -0,0 +1,110 @@
import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
BlockedSenderDto,
MailBlockCreateDto,
MailBlockListDto,
MailBlockRemovedDto,
} from './dto/mail-block.dto';
import { MailBlockService } from './mail-block.service';
import {
MAIL_BLOCKS_ROUTE,
MAX_EMAIL_LENGTH,
MAX_MAIL_BLOCKS,
} from './mail.constants';
/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */
const BLOCK_NOTE =
'차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' +
'이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다',
)
@Controller(MAIL_BLOCKS_ROUTE)
export class MailBlockController {
constructor(private readonly blocks: MailBlockService) {}
@Get()
@ApiOperation({
summary: '차단한 주소',
description: `${BLOCK_NOTE} 최근에 차단한 것부터, ${MAX_MAIL_BLOCKS}개까지. 한 번도 차단하지 않았으면 빈 목록이다.`,
})
@ApiStandardResponse(MailBlockListDto, {
description: '차단 목록 · 동작하는지',
})
list(@CurrentUser() user: AuthenticatedUser) {
return this.blocks.list(user);
}
@Post()
@ApiOperation({
summary: '보낸 사람 차단 (주소 또는 @도메인)',
description:
`${BLOCK_NOTE} 이미 차단한 주소면 바꾸지 않고 200 으로 그 항목을 돌려준다(새로 차단하면 201). ` +
`내 주소 · 사내 도메인(그 하위 포함) · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과는 400 VAL_001.`,
})
@ApiStandardResponse(BlockedSenderDto, {
status: HttpStatus.CREATED,
description: '차단한 항목 (이미 있었으면 200 으로 그 항목)',
})
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · ${MAX_MAIL_BLOCKS}개 초과 · 모르는 필드 ` +
'/ MAIL_013 필터 · 부재중과 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
async add(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailBlockCreateDto,
@Res({ passthrough: true }) res: Response,
): Promise<BlockedSenderDto> {
const { sender, created } = await this.blocks.add(user, dto);
// 새로 차단했으면 201(POST 의 기본), 이미 있던 주소면 200
if (!created) res.status(HttpStatus.OK);
return sender;
}
@Delete(':address')
@ApiOperation({
summary: '차단 풀기',
description:
'경로의 주소 · @도메인은 URL 인코딩한다(대소문자는 가리지 않는다). 이미 받은 메일은 옮기지 않는다.',
})
@ApiStandardResponse(MailBlockRemovedDto, {
description: '푼 주소 · 도메인',
})
@ApiErrorResponse('RES_001', '차단한 적 없는 주소 · 형식이 틀린 주소')
remove(
@CurrentUser() user: AuthenticatedUser,
@Param('address') address: string,
) {
return this.blocks.remove(user, address);
}
}
@@ -0,0 +1,161 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MailBlockService } from './mail-block.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { BlockedSender } from './mail.types';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const settings = {
domain: 'arasystem.test',
} as unknown as MailSettings;
function fakeProvider() {
return {
getMailBlocks: jest.fn().mockResolvedValue({ senders: [], isActive: true }),
addMailBlock: jest.fn((_user: string, sender: BlockedSender) =>
Promise.resolve({ sender, created: true }),
),
removeMailBlock: jest.fn().mockResolvedValue(undefined),
};
}
async function failureOf(
run: () => Promise<unknown>,
): Promise<{ code?: string; reason?: unknown }> {
try {
await run();
} catch (error) {
return error instanceof BusinessException
? { code: error.code, reason: error.context?.reason }
: { code: 'NOT_BUSINESS' };
}
return {};
}
describe('MailBlockService — 보낸 사람 차단 (9단계)', () => {
let provider: ReturnType<typeof fakeProvider>;
let service: MailBlockService;
let logSpy: jest.SpyInstance;
beforeEach(() => {
provider = fakeProvider();
service = new MailBlockService(
provider as unknown as MailProvider,
settings,
);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
const logged = () =>
logSpy.mock.calls.map(([line]) => String(line)).join('\n');
it('차단 — 앞뒤 공백을 빼고 소문자로, 차단한 시각은 서버 시계(UTC). 로그의 주소는 가린다', async () => {
const result = await service.add(user, { address: ' Kim@Spam.Example ' });
expect(result.created).toBe(true);
expect(result.sender.address).toBe('kim@spam.example');
expect(result.sender.blockedAt).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
);
expect(provider.addMailBlock).toHaveBeenCalledWith(user.email, {
address: 'kim@spam.example',
blockedAt: result.sender.blockedAt,
});
expect(logged()).not.toContain('kim@spam.example');
expect(logged()).not.toContain(user.email);
});
it('도메인 차단의 로그는 도메인을 그대로 — 이미 있던 주소면 로그를 남기지 않는다', async () => {
await service.add(user, { address: '@Ads.Example.org' });
expect(logged()).toContain('도메인=@ads.example.org');
logSpy.mockClear();
provider.addMailBlock.mockResolvedValueOnce({
sender: {
address: '@ads.example.org',
blockedAt: '2026-10-28T00:00:00.000Z',
},
created: false,
});
await expect(
service.add(user, { address: '@ads.example.org' }),
).resolves.toMatchObject({ created: false });
expect(logSpy).not.toHaveBeenCalled();
});
it.each([
[
'내 주소(대소문자 무시)',
'ARA.KIM@arasystem.test',
'mail-block-own-address',
],
['사내 도메인', '@arasystem.test', 'mail-block-internal-domain'],
['사내 하위 도메인', '@mail.AraSystem.test', 'mail-block-internal-domain'],
[
'사내 직원 주소',
'doyun.lee@arasystem.test',
'mail-block-internal-domain',
],
[
'여러 사람이 쓰는 메일 서비스',
'@gmail.com',
'mail-block-public-mail-domain',
],
['모양이 아님', 'not an address', 'malformed-mail-block'],
])(
'막을 수 없는 대상은 VAL_001 — %s (엔진에 묻지 않는다)',
async (_label, address, reason) => {
expect(await failureOf(() => service.add(user, { address }))).toEqual({
code: 'VAL_001',
reason,
});
expect(provider.addMailBlock).not.toHaveBeenCalled();
},
);
it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소 · 여러 사람이 쓰는 서비스는 그대로 막는다', async () => {
const unconfigured = new MailBlockService(
provider as unknown as MailProvider,
{ domain: null } as unknown as MailSettings,
);
await expect(
unconfigured.add(user, { address: '@arasystem.test' }),
).resolves.toMatchObject({ created: true });
expect(
await failureOf(() => unconfigured.add(user, { address: user.email })),
).toMatchObject({ code: 'VAL_001' });
expect(
await failureOf(() => unconfigured.add(user, { address: '@naver.com' })),
).toMatchObject({ code: 'VAL_001' });
});
it('차단 풀기 — 경로의 주소를 차단 모양으로 맞춰 넘기고 그 값을 돌려준다. 모양이 틀리면 RES_001', async () => {
await expect(service.remove(user, ' LEE@Spam.Example ')).resolves.toEqual({
address: 'lee@spam.example',
});
expect(provider.removeMailBlock).toHaveBeenCalledWith(
user.email,
'lee@spam.example',
);
expect(await failureOf(() => service.remove(user, 'a b'))).toEqual({
code: 'RES_001',
reason: 'malformed-mail-block',
});
expect(provider.removeMailBlock).toHaveBeenCalledTimes(1);
});
it('목록은 포트가 준 그대로', async () => {
await expect(service.list(user)).resolves.toEqual({
senders: [],
isActive: true,
});
expect(provider.getMailBlocks).toHaveBeenCalledWith(user.email);
});
});
@@ -0,0 +1,91 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { MailBlockCreateDto } from './dto/mail-block.dto';
import {
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)
* - 500개 · 엔진 규칙 크기(MAIL_013)는 어댑터가 저장하는 그 판으로 본다
* - 차단한 시각은 서버 시계(UTC)
*/
@Injectable()
export class MailBlockService {
private readonly logger = new Logger(MailBlockService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
) {}
list(user: AuthenticatedUser): Promise<MailBlockList> {
return this.provider.getMailBlocks(user.email);
}
/** 차단을 더한다 — 이미 있으면 그 항목(created: false — 컨트롤러가 200 으로 답한다) */
async add(
user: AuthenticatedUser,
dto: MailBlockCreateDto,
): Promise<MailBlockAdded> {
const target = parseBlockTarget(dto.address);
if (target === null) {
throw new BusinessException('VAL_001', {
context: { reason: 'malformed-mail-block' },
});
}
const problem = findBlockProblem(target, {
self: user.email,
internalDomain: this.settings.domain,
});
if (problem !== null) {
throw new BusinessException('VAL_001', {
context: { reason: `mail-block-${problem}`, kind: target.kind },
});
}
const result = await this.provider.addMailBlock(user.email, {
address: target.value,
blockedAt: new Date().toISOString(),
});
if (result.created) {
this.logger.log(
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
);
}
return result;
}
/** 차단을 푼다 — 경로의 주소(URL 인코딩을 푼 값). 모양이 틀리거나 없는 주소면 RES_001 */
async remove(
user: AuthenticatedUser,
raw: string,
): Promise<{ address: string }> {
const target = parseBlockTarget(raw);
if (target === null) {
throw new BusinessException('RES_001', {
context: { reason: 'malformed-mail-block' },
});
}
await this.provider.removeMailBlock(user.email, target.value);
this.logger.log(
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
);
return { address: target.value };
}
}
@@ -0,0 +1,172 @@
import { PUBLIC_MAIL_DOMAINS } from './mail.constants';
import {
findBlockProblem,
isBlockableDomain,
parseBlockTarget,
} from './mail-blocks';
const CONTEXT = {
self: 'Ara.Kim@arasystem.test',
internalDomain: 'arasystem.test',
};
describe('parseBlockTarget — 차단할 주소 · @도메인의 모양', () => {
it.each([
['kim@spam.example', { kind: 'address', value: 'kim@spam.example' }],
[' Kim@SPAM.Example ', { kind: 'address', value: 'kim@spam.example' }],
[
'news+promo@mail.shop.example',
{ kind: 'address', value: 'news+promo@mail.shop.example' },
],
// dot-atom 의 atext 기호는 모두 받는다 (프론트 isStrictEmail 과 같다)
[
"o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example",
{
kind: 'address',
value: "o'brien.x_y-z!#$%&*/=?^`{|}~@spam.example",
},
],
['@spam.example', { kind: 'domain', value: '@spam.example' }],
['@SPAM.Example', { kind: 'domain', value: '@spam.example' }],
[
'@mail.sub-domain.example',
{ kind: 'domain', value: '@mail.sub-domain.example' },
],
[
'@xn--vg1b002a.example',
{ kind: 'domain', value: '@xn--vg1b002a.example' },
],
])('%j → %j', (raw, target) => {
expect(parseBlockTarget(raw)).toEqual(target);
});
it.each([
['빈 글', ''],
['@ 뒤가 없음', '@'],
['점이 없는 도메인', '@localhost'],
['IP 주소', '@192.168.0.1'],
['IPv6', '@::1'],
['숫자만인 최상위', '@spam.123'],
['- 로 시작하는 낱말', '@-spam.example'],
['- 로 끝나는 낱말', '@spam-.example'],
['빈 낱말', '@spam..example'],
['밑줄', '@spam_x.example'],
['한글 도메인(xn-- 로 쓴다)', '@뉴스.example'],
['도메인 끝 점', '@spam.example.'],
['너무 긴 낱말', `@${'a'.repeat(64)}.example`],
['너무 긴 도메인', `@${'a.'.repeat(130)}example`],
['주소가 아님', 'kim'],
['@ 가 둘', 'kim@spam@example.com'],
['변수 표시', 'a${x}@spam.example'],
// 따옴표 앞부분 — IsEmail 은 받지만 Sieve 글에 줄바꿈 · 제어 문자가 들어가고 엔진은 따옴표를 뗀 주소와 견준다 (2026-10-29 리뷰)
['따옴표 앞부분 안의 줄바꿈', '"a\nb"@spam.example'],
['따옴표 앞부분 안의 CR LF', '"a\r\nb"@spam.example'],
['따옴표 앞부분 안의 제어 문자', '"a\u0001b"@spam.example'],
['따옴표 앞부분 안의 빈칸', '"a b"@spam.example'],
['따옴표 앞부분 안의 탭', '"a\tb"@spam.example'],
['따옴표 앞부분', '"ab"@spam.example'],
['@ 앞의 점으로 시작', '.kim@spam.example'],
['@ 앞의 점으로 끝남', 'kim.@spam.example'],
['@ 앞의 연달은 점', 'k..im@spam.example'],
['@ 앞 65자', `${'a'.repeat(65)}@spam.example`],
['@ 앞의 한글', '김@spam.example'],
[
'254자 넘음',
`${'a'.repeat(64)}@${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(63)}.com`,
],
])('%s(%j)는 차단 대상이 아니다', (_label, raw) => {
expect(parseBlockTarget(raw)).toBeNull();
});
it('도메인 모양은 마지막 낱말이 숫자만이면(IP 꼴) 받지 않는다', () => {
expect(isBlockableDomain('spam.example')).toBe(true);
expect(isBlockableDomain('1.2.3.4')).toBe(false);
expect(isBlockableDomain('spam.4')).toBe(false);
expect(isBlockableDomain('4spam.example')).toBe(true);
});
});
describe('findBlockProblem — 막을 수 없는 대상', () => {
const target = (raw: string) => {
const parsed = parseBlockTarget(raw);
if (!parsed) throw new Error(`모양이 틀림: ${raw}`);
return parsed;
};
it('내 주소 · 사내 도메인(그 하위 포함)의 주소와 도메인은 막을 수 없다', () => {
expect(findBlockProblem(target('ara.kim@arasystem.test'), CONTEXT)).toBe(
'own-address',
);
for (const raw of [
'doyun.lee@arasystem.test',
'noreply@mail.arasystem.test',
'@arasystem.test',
'@ARASYSTEM.test',
'@hr.arasystem.test',
]) {
expect(findBlockProblem(target(raw), CONTEXT)).toBe('internal-domain');
}
});
it('사내 도메인과 끝만 같은 다른 도메인은 막을 수 있다 (점으로 나뉜 하위만 사내)', () => {
expect(findBlockProblem(target('@notarasystem.test'), CONTEXT)).toBeNull();
expect(findBlockProblem(target('kim@xarasystem.test'), CONTEXT)).toBeNull();
});
it('여러 사람이 쓰는 메일 서비스 목록 — 글자 · 차례 그대로 (짝: frontend src/shared/constants/mail-block.ts 의 PUBLIC_MAIL_DOMAINS)', () => {
expect(PUBLIC_MAIL_DOMAINS).toEqual([
'gmail.com',
'googlemail.com',
'naver.com',
'daum.net',
'hanmail.net',
'kakao.com',
'nate.com',
'empas.com',
'hanmir.com',
'korea.com',
'dreamwiz.com',
'outlook.com',
'hotmail.com',
'live.com',
'msn.com',
'yahoo.com',
'yahoo.co.kr',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'proton.me',
'protonmail.com',
'gmx.com',
'gmx.net',
'mail.com',
'zoho.com',
'yandex.com',
'qq.com',
'163.com',
]);
});
it('여러 사람이 쓰는 메일 서비스는 도메인으로는 막을 수 없고 주소로는 된다 (목록 그대로)', () => {
expect(PUBLIC_MAIL_DOMAINS).toHaveLength(30);
for (const domain of PUBLIC_MAIL_DOMAINS) {
expect(findBlockProblem(target(`@${domain}`), CONTEXT)).toBe(
'public-mail-domain',
);
expect(findBlockProblem(target(`spammer@${domain}`), CONTEXT)).toBeNull();
}
// 하위 도메인은 목록에 없으면 막을 수 있다
expect(findBlockProblem(target('@mail.naver.com'), CONTEXT)).toBeNull();
});
it('사내 도메인을 모르면(엔진 설정 없음) 사내 검사는 건너뛴다 — 내 주소는 그대로 막는다', () => {
const unconfigured = { self: CONTEXT.self, internalDomain: null };
expect(
findBlockProblem(target('@arasystem.test'), unconfigured),
).toBeNull();
expect(
findBlockProblem(target('ara.kim@arasystem.test'), unconfigured),
).toBe('own-address');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { isEmail } from 'class-validator';
import { isIP } from 'net';
import {
MAX_EMAIL_LENGTH,
PUBLIC_MAIL_DOMAINS,
SIEVE_VARIABLE_MARKER,
} from './mail.constants';
/**
* 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 메일 엔진과 무관한 순수 규칙.
* 요청 검증(dto/mail-block.dto.ts)이 모양을, 서비스가 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스를 이것으로 본다.
* 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 모양 규칙으로 본다 — 어긋나면 깨진 원본이다.
*
* - 주소: 발송의 주소와 같은 규칙(IsEmail · 254자)에 더해 @ 앞은 dot-atom 만(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~ 덩어리를 점으로 —
* 빈칸 · 제어 문자 · 따옴표 없음, 64자) — 소문자로 맞춰 둔다. 따옴표 앞부분(`"a b"@…`)은 IsEmail 이 받지만 Sieve 글에 줄바꿈이 그대로 들어가고
* 엔진은 따옴표를 뗀 주소와 견주므로 결코 맞지 않는다(2026-10-29 리뷰). 프론트의 isStrictEmail(shared/utils/mail-address.ts)과 같은 @ 앞 규칙이다
* - 도메인: `@` + 호스트 이름 — 점이 하나 이상, 253자까지, 낱말은 영문 · 숫자 · `-`(앞뒤는 아님) 1~63자, 마지막 낱말이 숫자만은 아님(IP 주소 아님)
* — 국제화 도메인은 `xn--` 꼴로 쓴다(머리글에 오는 모양). 그 도메인에서 오는 메일 모두이고 하위 도메인은 따로다
* - `${` 는 받지 않는다 — 엔진이 규칙 글 안의 `${…}` 를 늘 변수로 펼쳐 다른 주소를 막게 된다 (계약 2절 "만드는 Sieve")
*/
/** 차단 대상 — 주소 · 도메인 (value 는 소문자, 도메인은 `@` 를 붙인 모양 — 계약의 BlockedSender.address) */
export interface BlockTarget {
readonly kind: 'address' | 'domain';
readonly value: string;
}
/** 서비스가 막는 까닭 — 로그용 */
export type BlockProblem =
| 'own-address'
| 'internal-domain'
| 'public-mail-domain';
/** 호스트 이름 최대 길이 (RFC 1035) */
const MAX_HOSTNAME_LENGTH = 253;
/** 호스트 이름 낱말 하나 — 영문 · 숫자 · '-', 앞뒤는 '-' 가 아님, 1~63자 */
const HOSTNAME_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
/** 숫자만인 낱말 — 마지막 낱말(최상위 도메인)이 이렇다면 IP 주소 꼴이다 */
const NUMERIC_LABEL = /^\d+$/;
/**
* 주소의 @ 앞 — RFC 5322 dot-atom (atext 덩어리를 점으로 잇는다 — 앞 · 뒤 · 연달은 점 없음, 64자). 덩어리 안에 점이 없어 입력 길이에 비례한다.
* 짝: frontend `src/shared/utils/mail-address.ts` 의 `DOT_ATOM_LOCAL_PART` · `LOCAL_PART_MAX_LENGTH` — 한쪽을 고치면 둘 다
*/
const DOT_ATOM_LOCAL_PART =
/^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
const LOCAL_PART_MAX_LENGTH = 64;
const PUBLIC_DOMAINS: ReadonlySet<string> = new Set(PUBLIC_MAIL_DOMAINS);
/** 차단할 수 있는 도메인 모양인가 (소문자로 맞춘 값, `@` 없이) */
export function isBlockableDomain(domain: string): boolean {
if (domain.length === 0 || domain.length > MAX_HOSTNAME_LENGTH) return false;
if (isIP(domain) !== 0) return false;
const labels = domain.split('.');
if (labels.length < 2) return false;
if (!labels.every((label) => HOSTNAME_LABEL.test(label))) return false;
return !NUMERIC_LABEL.test(labels[labels.length - 1]);
}
/** 차단할 수 있는 주소의 @ 앞인가 — dot-atom · 64자 (빈칸 · 제어 문자 · 따옴표 없음) */
function isDotAtomLocalPart(local: string): boolean {
return (
local.length > 0 &&
local.length <= LOCAL_PART_MAX_LENGTH &&
DOT_ATOM_LOCAL_PART.test(local)
);
}
/**
* 주소 · `@도메인` 글 → 차단 대상 (앞뒤 공백을 빼고 소문자로) — 모양이 틀리면 null.
* 요청 검증 · 경로(DELETE) · 저장해 둔 원본 읽기(jmap/filter-source.ts 의 toBlocked)가 같이 쓴다 — 그래서 원본에 따옴표 앞부분이 있으면 깨진 원본이다
*/
export function parseBlockTarget(raw: string): BlockTarget | null {
const value = raw.trim().toLowerCase();
if (value.length === 0 || value.length > MAX_EMAIL_LENGTH) return null;
if (value.includes(SIEVE_VARIABLE_MARKER)) return null;
if (value.startsWith('@')) {
return isBlockableDomain(value.slice(1)) ? { kind: 'domain', value } : null;
}
const at = value.lastIndexOf('@');
if (at <= 0 || !isDotAtomLocalPart(value.slice(0, at))) return null;
return isEmail(value) ? { kind: 'address', value } : null;
}
/** 주소 · 도메인 대상의 도메인 (`@` 없이) */
export function blockTargetDomain(target: BlockTarget): string {
return target.value.slice(target.value.lastIndexOf('@') + 1);
}
/** 사내 도메인이거나 그 하위 도메인인가 */
function isInternalDomain(domain: string, internal: string): boolean {
return domain === internal || domain.endsWith(`.${internal}`);
}
/**
* 막을 수 없는 대상 — 내 주소, 사내 도메인(MAIL_DOMAIN 과 그 하위)의 주소 · 도메인, 여러 사람이 쓰는 메일 서비스의 도메인(주소로는 된다).
* 사내 도메인을 모르면(엔진 설정 없음 — 비운영) 사내 검사는 건너뛴다
*/
export function findBlockProblem(
target: BlockTarget,
context: { readonly self: string; readonly internalDomain: string | null },
): BlockProblem | null {
const domain = blockTargetDomain(target);
if (
target.kind === 'address' &&
target.value === context.self.trim().toLowerCase()
) {
return 'own-address';
}
if (
context.internalDomain !== null &&
isInternalDomain(domain, context.internalDomain)
) {
return 'internal-domain';
}
if (target.kind === 'domain' && PUBLIC_DOMAINS.has(domain)) {
return 'public-mail-domain';
}
return null;
}
@@ -11,6 +11,12 @@ export const MAIL_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/;
/** 받는 사람(to + cc + bcc) 합계 상한 */
export const MAX_RECIPIENTS = 100;
/**
* 발송 때 메일 서버가 받지 않는 주소(모르는 최상위 도메인 등 — 계약 2절 "받는 사람별 결과")를 빼며 같은 메일을 다시 내는 차례의 상한 (9단계 리뷰 2).
* 서버는 한 번에 주소 하나만 알려 주므로 처음 제출 + 이만큼 — 이보다 하나 많은 주소까지 뺀다. 더 빼야 하면 보내지 않는다(400 VAL_001)
*/
export const MAX_ENVELOPE_RESUBMITS = 20;
/** 제목 최대 길이 */
export const MAX_SUBJECT_LENGTH = 255;
@@ -129,6 +135,70 @@ export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
*/
export const SIEVE_VARIABLE_MARKER = '${';
/**
* 붙잡아 둔 발송(보내기 취소 · 예약) 라우트 경로 (`/api/scheduled-messages`) — 계약 2절 "보내기 취소 · 예약 발송" (9단계)
*/
export const SCHEDULED_MESSAGES_ROUTE = 'scheduled-messages';
/**
* 예약 발송 시각의 범위 — 지금(서버 시계)부터 5분 뒤 이상 · 7일 안 (계약 2절 "보내기 취소 · 예약 발송"). 밖이면 400 VAL_001.
* 7일은 엔진 한도다(`x:MtaExtensions` 의 futureRelease — 넘기면 엔진이 forbiddenMailFrom 으로 거절, 2026-10-29 확인).
* 우리가 본 뒤 엔진이 보는 때까지 남은 시간은 줄기만 하므로 이 범위면 엔진 한도 안이다. 붙잡은 메일의 대기열 만료는 풀리는 시각 + 3일이다
*/
export const SCHEDULED_SEND_MIN_LEAD_MS = 5 * 60_000;
export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000;
/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */
export const MAIL_BLOCKS_ROUTE = 'mail-blocks';
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */
export const MAX_MAIL_BLOCKS = 500;
/**
* 여러 사람이 쓰는 메일 서비스 — 도메인(`@gmail.com`)으로는 막을 수 없다(주소로는 된다). 계약 2절 "보낸 사람 차단".
* ⚠️ 짝: frontend `src/shared/constants/mail-block.ts` 의 `PUBLIC_MAIL_DOMAINS` — 클라이언트도 같은 목록을 먼저 본다.
* 한쪽을 고치면 둘 다 (차례도 같게 둔다 — mail-blocks.spec.ts 가 글자 · 차례를 그대로 본다)
*/
export const PUBLIC_MAIL_DOMAINS: readonly string[] = [
'gmail.com',
'googlemail.com',
'naver.com',
'daum.net',
'hanmail.net',
'kakao.com',
'nate.com',
'empas.com',
'hanmir.com',
'korea.com',
'dreamwiz.com',
'outlook.com',
'hotmail.com',
'live.com',
'msn.com',
'yahoo.com',
'yahoo.co.kr',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'proton.me',
'protonmail.com',
'gmx.com',
'gmx.net',
'mail.com',
'zoho.com',
'yandex.com',
'qq.com',
'163.com',
];
/**
* 외부 이미지 중계 라우트 경로 (`/api/mail-images/{token}`) — 계약 2절 "외부 이미지 보기" (9단계).
* 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH`(`/api/mail-images/` — 본문 틀의 CSP · 정화가 남기는 주소) —
* 경로를 바꾸면 둘 다. 서버가 넣는 주소의 앞부분은 mail.service.ts 의 `MAIL_IMAGE_PATH_PREFIX`
*/
export const MAIL_IMAGES_ROUTE = 'mail-images';
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
+32
View File
@@ -9,12 +9,27 @@ import { BusinessException } from '@/common/errors/business.exception';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
import { MailImageController } from './images/mail-image.controller';
import {
MAIL_IMAGE_RESOLVER,
MAIL_IMAGE_TRANSPORT,
MailImageFetcher,
} from './images/mail-image-fetcher';
import { MailImageProxyService } from './images/mail-image-proxy.service';
import { MailImageTokens } from './images/mail-image-token';
import {
DnsMailImageResolver,
NodeMailImageTransport,
} from './images/node-image-transport';
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 { MailBlockService } from './mail-block.service';
import { MailEventsController } from './mail-events.controller';
import {
MailFilterController,
@@ -39,6 +54,8 @@ import { MessageDeletionController } from './message-deletion.controller';
import { MessageController } from './message.controller';
import { RecipientSuggestionController } from './recipient-suggestion.controller';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import { ScheduledMessageController } from './scheduled-message.controller';
import { ScheduledMessagesService } from './scheduled-messages.service';
function readMailSettings(config: ConfigService): MailSettings {
return buildMailSettings((key) => config.get<string>(key));
@@ -97,6 +114,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
ContactsModule,
// 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천")
MailDirectoryModule,
// 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계)
MailUserSettingsModule,
],
controllers: [
MailboxController,
@@ -106,13 +125,26 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailVacationController,
MailFilterController,
MailFilterRunController,
MailBlockController,
MessageController,
MessageDeletionController,
ScheduledMessageController,
MailAttachmentController,
MailImageController,
DraftController,
RecipientSuggestionController,
],
providers: [
// 보낸 사람 차단 (9단계) — 저장은 필터 · 부재중과 같은 엔진 규칙(포트)
MailBlockService,
// 붙잡아 둔 발송 — 예약한 메일 목록 · 취소 (9단계)
ScheduledMessagesService,
// 외부 이미지 중계 (9단계) — 표(서버가 뜰 때 만든 키로 암호화) · 바깥 받기(SSRF 막기) · 직원마다 동시 수
MailImageTokens,
MailImageFetcher,
MailImageProxyService,
{ provide: MAIL_IMAGE_RESOLVER, useClass: DnsMailImageResolver },
{ provide: MAIL_IMAGE_TRANSPORT, useClass: NodeMailImageTransport },
// 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다
JmapFilterScriptStore,
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
+53 -6
View File
@@ -1,10 +1,13 @@
import type {
AttachmentContent,
BlockedSender,
Correspondence,
DraftContent,
DraftSource,
MailAccountProfile,
MailAttachment,
MailBlockAdded,
MailBlockList,
Mailbox,
MailboxBulkResult,
MailboxCreate,
@@ -96,6 +99,7 @@ export interface MailProvider {
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
* 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015
*/
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
@@ -105,6 +109,7 @@ export interface MailProvider {
/**
* 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다.
* 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
* 붙잡힌 메일(9단계)은 지우지 않는다 — 남은 것이 그것뿐이고 이 요청에서 지운 것이 없으면 MAIL_015
*/
emptyMailbox(user: string, id: string): Promise<MailboxBulkResult>;
@@ -123,6 +128,21 @@ export interface MailProvider {
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
/**
* 차단한 보낸 사람 (계약 2절 "보낸 사람 차단" — 9단계) — 최근에 차단한 것부터 · 동작하는지(isActive — 메일 필터의 것과 같은 값).
* 저장 자리는 메일 필터 · 부재중과 같은 엔진 규칙이다
*/
getMailBlocks(user: string): Promise<MailBlockList>;
/**
* 차단을 더한다 — 이미 있으면 바꾸지 않고 그 항목(created: false). 필터 · 부재중은 그대로 두고 필터의 지문(version)을 보지 않는다.
* 이미 MAX_MAIL_BLOCKS(500)개면 VAL_001, 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 대상의 모양 · 막을 수 있는지는 서비스가 검사한 값이다
*/
addMailBlock(user: string, sender: BlockedSender): Promise<MailBlockAdded>;
/** 차단을 푼다 — 없는 주소면 RES_001. 필터 · 부재중은 그대로 */
removeMailBlock(user: string, address: string): Promise<void>;
/**
* 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 —
@@ -182,9 +202,15 @@ export interface MailProvider {
*/
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
/** 조회만 한다 — 읽음 처리하지 않는다 */
/**
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
*/
getMessage(user: string, id: string): Promise<MessageDetailSource>;
/**
* 한 통 바꾸기 — 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)의 이동 · 스팸 표시(mailboxId · isJunk)는 MAIL_015,
* 붙잡혔는지 확인하지 못하면 MAIL_001 (어느 쪽이든 아무것도 바꾸지 않는다). 읽음 · 중요 표시는 된다
*/
updateMessage(
user: string,
id: string,
@@ -193,7 +219,8 @@ export interface MailProvider {
/**
* 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로.
* 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001.
* 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001. 이동 · 스팸 표시는 붙잡힌 메일(9단계)에 하지 않는다 — 그 id 도 failedIds
* (붙잡혔는지 확인하지 못하면 아무것도 바꾸지 않고 MAIL_001)
*/
updateMessages(
user: string,
@@ -201,13 +228,13 @@ export interface MailProvider {
update: MessageUpdate,
): Promise<MessagesPatchResult>;
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 — 붙잡힌 메일(9단계)은 MAIL_015 (아무것도 바꾸지 않는다) */
deleteMessage(user: string, id: string): Promise<void>;
/**
* 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다.
* 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다).
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001.
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001. 붙잡힌 메일(9단계)은 건너뛴다(skippedIds)
*/
deleteFromTrash(
user: string,
@@ -215,23 +242,43 @@ export interface MailProvider {
): Promise<MessageDeletionResult>;
/**
* @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다)
* @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다),
* 붙잡아 둔 발송이면 그 정보(pendingSend — 9단계)
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
* 받는 사람을 엔진이 모두 받지 않았으면 보내지 않은 것으로 다룬다 — 이 요청이 만든 보낸 사본을 지우고
* MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음)
* MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음).
* `message.hold` 가 있으면 엔진이 그만큼 붙잡았다가 보낸다(보내기 취소 · 예약) — 예약은 사본에 예약 표시를 붙인다.
* 엔진이 붙잡을 시각을 받지 않으면(한도) VAL_001
*/
sendMessage(user: string, message: OutgoingMessage): Promise<SentMessage>;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
* 보내지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다).
* 취소한 발송(제출이 모두 canceled — 9단계)은 지우지 않고 canceled 로 돌려준다 (서비스가 MAIL_005 로 답한다)
*/
findSentMessage(
user: string,
messageId: string,
): Promise<SentMessageRecord | null>;
/**
* 예약한 메일 (계약 2절 "보내기 취소 · 예약 발송" — GET /api/scheduled-messages) — 예약 표시가 있고 아직 보내지 않은 것만,
* 보낼 시각 차례(같으면 id 차례). 보내기 취소를 기다리는 메일은 들지 않는다
*/
listScheduledMessages(user: string): Promise<MessageSummary[]>;
/**
* 붙잡은 발송을 취소하고 그 메일을 임시보관함의 초안으로 되돌린다 (보내기 취소 · 예약 취소) — `id` 는 보낸 사본의 메일 id.
* 취소한 **뒤에** 다른 요청으로 옮긴다(같은 요청이면 취소가 실패해도 옮겨진다). 없는 메일 RES_001,
* 이미 보냈거나(엔진이 풀었음) 붙잡은 적이 없는 메일이면 MAIL_014(아무것도 바꾸지 않는다), 엔진이 취소를 확인해 주지 않으면 MAIL_001(옮기지 않는다),
* 취소는 됐는데 옮기지 못하면(한 번 더 한 뒤) MAIL_001.
* 이미 취소한 발송(제출이 모두 canceled)이면 성공 — 되돌리기를 마쳤으면(취소한 발송 표시) 어느 메일함에 있든 옮기지 않고, 마치지 못했으면 되돌리기만 다시 한다
* (같은 요청을 다시 보내도 같은 결과)
*/
cancelPendingSend(user: string, id: string): Promise<void>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
+423 -36
View File
@@ -5,10 +5,15 @@ import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
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 { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import { MailImageTokens } from './images/mail-image-token';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
@@ -25,6 +30,7 @@ import type {
MailFilterRule,
MessageDetailSource,
SentMessage,
SentMessageRecord,
} from './mail.types';
import type { RecipientSuggestionService } from './recipient-suggestions.service';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
@@ -61,7 +67,7 @@ function mockOf<K extends keyof MailProvider>(): ProviderMocks[K] {
}
/** 대역 엔진의 발송 결과 — 모두 받았다 */
const SENT = { id: 'sent-1', rejectedRecipients: [] };
const SENT = { id: 'sent-1', rejectedRecipients: [], pendingSend: null };
function fakeProvider(): ProviderMocks {
const sendMessage = mockOf<'sendMessage'>();
@@ -110,6 +116,11 @@ function fakeProvider(): ProviderMocks {
getDraft: mockOf<'getDraft'>(),
deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined),
listCorrespondents: mockOf<'listCorrespondents'>(),
getMailBlocks: mockOf<'getMailBlocks'>(),
addMailBlock: mockOf<'addMailBlock'>(),
removeMailBlock: mockOf<'removeMailBlock'>(),
listScheduledMessages: mockOf<'listScheduledMessages'>(),
cancelPendingSend: mockOf<'cancelPendingSend'>(),
};
}
@@ -172,6 +183,18 @@ async function failureOf(
return {};
}
/** 같은 키로 이미 보낸 메일의 기록 — 취소하지 않았고 붙잡혀 있지 않다 */
const sentRecord = (
fingerprint: string | null,
overrides: Partial<SentMessageRecord> = {},
): SentMessageRecord => ({
id: 'sent-1',
fingerprint,
canceled: false,
pendingSend: null,
...overrides,
});
const source = (
overrides: Partial<MessageDetailSource> = {},
): MessageDetailSource => ({
@@ -199,6 +222,8 @@ const source = (
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
pendingSend: null,
isInJunkMailbox: false,
...overrides,
});
@@ -230,13 +255,67 @@ const draftSource = (overrides: Partial<DraftSource> = {}): DraftSource => ({
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-27T01:00:00.000Z',
isCanceledSend: false,
...overrides,
});
/** 9단계 — 보내기 · 읽기 설정과 외부 이미지 "주소록 · 사내 직원" 판정의 대역 */
interface NinthStepMocks {
userSettings: {
get: jest.Mock<Promise<MailUserSettings>, [string]>;
};
contacts: { hasEmail: jest.Mock<Promise<boolean>, [string, string]> };
directory: { hasEmail: jest.Mock<Promise<boolean>, [string]> };
imageTokens: MailImageTokens;
}
/**
* 기존 발송 테스트는 붙잡지 않는 발송(보내기 취소 0초)을 본다 — 보내기 취소 · 예약은 따로 본다.
* 외부 이미지는 처음 값('ask')
*/
function fakeNinthStep(): NinthStepMocks {
return {
userSettings: {
get: jest
.fn<Promise<MailUserSettings>, [string]>()
.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
},
contacts: {
hasEmail: jest
.fn<Promise<boolean>, [string, string]>()
.mockResolvedValue(false),
},
directory: {
hasEmail: jest.fn<Promise<boolean>, [string]>().mockResolvedValue(false),
},
imageTokens: new MailImageTokens(),
};
}
function createService(
provider: ProviderMocks,
mailSettings: MailSettings,
signatures: SignatureMocks,
suggestions: { invalidate: jest.Mock<void, [string]> },
ninth: NinthStepMocks,
): MailService {
return new MailService(
provider,
mailSettings,
signatures as unknown as MailSignatureService,
suggestions as unknown as RecipientSuggestionService,
ninth.userSettings as unknown as MailUserSettingsService,
ninth.contacts as unknown as ContactsService,
ninth.directory as unknown as DirectoryService,
ninth.imageTokens,
);
}
describe('MailService', () => {
let provider: ProviderMocks;
let signatures: SignatureMocks;
let suggestions: { invalidate: jest.Mock<void, [string]> };
let ninth: NinthStepMocks;
let service: MailService;
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
@@ -245,12 +324,8 @@ describe('MailService', () => {
provider = fakeProvider();
signatures = fakeSignatures();
suggestions = { invalidate: jest.fn<void, [string]>() };
service = new MailService(
provider,
settings,
signatures as unknown as MailSignatureService,
suggestions as unknown as RecipientSuggestionService,
);
ninth = fakeNinthStep();
service = createService(provider, settings, signatures, suggestions, ninth);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
@@ -854,6 +929,7 @@ describe('MailService', () => {
isFlagged: false,
hasAttachment: false,
size: 1,
pendingSend: null,
});
provider.updateMessages.mockResolvedValue({
updatedIds: ['m1'],
@@ -1077,11 +1153,12 @@ describe('MailService', () => {
});
it('메일 도메인 설정이 없으면 사내 주소인지 알 수 없어 전달을 받지 않는다', async () => {
const unconfigured = new MailService(
const unconfigured = createService(
provider,
{ ...settings, domain: null },
signatures as unknown as MailSignatureService,
suggestions as unknown as RecipientSuggestionService,
signatures,
suggestions,
ninth,
);
expect(
await failureOf(() =>
@@ -2164,10 +2241,7 @@ describe('MailService', () => {
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
await expect(send()).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
@@ -2179,12 +2253,16 @@ describe('MailService', () => {
const rejectedRecipients = [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
provider.getRejectedRecipients.mockResolvedValue(rejectedRecipients);
const result = await send();
expect(result).toEqual({ id: 'sent-1', rejectedRecipients });
expect(result).toEqual({
id: 'sent-1',
rejectedRecipients,
pendingSend: null,
});
expect(result).not.toHaveProperty('fingerprint');
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.getRejectedRecipients).toHaveBeenCalledWith(
@@ -2204,7 +2282,7 @@ describe('MailService', () => {
const everyone = [
{ email: 'doyun.lee@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
provider.getRejectedRecipients.mockResolvedValue(everyone);
await expect(
@@ -2213,14 +2291,18 @@ describe('MailService', () => {
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1', rejectedRecipients: everyone });
).resolves.toEqual({
id: 'sent-1',
rejectedRecipients: everyone,
pendingSend: null,
});
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('내용이 달라 MAIL_005 로 끝나면 처음 발송의 결과를 읽지 않는다 (제출 기록을 헛되이 읽지 않게)', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
expect(await codeOf(() => send(sendDto({ subject: '고친 제목' })))).toBe(
'MAIL_005',
@@ -2236,10 +2318,7 @@ describe('MailService', () => {
expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
// 정화로 지워지는 부분만 다르면 같은 메일이다
await expect(
@@ -2258,10 +2337,7 @@ describe('MailService', () => {
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
provider.findSentMessage.mockResolvedValue(sentRecord(fingerprint));
for (const changed of [
sendDto({ subject: '고친 제목' }),
@@ -2275,10 +2351,7 @@ describe('MailService', () => {
});
it('지문 없이 남은 메일(헤더가 지워진 경우 등)도 같은 내용인지 알 수 없으므로 MAIL_005', async () => {
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint: null,
});
provider.findSentMessage.mockResolvedValue(sentRecord(null));
expect(await codeOf(() => send())).toBe('MAIL_005');
});
@@ -2299,10 +2372,11 @@ describe('MailService', () => {
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
).resolves.toEqual(SENT);
finish({ id: 'sent-first', rejectedRecipients: [] });
finish({ id: 'sent-first', rejectedRecipients: [], pendingSend: null });
await expect(first).resolves.toEqual({
id: 'sent-first',
rejectedRecipients: [],
pendingSend: null,
});
await expect(send()).resolves.toEqual(SENT);
});
@@ -2702,6 +2776,319 @@ describe('MailService', () => {
});
});
/** 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 (계약 2절 "보내기 취소 · 예약 발송" · "외부 이미지 보기") */
describe('9단계 — 붙잡는 발송 · 외부 이미지', () => {
const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * MINUTE_MS;
const sentMessage = () => provider.sendMessage.mock.calls.at(-1)?.[1];
describe('붙잡는 발송', () => {
it('sendAt 이 있으면 그 시각(소수 초를 버린 값)까지 — 설정은 읽지 않는다(예약에는 보내기 취소 시간을 더하지 않는다)', async () => {
const second =
Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000;
await service.sendMessage(
user,
sendDto({ sendAt: new Date(second + 999).toISOString() }),
);
expect(sentMessage()?.hold).toEqual({
kind: 'scheduled',
until: new Date(second).toISOString(),
});
expect(ninth.userSettings.get).not.toHaveBeenCalled();
});
it.each<[string, () => string]>([
[
'4분 59초 뒤',
() => new Date(Date.now() + 5 * MINUTE_MS - 1_000).toISOString(),
],
[
'7일하고 1분 뒤',
() => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(),
],
['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()],
])(
'예약 시각이 %s 이면 엔진에 묻기 전에 VAL_001',
async (_label, sendAt) => {
expect(
await failureOf(() =>
service.sendMessage(user, sendDto({ sendAt: sendAt() })),
),
).toEqual({ code: 'VAL_001', reason: 'send-at-out-of-range' });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.findSentMessage).not.toHaveBeenCalled();
},
);
it('sendAt 이 없거나 null 이면 설정의 보내기 취소 초만큼 — 0초면 붙잡지 않는다(hold 없음)', async () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 20,
externalImages: 'ask',
});
await service.sendMessage(user, sendDto());
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
await service.sendMessage(user, sendDto({ sendAt: null }));
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
expect(ninth.userSettings.get).toHaveBeenCalledWith(user.email);
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 0,
externalImages: 'ask',
});
await service.sendMessage(user, sendDto());
expect(sentMessage()).not.toHaveProperty('hold');
});
it('설정을 읽지 못하면(DB 장애) 처음 값(10초)으로 보내고 경고만 — 발송을 실패로 만들지 않는다', async () => {
ninth.userSettings.get.mockRejectedValue(
new Error('connect ECONNREFUSED'),
);
await expect(service.sendMessage(user, sendDto())).resolves.toEqual(
SENT,
);
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 10 });
const warned = warnSpy.mock.calls
.map(([line]) => String(line))
.join('\n');
expect(warned).toContain('[SYS_001]');
expect(warned).not.toContain(user.email);
});
it('붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송(붙잡은 시각 포함)을 그대로 돌려준다', async () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 10,
externalImages: 'ask',
});
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const first = sentMessage();
const pendingSend = {
kind: 'undo' as const,
sendAt: '2026-10-29T06:00:10.000Z',
};
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue(
sentRecord(first?.fingerprint ?? null, { pendingSend }),
);
await expect(
service.sendMessage(
user,
sendDto({
sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(),
}),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({
id: 'sent-1',
rejectedRecipients: [],
pendingSend,
});
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('취소한 발송의 키로 다시 보내면 보내지 않고 409 MAIL_005 — 클라이언트는 새 키로 보낸다', async () => {
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const first = sentMessage();
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue(
sentRecord(first?.fingerprint ?? null, { canceled: true }),
);
expect(
await failureOf(() =>
service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY),
),
).toEqual({
code: 'MAIL_005',
reason: 'idempotency-key-of-canceled-send',
});
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
});
it('붙잡은 발송의 응답은 어댑터의 pendingSend 그대로 — 로그에 보낼 시각', async () => {
const pendingSend = {
kind: 'scheduled' as const,
sendAt: '2030-01-01T09:00:00.000Z',
};
provider.sendMessage.mockResolvedValue({ ...SENT, pendingSend });
await expect(
service.sendMessage(
user,
sendDto({
sendAt: new Date(Date.now() + 60 * MINUTE_MS).toISOString(),
}),
),
).resolves.toEqual({ ...SENT, pendingSend });
const logged = logSpy.mock.calls
.map(([line]) => String(line))
.join('\n');
expect(logged).toContain('보낼 시각=2030-01-01T09:00:00.000Z');
});
});
describe('외부 이미지', () => {
const EXTERNAL = 'https://news.example/track/abc123.png';
const imageMail = (overrides: Partial<MessageDetailSource> = {}) =>
source({
from: { name: '소식지', email: 'news@letters.example' },
rawHtmlBody: `<p>안녕하세요</p><img src="${EXTERNAL}" alt="로고">`,
...overrides,
});
const settingsAre = (externalImages: 'ask' | 'trusted' | 'always') =>
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 10,
externalImages,
});
/** 본문의 중계 주소 → 표를 푼 원래 주소 */
const proxied = (html: string | null) =>
[...(html ?? '').matchAll(/src="\/api\/mail-images\/([^"]+)"/g)].map(
([, token]) => ninth.imageTokens.open(token, user.email),
);
it('늘 묻기(처음 값)면 지우고 hasBlockedImages — 주소록 · 사내 목록은 묻지 않는다', async () => {
provider.getMessage.mockResolvedValue(imageMail());
const detail = await service.getMessage(user, 'm1');
expect(detail.htmlBody).toBe('<p>안녕하세요</p>');
expect(detail.hasBlockedImages).toBe(true);
expect(detail.imagesShown).toBe(false);
expect(ninth.contacts.hasEmail).not.toHaveBeenCalled();
expect(ninth.directory.hasEmail).not.toHaveBeenCalled();
});
it('images=show 면 이 직원의 표로 중계 주소를 넣는다 — 설정을 읽지 않고, 스팸메일함도', async () => {
provider.getMessage.mockResolvedValue(
imageMail({ isInJunkMailbox: true }),
);
const detail = await service.getMessage(user, 'm1', { images: 'show' });
expect(proxied(detail.htmlBody)).toEqual([EXTERNAL]);
expect(detail.htmlBody).not.toContain('news.example');
expect(detail.htmlBody).toContain('alt="로고"');
expect(detail.imagesShown).toBe(true);
expect(detail.hasBlockedImages).toBe(false);
expect(ninth.userSettings.get).not.toHaveBeenCalled();
// 스팸메일함에 들었는지는 응답에 싣지 않는다
expect(detail).not.toHaveProperty('isInJunkMailbox');
});
it('늘 보기(always)면 보인다 — 스팸메일함의 메일은 설정과 상관없이 지운다', async () => {
settingsAre('always');
provider.getMessage.mockResolvedValue(imageMail());
expect(
proxied((await service.getMessage(user, 'm1')).htmlBody),
).toEqual([EXTERNAL]);
provider.getMessage.mockResolvedValue(
imageMail({ isInJunkMailbox: true }),
);
const junk = await service.getMessage(user, 'm1');
expect(junk.imagesShown).toBe(false);
expect(junk.hasBlockedImages).toBe(true);
});
it('주소록 · 사내 직원(trusted)이면 보낸 사람이 내 주소록이나 사내 목록에 있을 때만', async () => {
settingsAre('trusted');
provider.getMessage.mockResolvedValue(imageMail());
expect((await service.getMessage(user, 'm1')).imagesShown).toBe(false);
expect(ninth.contacts.hasEmail).toHaveBeenCalledWith(
user.email,
'news@letters.example',
);
expect(ninth.directory.hasEmail).toHaveBeenCalledWith(
'news@letters.example',
);
ninth.contacts.hasEmail.mockResolvedValueOnce(true);
expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
ninth.directory.hasEmail.mockResolvedValueOnce(true);
expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
});
it('trusted 인데 주소록 · 사내 목록을 읽지 못하면 없는 것으로 — 메일은 열린다(보이지 않을 뿐)', async () => {
settingsAre('trusted');
provider.getMessage.mockResolvedValue(imageMail());
ninth.contacts.hasEmail.mockRejectedValue(
new BusinessException('MAIL_001'),
);
ninth.directory.hasEmail.mockRejectedValue(
new BusinessException('MAIL_001'),
);
const detail = await service.getMessage(user, 'm1');
expect(detail.imagesShown).toBe(false);
expect(detail.hasBlockedImages).toBe(true);
// 한쪽만 읽지 못하면 다른 쪽으로 본다
ninth.directory.hasEmail.mockResolvedValueOnce(true);
expect((await service.getMessage(user, 'm1')).imagesShown).toBe(true);
});
it('바깥 그림이 없으면 보이게 해도 imagesShown false — 텍스트만인 메일도', async () => {
provider.getMessage.mockResolvedValue(
source({ rawHtmlBody: '<p>그림 없음</p>' }),
);
const plain = await service.getMessage(user, 'm1', { images: 'show' });
expect(plain.imagesShown).toBe(false);
expect(plain.hasBlockedImages).toBe(false);
provider.getMessage.mockResolvedValue(source());
const text = await service.getMessage(user, 'm1', { images: 'show' });
expect(text.imagesShown).toBe(false);
});
it('설정을 읽지 못하면(DB 장애) 처음 값(늘 묻기)으로 — 메일은 열린다', async () => {
ninth.userSettings.get.mockRejectedValue(new Error('db down'));
provider.getMessage.mockResolvedValue(imageMail());
const detail = await service.getMessage(user, 'm1');
expect(detail.hasBlockedImages).toBe(true);
expect(detail.imagesShown).toBe(false);
});
});
it('취소해 되돌린 발송을 이어 쓸 때 서명 그림(cid:sig-…)을 서명 그림 주소로 되돌린다 — 내 그림만 남는다', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
provider.getDraft.mockResolvedValue(
draftSource({
rawHtmlBody: `<p>본문</p><img src="cid:sig-${MY_IMAGE_ID.toUpperCase()}@arasystem.test" width="96"><img src='cid:sig-${OTHER_IMAGE_ID}@arasystem.test'>`,
isCanceledSend: true,
}),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.bodyHtml).toBe(
`<p>본문</p><img src="${imageSrc(MY_IMAGE_ID)}" width="96" />`,
);
});
it('취소한 발송이 아닌 초안의 cid:sig-… 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지운다, 9단계 리뷰 뒤)', async () => {
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
provider.getDraft.mockResolvedValue(
draftSource({
rawHtmlBody: `<p>본문</p><img src="cid:sig-${MY_IMAGE_ID}@arasystem.test" width="96">`,
isCanceledSend: false,
}),
);
const draft = await service.getDraft(user, 'draft-1');
expect(draft.bodyHtml).toBe('<p>본문</p>');
// 서명 그림 주소가 생기지 않았으니 그림 목록을 읽지도 않는다
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
});
});
/** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */
describe('보낸 뒤', () => {
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
@@ -2728,10 +3115,9 @@ describe('MailService', () => {
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint: fingerprint ?? null,
});
provider.findSentMessage.mockResolvedValue(
sentRecord(fingerprint ?? null),
);
await expect(
service.sendMessage(
@@ -2818,6 +3204,7 @@ describe('MailService', () => {
rejectedRecipients: [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
],
pendingSend: null,
};
provider.sendMessage.mockResolvedValue(partial);
+190 -18
View File
@@ -6,9 +6,16 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { ContactsService } from '@/modules/contacts/contacts.service';
import { DirectoryService } from '@/modules/contacts/directory.service';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
import {
DEFAULT_MAIL_USER_SETTINGS,
type MailUserSettings,
} from '@/modules/mail-user-settings/mail-user-settings.types';
import { traceTag } from '@/shared/logger/request-context';
import { toWholeSecondIso } from '@/shared/utils/format';
import { maskEmail } from '@/shared/utils/mask';
@@ -31,6 +38,10 @@ import type {
MailboxMessagesPatchDto,
RenameMailboxDto,
} from './dto/mailbox.dto';
import {
SHOW_IMAGES,
type MessageDetailQueryDto,
} from './dto/message-detail-query.dto';
import type { MessageListQueryDto } from './dto/message-list-query.dto';
import type {
ComposeContentDto,
@@ -53,15 +64,21 @@ import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import {
SIGNATURE_IMAGE_PATH_PREFIX,
findSignatureImageIds,
restoreSignatureImageSources,
rewriteSignatureImages,
signatureImageContentId,
} from './html/signature-images';
import { MailImageTokens } from './images/mail-image-token';
import {
MAIL_IMAGES_ROUTE,
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
SCHEDULED_SEND_MAX_LEAD_MS,
SCHEDULED_SEND_MIN_LEAD_MS,
} from './mail.constants';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
@@ -97,6 +114,7 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
SavedDraft,
SendHold,
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
@@ -243,10 +261,37 @@ function assertMoveTargetsAllowed(
}
}
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
return `sig-${imageId}@${domain}`;
/** 1초 (밀리초) — 예약 시각을 초 단위로 맞출 때 */
const SECOND_MS = 1_000;
/**
* 외부 이미지 중계 주소 — 같은 서버의 상대 경로 (`/api/mail-images/<표>`, 계약 2절 "외부 이미지 보기").
* 짝: frontend `src/shared/constants/mail.ts` 의 `MAIL_IMAGE_PROXY_PATH` — 화면은 이 앞부분으로 시작하는 그림만 남긴다(한쪽을 고치면 둘 다)
*/
const MAIL_IMAGE_PATH_PREFIX = `/api/${MAIL_IMAGES_ROUTE}/`;
/**
* 예약 발송 시각(sendAt) → 붙잡는 방법 — 소수 초를 버린 값(엔진의 HOLDUNTIL 은 초 단위)이 지금(서버 시계)부터 5분 뒤 이상 · 7일 안이어야 한다.
* 밖이면 엔진에 묻기 전에 VAL_001 (계약 2절 "보내기 취소 · 예약 발송")
*/
function scheduledHold(sendAt: string, nowMs: number): SendHold {
const until = Math.floor(Date.parse(sendAt) / SECOND_MS) * SECOND_MS;
const lead = until - nowMs;
if (
!Number.isFinite(until) ||
lead < SCHEDULED_SEND_MIN_LEAD_MS ||
lead > SCHEDULED_SEND_MAX_LEAD_MS
) {
throw new BusinessException('VAL_001', {
context: {
reason: 'send-at-out-of-range',
leadSeconds: Number.isFinite(lead)
? Math.round(lead / SECOND_MS)
: null,
},
});
}
return { kind: 'scheduled', until: new Date(until).toISOString() };
}
/**
@@ -283,6 +328,13 @@ export class MailService {
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
private readonly signatures: MailSignatureService,
private readonly suggestions: RecipientSuggestionService,
/** 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계) */
private readonly userSettings: MailUserSettingsService,
/** 외부 이미지 "주소록 · 사내 직원은 늘 보기" 판정 — 내 주소록 · 사내 목록 (9단계) */
private readonly contacts: ContactsService,
private readonly directory: DirectoryService,
/** 외부 이미지 중계 표 (9단계) */
private readonly imageTokens: MailImageTokens,
) {}
async listMailboxes(
@@ -618,10 +670,15 @@ export class MailService {
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
* 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다).
*
* 외부 이미지(9단계 "외부 이미지 보기"): `images=show` 이거나 설정이 보이라고 하면(always · trusted — 스팸메일함의 메일은 빼고)
* `<img>` 의 외부 주소를 중계 주소(`/api/mail-images/<표>`)로 바꿔 넣는다 — imagesShown 은 바꿔 넣은 것이 있으면 true,
* 보이게 한 본문은 hasBlockedImages 가 false 다(srcset · 배경 그림은 지금처럼 지운다 — 알려진 한계)
*/
async getMessage(
user: AuthenticatedUser,
id: string,
query: MessageDetailQueryDto = {},
): Promise<MessageDetail> {
const {
rawHtmlBody,
@@ -629,6 +686,8 @@ export class MailService {
isTextBodyTruncated,
inlineParts,
attachments,
// 스팸메일함에 들었는지는 이미지 판정에만 쓴다 — 응답에 싣지 않는다
isInJunkMailbox,
...detail
} = await this.provider.getMessage(user.email, id);
@@ -645,6 +704,7 @@ export class MailService {
attachments: markInline(NO_INLINE_ATTACHMENTS),
htmlBody: null,
hasBlockedImages: false,
imagesShown: false,
isHtmlBodyOmitted,
isBodyTruncated: isTextBodyTruncated,
});
@@ -661,12 +721,24 @@ export class MailService {
return withoutHtml(true);
}
const inlineImages = await this.loadInlineImages(
user.email,
const [inlineImages, showImages] = await Promise.all([
this.loadInlineImages(user.email, rawHtmlBody, inlineParts),
this.showsExternalImages(
user,
{ sender: detail.from?.email ?? null, isInJunkMailbox },
query,
),
]);
const sanitized = sanitizeMailHtml(
rawHtmlBody,
inlineParts,
inlineImages.dataUris,
showImages
? {
externalImageSrc: (url) =>
`${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`,
}
: {},
);
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris);
const inlined = new Set(
[...sanitized.embeddedContentIds].flatMap((contentId) => {
const index = inlineImages.attachmentIndexes.get(contentId);
@@ -678,12 +750,71 @@ export class MailService {
...detail,
attachments: markInline(inlined),
htmlBody: sanitized.html,
hasBlockedImages: sanitized.hasBlockedImages,
hasBlockedImages: showImages ? false : sanitized.hasBlockedImages,
imagesShown: sanitized.proxiedImageCount > 0,
isHtmlBodyOmitted: false,
isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated,
};
}
/**
* 외부 이미지를 보일까 (계약 2절 "외부 이미지 보기") — `images=show` 면 늘(스팸메일함도). 아니면 설정:
* ask 는 안 보이고, 스팸메일함의 메일은 설정과 상관없이 안 보인다, always 는 보이고, trusted 는 보낸 사람이 내 주소록이나 사내 목록에 있을 때.
* 주소록 · 사내 목록을 읽지 못하면 보이지 않는다(묻는 쪽으로 — 경고만)
*/
private async showsExternalImages(
user: AuthenticatedUser,
message: {
readonly sender: string | null;
readonly isInJunkMailbox: boolean;
},
query: MessageDetailQueryDto,
): Promise<boolean> {
if (query.images === SHOW_IMAGES) return true;
const { externalImages } = await this.userSettingsOf(user);
if (externalImages === 'ask' || message.isInJunkMailbox) return false;
if (externalImages === 'always') return true;
return message.sender ? this.isTrustedSender(user, message.sender) : false;
}
/**
* 그 직원의 보내기 · 읽기 설정 — 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 남긴다. 발송 · 메일 읽기를 설정 때문에 실패로 만들지 않는다
*/
private async userSettingsOf(
user: AuthenticatedUser,
): Promise<MailUserSettings> {
try {
return await this.userSettings.get(user.email);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
return DEFAULT_MAIL_USER_SETTINGS;
}
}
/** 보낸 사람이 내 주소록 · 사내 목록(직원 · 공용 주소)에 있는가 — 읽지 못한 쪽은 없는 것으로 본다 */
private async isTrustedSender(
user: AuthenticatedUser,
sender: string,
): Promise<boolean> {
const lookups: [string, () => Promise<boolean>][] = [
['contacts', () => this.contacts.hasEmail(user.email, sender)],
['directory', () => this.directory.hasEmail(sender)],
];
for (const [where, lookup] of lookups) {
try {
if (await lookup()) return true;
} catch (error) {
if (!(error instanceof BusinessException)) throw error;
this.logger.debug(
`${traceTag()} 외부 이미지의 보낸 사람 확인에서 ${where} 를 읽지 못해 없는 것으로 봅니다: user=${maskEmail(user.email)}, 원인=${error.code}`,
);
}
}
return false;
}
async updateMessage(
user: AuthenticatedUser,
id: string,
@@ -740,6 +871,10 @@ export class MailService {
*
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
*
* 붙잡기 (9단계 "보내기 취소 · 예약 발송"): `sendAt` 이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안 — 밖이면 엔진에 묻기 전에 VAL_001),
* 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 지금처럼 바로) 엔진이 붙잡았다가 보낸다 — 응답의 pendingSend.
* 예약에는 보내기 취소 시간을 더하지 않는다. 붙잡는 시간은 지문에 넣지 않는다 — 같은 키의 재시도는 처음 발송을 그대로 돌려준다
*/
async sendMessage(
user: AuthenticatedUser,
@@ -752,8 +887,15 @@ export class MailService {
context: { reason: 'invalid-idempotency-key' },
});
}
// 예약 시각은 본문 준비 · 엔진보다 먼저 본다 — 범위 밖이면 아무것도 하지 않는다. null 은 없는 것과 같다(다른 선택 칸처럼)
const scheduled =
dto.sendAt === undefined || dto.sendAt === null
? null
: scheduledHold(dto.sendAt, Date.now());
const message = await this.prepareOutgoing(user, dto);
const prepared = await this.prepareOutgoing(user, dto);
const hold = scheduled ?? (await this.undoHold(user));
const message: OutgoingMessage = hold ? { ...prepared, hold } : prepared;
const result =
parsed.key === null
? await this.deliver(user, message)
@@ -762,6 +904,14 @@ export class MailService {
return result;
}
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
const { undoSendSeconds } = await this.userSettingsOf(user);
return undoSendSeconds > 0
? { kind: 'undo', seconds: undoSendSeconds }
: null;
}
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
async createDraft(
user: AuthenticatedUser,
@@ -910,6 +1060,16 @@ export class MailService {
messageId,
);
if (previous) {
// 취소한 발송의 키(9단계) — 보내지 않는다. 클라이언트는 취소 뒤 새 키로 보낸다 (계약 2절 "보내기 취소 · 예약 발송")
if (previous.canceled) {
throw new BusinessException('MAIL_005', {
context: {
reason: 'idempotency-key-of-canceled-send',
from: maskEmail(user.email),
sentId: previous.id,
},
});
}
if (previous.fingerprint !== fingerprint) {
throw new BusinessException('MAIL_005', {
context: {
@@ -927,6 +1087,7 @@ export class MailService {
previous.id,
message,
),
pendingSend: previous.pendingSend,
};
this.logger.log(
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`,
@@ -1095,7 +1256,7 @@ export class MailService {
const cids = new Map(
images.map((image) => [
image.id,
signatureImageCid(image.id, user.email),
signatureImageContentId(image.id, user.email),
]),
);
const inlineImages = images.map(
@@ -1113,22 +1274,29 @@ export class MailService {
return { htmlBody, inlineImages };
}
/** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */
/**
* 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null.
* 취소해 되돌린 발송(9단계 — isCanceledSend)만 서명 그림이 메일 안의 `cid:sig-…` 라 정화 전에 서명 그림 주소로 되돌린다(정화가 내 그림만 남긴다).
* 다른 초안의 `cid:sig-…` 는 되돌리지 않는다 — 다른 프로그램이 남의 메일을 전달 · 인용한 것일 수 있다(정화가 cid 그림을 지우고 그 부분은 첨부로 남는다)
*/
private async draftHtml(
user: AuthenticatedUser,
source: DraftSource,
): Promise<string | null> {
const raw = source.rawHtmlBody;
if (!raw) return null;
if (!source.rawHtmlBody) return null;
const budget = checkHtmlParseBudget(raw);
const budget = checkHtmlParseBudget(source.rawHtmlBody);
if (!budget.ok) {
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
this.logger.warn(
`${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`,
`${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${source.rawHtmlBody.length}자`,
);
return null;
}
// 비용을 본 뒤에 되돌린다 — 되돌리기는 입력 길이에 비례하고 태그 · 중첩을 늘리지 않는다
const raw = source.isCanceledSend
? restoreSignatureImageSources(source.rawHtmlBody)
: source.rawHtmlBody;
const html = sanitizeOutgoingHtml(raw, {
signatureImageIds: await this.signatureImageIdsFor(user, raw),
});
@@ -1183,11 +1351,15 @@ export class MailService {
): Promise<SentMessage> {
const result = await this.provider.sendMessage(user.email, message);
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다.
// 붙잡은 발송은 메일 id · 보낼 시각만 (계약 2절 "보내기 취소 · 예약 발송"의 로그)
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
const held = result.pendingSend
? `, ${result.pendingSend.kind === 'scheduled' ? '예약' : '보내기 취소 대기'} id=${result.id} 보낼 시각=${result.pendingSend.sendAt}`
: '';
this.logger.log(
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}`,
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}${held}`,
);
return result;
}
+72 -1
View File
@@ -32,6 +32,18 @@ export interface Mailbox {
readonly unreadCount: number;
}
/**
* 엔진이 붙잡아 둔 발송의 종류 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 보내기 취소를 기다리는 중 · 예약 발송
*/
export type PendingSendKind = 'undo' | 'scheduled';
/** 아직 보내지 않은 내 메일의 발송 — 엔진의 기다리는 제출(undoStatus: pending, 보낼 시각이 아직 오지 않음) */
export interface PendingSend {
/** 엔진이 보낼 시각 (ISO 8601 UTC) */
readonly sendAt: string;
readonly kind: PendingSendKind;
}
export interface MessageSummary {
readonly id: string;
readonly threadId: string;
@@ -48,6 +60,8 @@ export interface MessageSummary {
readonly hasAttachment: boolean;
/** 바이트 */
readonly size: number;
/** 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 발송 — 그 밖에는 null (9단계) */
readonly pendingSend: PendingSend | null;
}
export interface MailAttachment {
@@ -78,8 +92,10 @@ export interface MessageDetail extends MessageSummary {
/** 서버가 정화한 HTML — 정화 전 원본은 절대 내보내지 않는다 */
readonly htmlBody: string | null;
readonly textBody: string | null;
/** 외부 이미지를 지웠으면 true */
/** 외부 이미지를 지웠으면 true — 보이게 했으면(imagesShown 을 정한 요청) false */
readonly hasBlockedImages: boolean;
/** 외부 이미지를 중계 주소(/api/mail-images/…)로 바꿔 넣었으면 true — images=show 또는 설정 (9단계 "외부 이미지 보기") */
readonly imagesShown: boolean;
/** HTML 이 있었지만 정화 전 사전 검사(html/html-parse-budget.ts)를 넘어 htmlBody 를 비웠으면 true */
readonly isHtmlBodyOmitted: boolean;
/**
@@ -111,12 +127,17 @@ export interface MessageDetailSource extends Omit<
| 'htmlBody'
| 'textBody'
| 'hasBlockedImages'
| 'imagesShown'
| 'isHtmlBodyOmitted'
| 'isBodyTruncated'
| 'attachments'
> {
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
readonly attachments: MailAttachment[];
/**
* 스팸메일함에 든 메일인가 — 외부 이미지를 설정(trusted · always)으로 보이지 않는다 (images=show 로만 — 계약 2절 "외부 이미지 보기")
*/
readonly isInJunkMailbox: boolean;
readonly rawHtmlBody: string | null;
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
readonly isRawHtmlBodyTruncated: boolean;
@@ -446,8 +467,21 @@ export interface OutgoingMessage {
readonly messageId?: string;
/** 내용 지문 — 같은 키로 내용이 바뀌었는지 비교한다. 어댑터는 메일과 함께 남겨 `findSentMessage` 로 돌려준다 */
readonly fingerprint?: string;
/**
* 엔진이 붙잡았다가 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 없으면 지금처럼 바로 보낸다.
* 서비스가 정한다: 예약(sendAt)이면 그 시각까지, 아니면 그 직원 설정의 보내기 취소 초(0 이면 없음)
*/
readonly hold?: SendHold;
}
/**
* 발송을 붙잡는 방법 — 보내기 취소(초 — HOLDFOR) · 예약(UTC 시각, 초 단위 — HOLDUNTIL).
* 어댑터는 이것으로 발송 envelope 을 직접 적는다 (엔진의 FUTURERELEASE — Stalwart 0.16.27, 2026-10-29 확인)
*/
export type SendHold =
| { readonly kind: 'undo'; readonly seconds: number }
| { readonly kind: 'scheduled'; readonly until: string };
/** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */
export type RecipientFields = Pick<OutgoingMessage, 'to' | 'cc' | 'bcc'>;
@@ -491,6 +525,11 @@ export interface DraftSource {
/** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */
readonly inReplyTo: string | null;
readonly updatedAt: string;
/**
* 취소해 임시보관함으로 되돌린 발송인가 (9단계 — 취소한 발송 표시). 이때만 서식 본문의 `cid:sig-…`(우리가 실어 보낸 서명 그림)를
* 서명 그림 주소로 되돌린다 — 어댑터는 그 그림 부분을 첨부에서 이미 뺐다
*/
readonly isCanceledSend: boolean;
}
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
@@ -558,6 +597,8 @@ export interface SentMessage {
readonly id: string;
/** 엔진이 받지 않은 사람 — 없으면 빈 배열. 같은 주소는 한 번만, 요청의 받는 사람 · 참조 · 숨은참조 순서 */
readonly rejectedRecipients: RejectedRecipient[];
/** 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null */
readonly pendingSend: PendingSend | null;
}
/**
@@ -569,6 +610,36 @@ export interface SentMessageRecord {
readonly id: string;
/** 발송 때 남긴 내용 지문 — 없으면 null */
readonly fingerprint: string | null;
/**
* 그 발송을 취소했다(9단계 — 제출이 모두 canceled) — 보낸 것이 아니다. 같은 키로 다시 보내면 서비스가 409 MAIL_005 로 답한다
* (클라이언트는 취소 뒤 새 키로 보낸다 — 계약 2절 "보내기 취소 · 예약 발송")
*/
readonly canceled: boolean;
/** 아직 엔진이 붙잡고 있는 발송이면 그 정보 — 같은 키의 재시도가 처음 결과를 그대로 돌려줄 때 싣는다 */
readonly pendingSend: PendingSend | null;
}
/**
* 보낸 사람 차단 하나 (계약 2절 "보낸 사람 차단" — 9단계) — 주소("kim@spam.example") 또는 도메인("@spam.example") · 소문자
*/
export interface BlockedSender {
readonly address: string;
/** 차단한 시각 (ISO 8601 UTC) */
readonly blockedAt: string;
}
/** 차단 목록 (GET /api/mail-blocks) */
export interface MailBlockList {
/** 최근에 차단한 것부터 */
readonly senders: readonly BlockedSender[];
/** 엔진에서 우리 규칙(필터 · 부재중과 같은 Sieve 스크립트)이 켜져 있는지 — 메일 필터의 isActive 와 같다 */
readonly isActive: boolean;
}
/** 차단을 더한 결과 — 이미 있던 주소면 created: false (그 항목 그대로) */
export interface MailBlockAdded {
readonly sender: BlockedSender;
readonly created: boolean;
}
/** 업로드된 첨부 — 임시 파일에 있다 (요청이 끝나면 지워진다) */
@@ -137,6 +137,10 @@ export class MailboxController {
})
@ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse(
'MAIL_015',
'남은 메일이 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)뿐이고 이 요청에서 옮긴 것이 없다 — 옮기지 않는다 (먼저 보내기를 취소한다)',
)
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
remove(
@@ -178,6 +182,10 @@ export class MailboxController {
})
@ApiErrorResponse('VAL_001', '휴지통 · 스팸메일함이 아님')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse(
'MAIL_015',
'남은 메일이 붙잡힌 메일(9단계 — 다른 프로그램이 옮겨 둔 것)뿐이고 이 요청에서 지운 것이 없다 — 지우지 않는다',
)
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
empty(
@@ -41,7 +41,7 @@ export class MessageDeletionController {
summary: '휴지통에서 영구 삭제 (여러 통)',
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 휴지통에 있는 메일만 다룬다 — 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다. ` +
'휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일은 지우지 않고 skippedIds 로 돌려준다. ' +
'휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일 · 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 지우지 않고 skippedIds 로 돌려준다. ' +
'대상을 확인한 뒤 그 상태 그대로일 때만 한 번에 지운다(그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다) — ' +
'계속 어긋나면 아무것도 지우지 않고 503 MAIL_001 (다시 시도하면 된다).',
})
@@ -54,7 +54,7 @@ export class MessageDeletionController {
)
@ApiErrorResponse(
'MAIL_001',
'확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
'확인하는 사이 계속 바뀜 · 휴지통 없음 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
)
remove(
@CurrentUser() user: AuthenticatedUser,
+34 -5
View File
@@ -50,12 +50,14 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_BULK_MESSAGE_IDS,
MAX_ENVELOPE_RESUBMITS,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MESSAGES_ROUTE,
} from './mail.constants';
import { UpdateMessagesDto } from './dto/bulk-messages.dto';
import { MessageDetailQueryDto } from './dto/message-detail-query.dto';
import { MessageListQueryDto } from './dto/message-list-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { UpdateMessageDto } from './dto/update-message.dto';
@@ -119,6 +121,7 @@ export class MessageController {
description:
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만, isJunk = $junk · $notjunk). 엔진 요청 한 번으로 처리한다. ` +
'바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' +
'옮기기 · 스팸 표시는 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)에 하지 않는다 — 그 id 도 failedIds (확인하지 못하면 아무것도 바꾸지 않고 503 MAIL_001). ' +
'없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).',
})
@ApiStandardResponse(MessagesPatchedDto, {
@@ -146,27 +149,40 @@ export class MessageController {
description:
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' +
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다.`,
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` +
'images=show 면 외부 이미지(<img> 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' +
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).',
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리')
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
get(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Query() query: MessageDetailQueryDto,
) {
return this.mail.getMessage(user, id);
return this.mail.getMessage(user, id, query);
}
@Patch(':id')
@ApiOperation({
summary: '읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)',
description:
'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다.',
'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다. ' +
'붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 mailboxId · isJunk 를 받지 않는다(409 MAIL_015 — 취소로만 보낸편지함을 떠난다), 읽음 · 중요 표시는 된다.',
})
@ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' })
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동')
@ApiErrorResponse(
'MAIL_015',
'붙잡힌 메일의 mailboxId · isJunk — 아무것도 바꾸지 않았다 (먼저 보내기를 취소한다)',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 — 옮기기 · 스팸 표시는 붙잡힌 메일인지 확인하지 못하면 바꾸지 않는다',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@@ -186,6 +202,14 @@ export class MessageController {
description: '삭제(이동) 완료 — 본문 없음',
})
@ApiErrorResponse('RES_001', '없는 메일')
@ApiErrorResponse(
'MAIL_015',
'붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계) — 옮기지도 지우지도 않았다 (먼저 보내기를 취소한다)',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 확인하는 사이 계속 바뀜 · 붙잡힌 메일인지 확인하지 못함 — 지우지 않았다',
)
async remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@@ -209,7 +233,12 @@ export class MessageController {
`본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` +
`bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` +
'엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' +
'이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
'이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002). ' +
'9단계 — 메일 서버가 붙잡았다가 보낸다: sendAt(UTC)이 있으면 그 시각까지(지금부터 5분 뒤 ~ 7일 안, 밖이면 400 VAL_001), 없으면 그 직원 설정의 보내기 취소 초만큼(0 이면 바로). ' +
'201 의 pendingSend 가 그 발송이다(바로 보냈으면 null) — 붙잡아도 사내 받는 사람 확인은 제출 순간이라 rejectedRecipients · MAIL_010 · MAIL_011 은 그대로다. ' +
'메일 서버가 받지 않는 주소(모르는 최상위 도메인 등)는 rejectedRecipients 의 not-found 로 빼고 나머지에게 보낸다 — 서버는 한 번에 하나만 알려 주므로 ' +
`${MAX_ENVELOPE_RESUBMITS + 1}개를 넘게 빼야 하면(다시 내기 ${MAX_ENVELOPE_RESUBMITS}차례) 보내지 않는다(400 VAL_001 — 만든 메일을 지웠다, 주소를 고쳐 같은 키로 다시 보내면 새로 보낸다). ` +
'취소(DELETE /api/scheduled-messages/{id})한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다.',
})
@ApiHeader({
name: 'Idempotency-Key',
@@ -236,7 +265,7 @@ export class MessageController {
)
@ApiErrorResponse(
'MAIL_005',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 · 그 키의 발송을 취소했다(9단계) — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
)
@ApiErrorResponse(
'FILE_002',
@@ -0,0 +1,80 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
ScheduledMessageCanceledDto,
ScheduledMessagePageDto,
} from './dto/mail-response.dto';
import { MailIdPipe } from './mail-id.pipe';
import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants';
import { ScheduledMessagesService } from './scheduled-messages.service';
// 붙잡아 둔 발송 — 예약한 메일 목록 · 보내기 취소 · 예약 취소 (계약 2절 "보내기 취소 · 예약 발송", 9단계)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(SCHEDULED_MESSAGES_ROUTE)
export class ScheduledMessageController {
constructor(private readonly scheduled: ScheduledMessagesService) {}
@Get()
@ApiOperation({
summary: '예약한 메일 (보낼 시각 차례, 커서 페이지네이션)',
description:
'예약 발송(sendAt)으로 붙잡아 둔 메일 가운데 아직 보내지 않은 것 — 보내기 취소를 기다리는 메일은 들지 않는다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다. ' +
'목록 · 상태는 늘 메일 엔진의 기다리는 제출에 묻는다(서버가 따로 기억하지 않는다).',
})
@ApiStandardResponse(ScheduledMessagePageDto, {
description: '예약한 메일 (total 포함)',
})
@ApiErrorResponse('VAL_001', '다른 목록의 커서 · 깨진 커서 · limit 범위 밖')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: CursorPaginationQueryDto,
) {
return this.scheduled.list(user, query);
}
@Delete(':id')
@ApiOperation({
summary: '붙잡은 발송 취소 — 보내기 취소 · 예약 취소 (임시보관함으로)',
description:
'id 는 보낸 사본의 메일 id 다. 메일 서버에서 발송을 취소한 **뒤에** 그 메일을 임시보관함으로 옮겨 초안으로 되돌린다 — 받는 사람 · 제목 · 본문 · 첨부는 그대로이고 ' +
'draftId 는 같은 메일 id 다(쓰기 화면이 ?draft= 로 이어 쓴다). 취소한 발송의 Idempotency-Key 로 다시 보내면 409 MAIL_005 — 새 키로 보낸다. ' +
'이미 취소한 발송을 다시 취소하면(응답을 받지 못한 재시도 · 옮기지 못한 취소) 같은 200 — 되돌리기를 마쳤으면 그 뒤 어느 메일함으로 옮겼든 그대로 두고, ' +
'마치지 못했으면(보낸편지함에 남음) 되돌리기만 다시 한다. 메일 서버가 취소를 확인해 주지 않으면 503 MAIL_001 — 옮기지 않는다(다시 취소하면 처음부터 다시 본다).',
})
@ApiStandardResponse(ScheduledMessageCanceledDto, {
description: '취소하고 임시보관함으로 되돌렸다',
})
@ApiErrorResponse('RES_001', '없는 메일 (형식이 틀린 id 포함)')
@ApiErrorResponse(
'MAIL_014',
'이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다',
)
cancel(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.scheduled.cancel(user, id);
}
}
@@ -0,0 +1,118 @@
import { BusinessException } from '@/common/errors/business.exception';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import type { MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
import { ScheduledMessagesService } from './scheduled-messages.service';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const summary = (id: string, minute: number): MessageSummary => ({
id,
threadId: id,
mailboxIds: ['e'],
from: { name: '김아라', email: user.email },
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
subject: `예약 ${id}`,
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: {
kind: 'scheduled',
sendAt: new Date(Date.UTC(2030, 0, 1, 9, minute)).toISOString(),
},
});
async function failureOf(
run: () => Promise<unknown>,
): Promise<{ code?: string; reason?: unknown }> {
try {
await run();
} catch (error) {
return error instanceof BusinessException
? { code: error.code, reason: error.context?.reason }
: { code: 'NOT_BUSINESS' };
}
return {};
}
describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취소 (9단계)', () => {
const all = ['s1', 's2', 's3', 's4', 's5'].map((id, i) => summary(id, i));
let provider: {
listScheduledMessages: jest.Mock;
cancelPendingSend: jest.Mock;
};
let service: ScheduledMessagesService;
beforeEach(() => {
provider = {
listScheduledMessages: jest.fn().mockResolvedValue(all),
cancelPendingSend: jest.fn().mockResolvedValue(undefined),
};
service = new ScheduledMessagesService(provider as unknown as MailProvider);
});
it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => {
const first = await service.list(user, { limit: 2 });
expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']);
expect(first.pageInfo).toEqual({
hasNext: true,
nextCursor: expect.any(String) as string,
total: 5,
});
const second = await service.list(user, {
limit: 2,
cursor: first.pageInfo.nextCursor ?? undefined,
});
expect(second.items.map(({ id }) => id)).toEqual(['s3', 's4']);
const last = await service.list(user, {
limit: 2,
cursor: second.pageInfo.nextCursor ?? undefined,
});
expect(last.items.map(({ id }) => id)).toEqual(['s5']);
expect(last.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 5,
});
});
it('예약한 메일이 없으면 빈 쪽 · total 0', async () => {
provider.listScheduledMessages.mockResolvedValue([]);
await expect(service.list(user, { limit: 20 })).resolves.toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
});
it('다른 목록의 커서 · 깨진 커서는 엔진에 묻기 전에 VAL_001', async () => {
for (const cursor of [
encodeOffsetCursor(2, 'contacts'),
'bm90LWEtY3Vyc29y',
'!!',
]) {
expect(
await failureOf(() => service.list(user, { limit: 20, cursor })),
).toMatchObject({ code: 'VAL_001' });
}
expect(provider.listScheduledMessages).not.toHaveBeenCalled();
});
it('취소 — 포트에 넘기고 같은 메일 id 를 draftId 로 돌려준다 (실패는 포트의 코드 그대로)', async () => {
await expect(service.cancel(user, 'm1')).resolves.toEqual({
draftId: 'm1',
});
expect(provider.cancelPendingSend).toHaveBeenCalledWith(user.email, 'm1');
provider.cancelPendingSend.mockRejectedValueOnce(
new BusinessException('MAIL_014', { context: { reason: 'x' } }),
);
expect(await failureOf(() => service.cancel(user, 'm1'))).toMatchObject({
code: 'MAIL_014',
});
});
});
@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
type CursorPaginationQueryDto,
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
/** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */
const SCHEDULED_SCOPE = 'scheduled-messages';
/**
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소.
* 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다).
* 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다
*/
@Injectable()
export class ScheduledMessagesService {
constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {}
/**
* 예약한 메일 — 보낼 시각 차례, total 포함 (화면의 「예약됨」 통 수). 커서는 위치다 — 차례 사이에 보내지거나 취소되면 한두 개가 겹치거나 빠질 수 있다.
* 커서는 엔진에 묻기 전에 본다 — 다른 목록 · 깨진 커서는 VAL_001
*/
async list(
user: AuthenticatedUser,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<MessageSummary>> {
let start = 0;
if (query.cursor !== undefined) {
const reading = decodeOffsetCursor(query.cursor, SCHEDULED_SCOPE);
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-scheduled-cursor:${reading.problem}` },
});
}
start = reading.offset;
}
const all = await this.provider.listScheduledMessages(user.email);
const items = all.slice(start, start + query.limit);
const next = start + items.length;
const hasNext = next < all.length;
return toPaginatedResult(items, {
hasNext,
nextCursor: hasNext ? encodeOffsetCursor(next, SCHEDULED_SCOPE) : null,
total: all.length,
});
}
/**
* 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId).
* 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다)
*/
async cancel(
user: AuthenticatedUser,
id: string,
): Promise<{ draftId: string }> {
await this.provider.cancelPendingSend(user.email, id);
return { draftId: id };
}
}
+26
View File
@@ -57,6 +57,32 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
// 값 안의 = 도 값이다
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
});
it('외부 이미지 중계의 표(경로)는 길이만 남긴다 — 쿼리가 붙어도, 다른 경로는 그대로 (9단계)', () => {
const token = 'AbC_-1'.repeat(20);
expect(maskUrlForLog(`/api/mail-images/${token}`)).toBe(
'/api/mail-images/<120자>',
);
expect(maskUrlForLog(`/api/mail-images/${token}?q=abc&x=1`)).toBe(
'/api/mail-images/<120자>?q=<3자>&x=1',
);
expect(maskUrlForLog('/api/mail-images/')).toBe('/api/mail-images/');
expect(maskUrlForLog('/api/mail-imagesx/abc')).toBe(
'/api/mail-imagesx/abc',
);
expect(maskUrlForLog('/api/messages/abc?images=show')).toBe(
'/api/messages/abc?images=show',
);
});
it('외부 이미지 중계의 표는 경로의 대소문자와 상관없이 길이만 남긴다 — 라우팅이 대소문자를 가리지 않는다 (앞부분은 들어온 그대로)', () => {
expect(maskUrlForLog('/API/MAIL-IMAGES/AbCdef')).toBe(
'/API/MAIL-IMAGES/<6자>',
);
expect(maskUrlForLog('/Api/Mail-Images/AbCdef?q=x')).toBe(
'/Api/Mail-Images/<6자>?q=<1자>',
);
});
});
describe('maskEmail', () => {
+15 -2
View File
@@ -17,6 +17,14 @@ const MASK = '***';
*/
const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
/**
* 값을 남기지 않는 경로 조각 — 외부 이미지 중계의 표(`/api/mail-images/<표>` — 9단계).
* 표는 원래 주소(추적 번호가 든다)를 서버 비밀로 암호화한 글이라 풀리지는 않지만, 4,000자까지 길고 로그에 둘 까닭이 없다 — 길이만 남긴다
* (계약 2절 "외부 이미지 보기"의 로그). 경로의 대소문자는 가리지 않는다 — Express 의 라우팅이 가리지 않아(`/API/MAIL-IMAGES/<표>` 도 처리기에 닿는다)
* 그 요청이 401 · 429 로 끝나면 전역 필터의 로그에 표가 남는다 (2026-10-29 리뷰). 앞부분은 들어온 모양 그대로 둔다
*/
const REDACTED_PATH = /^(\/api\/mail-images\/)([^/?#]+)/i;
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
function decodeQueryPart(part: string): string {
try {
@@ -43,11 +51,16 @@ export function maskEmail(email: string): string {
/**
* 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). 외부 이미지 중계의 표(경로)도 길이만 (REDACTED_PATH).
*
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>'
* '/api/mail-images/AbC…(120자)' → '/api/mail-images/<120자>'
*/
export function maskUrlForLog(url: string): string {
export function maskUrlForLog(rawUrl: string): string {
const url = rawUrl.replace(
REDACTED_PATH,
(_whole, prefix: string, token: string) => `${prefix}<${token.length}자>`,
);
const queryStart = url.indexOf('?');
if (queryStart < 0) return url;
@@ -0,0 +1,89 @@
import { isPublicIpAddress } from './public-address';
describe('isPublicIpAddress — 바깥 이미지 중계가 연결해도 되는 주소', () => {
it.each([
'8.8.8.8',
'1.1.1.1',
'93.184.216.34',
'172.15.255.255',
'172.32.0.1',
'100.63.255.255',
'100.128.0.1',
'192.0.1.1',
'198.17.255.255',
'198.20.0.1',
'223.255.255.255',
'2606:4700:4700::1111',
'2001:4860:4860::8888',
'2a00:1450:4001:80b::200e',
])('공인 주소 %s 는 받는다', (address) => {
expect(isPublicIpAddress(address)).toBe(true);
});
it.each([
['이 망', '0.0.0.0'],
['이 망', '0.1.2.3'],
['사설', '10.0.0.1'],
['사설', '172.16.0.1'],
['사설 — 도커 앱 망(APP_NETWORK_SUBNET 기본)', '172.16.71.10'],
['사설 — 도커 edge 망(EDGE_NETWORK_SUBNET 기본)', '172.16.72.3'],
['사설', '172.31.255.255'],
['사설', '192.168.0.1'],
['CGNAT', '100.64.0.1'],
['CGNAT', '100.127.255.255'],
['루프백', '127.0.0.1'],
['루프백', '127.255.255.254'],
['링크 로컬(클라우드 메타데이터)', '169.254.169.254'],
['IETF 프로토콜용', '192.0.0.8'],
['문서용', '192.0.2.1'],
['6to4 릴레이', '192.88.99.1'],
['벤치마크', '198.18.0.1'],
['벤치마크', '198.19.255.255'],
['문서용', '198.51.100.7'],
['문서용', '203.0.113.9'],
['멀티캐스트', '224.0.0.1'],
['멀티캐스트', '239.255.255.250'],
['예약', '240.0.0.1'],
['브로드캐스트', '255.255.255.255'],
])('IPv4 %s 대역(%s)은 막는다', (_label, address) => {
expect(isPublicIpAddress(address)).toBe(false);
});
it.each([
['루프백', '::1'],
['지정 안 됨', '::'],
['IPv4 대응 — 루프백', '::ffff:127.0.0.1'],
['IPv4 대응 — 공인 주소여도 막는다', '::ffff:8.8.8.8'],
['IPv4 대응(16진)', '::ffff:7f00:1'],
['IPv4 호환(옛)', '::127.0.0.1'],
['NAT64', '64:ff9b::7f00:1'],
['NAT64 로컬', '64:ff9b:1::1'],
['버리는 대역', '100::1'],
['고유 로컬', 'fc00::1'],
['고유 로컬', 'fd12:3456:789a::1'],
['링크 로컬', 'fe80::1'],
['사이트 로컬(옛)', 'fec0::1'],
['멀티캐스트', 'ff02::1'],
['Teredo', '2001:0:4136:e378:8000:63bf:3fff:fdd2'],
['IETF 프로토콜용', '2001:2::1'],
['문서용', '2001:db8::1'],
['문서용(새)', '3fff::1'],
['6to4 — 안에 사설 IPv4', '2002:c0a8:101::1'],
])('IPv6 %s 대역(%s)은 막는다', (_label, address) => {
expect(isPublicIpAddress(address)).toBe(false);
});
it.each([
'',
'localhost',
'example.com',
'[2001:4860:4860::8888]',
'8.8.8',
'256.1.1.1',
'08.8.8.8',
'fe80::1%eth0',
'2001:4860:4860::8888%eth0',
])('IP 주소가 아닌 글 · 영역이 붙은 주소 %j 은 막는다', (value) => {
expect(isPublicIpAddress(value)).toBe(false);
});
});
@@ -0,0 +1,68 @@
import { BlockList, isIP } from 'net';
/**
* 공인(인터넷에서 닿는) IP 주소인가 — 순수 함수. 바깥 이미지 중계(계약 2절 "외부 이미지 보기")가 연결하기 전에 본다.
* 이 서버가 바깥으로 대신 묻는 곳이 사내 망 · 자기 자신 · 도커 망(메일 엔진 관리 API · DB)이 되지 않게 하는 막이다 (SSRF).
*
* - IPv4: 아래 특수 대역이 아니면 공인 — 사설(10/8 · 172.16/12 · 192.168/16) · 루프백 · 링크 로컬 · CGNAT(100.64/10) ·
* "이 망"(0/8) · IETF 프로토콜용(192.0.0/24) · 문서용 셋 · 6to4 릴레이(192.88.99/24) · 벤치마크(198.18/15) · 멀티캐스트 · 예약(240/4, 브로드캐스트 포함)
* - IPv6: 전역 유니캐스트(2000::/3) 안에서만 공인으로 본다(허용 목록) — 그 밖(::1 · :: · ::ffff:0:0/96(IPv4 대응) · 64:ff9b::/96 · 64:ff9b:1::/48(NAT64) ·
* 100::/64 · fc00::/7 · fe80::/10 · fec0::/10 · ff00::/8 …)은 모두 막는다. 2000::/3 안의 특수 대역 — IETF 프로토콜용(2001::/23 — Teredo 포함) ·
* 문서용(2001:db8::/32 · 3fff::/20) · 6to4(2002::/16 — 안에 IPv4 를 담아 사설 대역으로 닿을 수 있다)도 막는다
* - 우리 도커 망(APP_NETWORK_SUBNET · EDGE_NETWORK_SUBNET)은 사설 대역이라 함께 막힌다 (계약 2절)
* - IP 주소가 아닌 글은 공인이 아니다
*/
/** IPv4 특수 대역 [주소, 접두 길이] — RFC 6890 의 표와 같다 */
const IPV4_SPECIAL: readonly (readonly [string, number])[] = [
['0.0.0.0', 8],
['10.0.0.0', 8],
['100.64.0.0', 10],
['127.0.0.0', 8],
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.0.0.0', 24],
['192.0.2.0', 24],
['192.88.99.0', 24],
['192.168.0.0', 16],
['198.18.0.0', 15],
['198.51.100.0', 24],
['203.0.113.0', 24],
['224.0.0.0', 4],
['240.0.0.0', 4],
];
/** IPv6 전역 유니캐스트 — 이 안에서만 공인으로 본다 */
const IPV6_GLOBAL_UNICAST: readonly [string, number] = ['2000::', 3];
/** 전역 유니캐스트 안의 특수 대역 */
const IPV6_SPECIAL: readonly (readonly [string, number])[] = [
['2001::', 23],
['2001:db8::', 32],
['2002::', 16],
['3fff::', 20],
];
const blockedV4 = new BlockList();
for (const [network, prefix] of IPV4_SPECIAL) {
blockedV4.addSubnet(network, prefix, 'ipv4');
}
const globalV6 = new BlockList();
globalV6.addSubnet(IPV6_GLOBAL_UNICAST[0], IPV6_GLOBAL_UNICAST[1], 'ipv6');
const blockedV6 = new BlockList();
for (const [network, prefix] of IPV6_SPECIAL) {
blockedV6.addSubnet(network, prefix, 'ipv6');
}
/** IP 주소 글(대괄호 없이)이 공인 주소인가 — IP 가 아니면 false. 영역 표시(`%eth0`)가 붙은 IPv6 도 막는다(링크 안에서만 닿는 주소) */
export function isPublicIpAddress(address: string): boolean {
if (address.includes('%')) return false;
const family = isIP(address);
if (family === 4) return !blockedV4.check(address, 'ipv4');
if (family === 6) {
return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
}
return false;
}
+781 -2
View File
@@ -40,9 +40,17 @@ import {
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import {
MAIL_IMAGE_RESOLVER,
MAIL_IMAGE_TRANSPORT,
type MailImageRequest,
type MailImageResponse,
type ResolvedAddress,
} from '@/modules/mail/images/mail-image-fetcher';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
BlockedSender,
Correspondence,
DraftContent,
DraftSource,
@@ -50,6 +58,8 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailBlockAdded,
MailBlockList,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -60,6 +70,7 @@ import type {
MailWatch,
MailWatchListener,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
MessageSource,
MessagesPatchResult,
@@ -67,6 +78,7 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
PendingSend,
RecipientRejectionReason,
SavedDraft,
SentMessage,
@@ -224,6 +236,25 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
textBody: '',
};
/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
/**
* 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
* 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
*/
const HELD_MESSAGE = 'eheldbyengine';
/** 9단계 — 외부 이미지가 든 메일 (대역 엔진의 GET /api/messages/{id}) · 그 바깥 주소 */
const IMAGE_MESSAGE = 'imagemessage';
const PUBLIC_IMAGE_URL = 'https://images.e2e.example/logo.png';
const INTERNAL_IMAGE_URL = 'https://intranet.e2e.example/secret.png';
/** 대역 이름 풀기 — 공인 주소 · 사설 주소 (그 밖의 이름은 풀지 못한다) */
const IMAGE_HOSTS = new Map<string, ResolvedAddress[]>([
['images.e2e.example', [{ address: '93.184.216.34', family: 4 }]],
['intranet.e2e.example', [{ address: '10.0.0.7', family: 4 }]],
]);
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
const RAW_MESSAGE =
@@ -413,6 +444,37 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate;
}[] = [];
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
const mailBlocks = new Map<string, BlockedSender[]>();
const scheduledMessages = new Map<string, MessageSummary[]>();
const canceledSends: { user: string; id: string }[] = [];
/** 대역 엔진이 받은 한 통 삭제 (DELETE /api/messages/{id}) */
const deletedMessages: { user: string; id: string }[] = [];
const imageRequests: MailImageRequest[] = [];
const fakeImageResolver = {
resolve: (hostname: string): Promise<ResolvedAddress[]> => {
const addresses = IMAGE_HOSTS.get(hostname);
return addresses
? Promise.resolve(addresses)
: Promise.reject(new Error('e2e-unknown-host'));
},
};
const fakeImageTransport = {
get: (imageRequest: MailImageRequest): Promise<MailImageResponse> => {
imageRequests.push(imageRequest);
const data = pngBytes(48, 16);
return Promise.resolve({
status: 200,
location: null,
contentLength: data.length,
body: (async function* () {
yield await Promise.resolve(new Uint8Array(data));
})(),
close: () => undefined,
});
},
};
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
const liveWatches: {
user: string;
@@ -636,9 +698,21 @@ describe('응답 계약 (e2e)', () => {
);
}
sentMessages.push({ user, message });
// 9단계 — 엔진이 붙잡았으면 보낼 시각과 종류 (실제 계산은 jmap/pending-send.ts)
const hold = message.hold;
const pendingSend: PendingSend | null = hold
? {
kind: hold.kind,
sendAt:
hold.kind === 'scheduled'
? hold.until
: new Date(Date.now() + hold.seconds * 1_000).toISOString(),
}
: null;
return Promise.resolve({
id: `sent${sentMessages.length}`,
rejectedRecipients,
pendingSend,
});
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
@@ -689,6 +763,7 @@ describe('응답 계약 (e2e)', () => {
attachments: [],
inReplyTo: null,
updatedAt,
isCanceledSend: false,
});
},
deleteDraft: (user: string, id: string) => {
@@ -744,9 +819,14 @@ describe('응답 계약 (e2e)', () => {
}),
);
}
// 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 failedIds 로 (9단계)
const moving =
update.mailboxId !== undefined || update.isJunk !== undefined;
const fails = (id: string) =>
id === GONE_MESSAGE || (moving && id === HELD_MESSAGE);
return Promise.resolve({
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
failedIds: ids.filter((id) => id === GONE_MESSAGE),
updatedIds: ids.filter((id) => !fails(id)),
failedIds: ids.filter(fails),
});
},
deleteFromTrash: (
@@ -964,6 +1044,16 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate,
): Promise<MessageSummary> => {
singleUpdates.push({ user, id, update });
if (
id === HELD_MESSAGE &&
(update.mailboxId !== undefined || update.isJunk !== undefined)
) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
@@ -977,8 +1067,125 @@ describe('응답 계약 (e2e)', () => {
isFlagged: false,
hasAttachment: false,
size: 1,
pendingSend: null,
});
},
// 9단계 — 메일 읽기 (외부 이미지 확인용 한 통만). 정화 · 그림 판정은 html/*.spec.ts · mail.service.spec.ts 가 본다
getMessage: (
user: string,
id: string,
): Promise<MessageDetailSource> => {
if (id !== IMAGE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
return Promise.resolve({
id,
threadId: id,
mailboxIds: ['a'],
from: { name: '바깥 보낸 사람', email: 'news@letters.e2e.example' },
to: [{ name: null, email: user }],
subject: '그림이 든 메일',
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2_000,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: 'image@e2e',
references: [],
rawHtmlBody: `<p>안녕하세요</p><img src="${PUBLIC_IMAGE_URL}" alt="로고"><img src="${INTERNAL_IMAGE_URL}">`,
isRawHtmlBodyTruncated: false,
textBody: '안녕하세요',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
isInJunkMailbox: false,
});
},
// 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다)
getMailBlocks: (user: string): Promise<MailBlockList> =>
Promise.resolve({
senders: [...(mailBlocks.get(user) ?? [])],
isActive: true,
}),
addMailBlock: (
user: string,
sender: BlockedSender,
): Promise<MailBlockAdded> => {
const current = mailBlocks.get(user) ?? [];
const existing = current.find(
({ address }) => address === sender.address,
);
if (existing) {
return Promise.resolve({ sender: existing, created: false });
}
mailBlocks.set(user, [sender, ...current]);
return Promise.resolve({ sender, created: true });
},
removeMailBlock: (user: string, address: string): Promise<void> => {
const current = mailBlocks.get(user) ?? [];
if (!current.some((entry) => entry.address === address)) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mail-block-not-found' },
}),
);
}
mailBlocks.set(
user,
current.filter((entry) => entry.address !== address),
);
return Promise.resolve();
},
// 한 통 삭제 — 붙잡힌 메일은 409 MAIL_015 (9단계), 없으면 RES_001 (조건부 영구 삭제는 jmap-mail.provider.spec.ts)
deleteMessage: (user: string, id: string): Promise<void> => {
if (id === HELD_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_015', {
context: { reason: 'e2e-held-send-locked' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
deletedMessages.push({ user, id });
return Promise.resolve();
},
// 9단계 — 붙잡은 발송 (엔진의 기다리는 제출 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
listScheduledMessages: (user: string): Promise<MessageSummary[]> =>
Promise.resolve(scheduledMessages.get(user) ?? []),
cancelPendingSend: (user: string, id: string): Promise<void> => {
if (id === ALREADY_RELEASED_MESSAGE) {
return Promise.reject(
new BusinessException('MAIL_014', {
context: { reason: 'e2e-not-a-pending-send' },
}),
);
}
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
canceledSends.push({ user, id });
return Promise.resolve();
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -986,6 +1193,11 @@ describe('응답 계약 (e2e)', () => {
.useValue(fakeContactsProvider)
.overrideProvider(DIRECTORY_PROVIDER)
.useValue(fakeDirectoryProvider)
// 9단계 — 바깥 이미지 받기는 실제 네트워크에 나가지 않는다 (SSRF 검사 · 다시 가기 · 크기는 images/*.spec.ts)
.overrideProvider(MAIL_IMAGE_RESOLVER)
.useValue(fakeImageResolver)
.overrideProvider(MAIL_IMAGE_TRANSPORT)
.useValue(fakeImageTransport)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1023,6 +1235,7 @@ describe('응답 계약 (e2e)', () => {
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
'DELETE FROM mail_directory WHERE email LIKE $1',
'DELETE FROM mail_settings WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
@@ -3189,6 +3402,8 @@ describe('응답 계약 (e2e)', () => {
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
// 9단계 — 처음 설정은 보내기 취소 10초: 엔진이 붙잡았다가 보낸다
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
},
});
});
@@ -3209,6 +3424,7 @@ describe('응답 계약 (e2e)', () => {
{ email: NOBODY, reason: 'not-found' },
{ email: BUSY, reason: 'temporary' },
],
pendingSend: { kind: 'undo', sendAt: expect.any(String) as string },
});
expect(deletedDrafts).toContain(draftId);
});
@@ -4376,6 +4592,565 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 보낸 사람 차단 · 보내기와 읽기 설정 (계약 2절).
* 엔진 쪽(붙잡기 · 취소 · Sieve · 바깥 받기의 SSRF 검사)은 jmap/*.spec.ts · images/*.spec.ts 가 본다 —
* 여기서는 HTTP 형식 · 요청 검사 · 설정(DB 의 mail_settings)이 발송과 읽기에 닿는지를 본다
*/
describe('9단계 — 보내기 취소 · 예약 · 외부 이미지 · 차단 · 설정', () => {
const patchSettings = (email: string, payload: object) =>
request(server)
.patch('/api/mail-settings')
.set('Cookie', cookieOf(email))
.send(payload);
describe('보내기 · 읽기 설정 (/api/mail-settings)', () => {
const ME = 'settings.me@e2e.arasystem.test';
it('로그인하지 않았으면 401 AUTH_001', async () => {
for (const res of [
await request(server).get('/api/mail-settings'),
await request(server)
.patch('/api/mail-settings')
.send({ undoSendSeconds: 5 }),
]) {
expect(res.status).toBe(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('처음에는 처음 값 — 보내기 취소 10초 · 외부 이미지 늘 묻기 (DB 에 줄이 없어도)', async () => {
const res = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: { undoSendSeconds: 10, externalImages: 'ask' },
});
});
it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => {
const first = await patchSettings(ME, {
externalImages: 'always',
}).expect(200);
expect(dataOf<unknown>(first)).toEqual({
undoSendSeconds: 10,
externalImages: 'always',
});
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
200,
);
expect(dataOf<unknown>(second)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const read = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const rows: {
undo_send_seconds: number;
external_images: string;
}[] = await dataSource.query(
'SELECT undo_send_seconds, external_images FROM mail_settings WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([
{ undo_send_seconds: 30, external_images: 'always' },
]);
});
it.each<[string, object]>([
['빈 본문(하나 이상)', {}],
['고를 수 없는 초', { undoSendSeconds: 7 }],
['글로 쓴 초', { undoSendSeconds: '10' }],
['null 초', { undoSendSeconds: null }],
['모르는 외부 이미지 값', { externalImages: 'never' }],
['null 외부 이미지', { externalImages: null }],
['모르는 칸', { theme: 'dark' }],
])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => {
const res = await patchSettings(ME, payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
});
describe('보내기 취소 · 예약 발송 (POST /api/messages · /api/scheduled-messages)', () => {
const ME = 'schedule.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * MINUTE_MS;
const send = (payload: object = {}) =>
request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '제목', body: '본문', ...payload });
const lastHold = () =>
sentMessages[sentMessages.length - 1]?.message.hold;
const pendingOf = (res: Response) =>
dataOf<{ pendingSend: PendingSend | null }>(res).pendingSend;
const summary = (id: string, sendAt: string): MessageSummary => ({
id,
threadId: id,
mailboxIds: ['e'],
from: { name: '나', email: ME },
to: [DOYUN],
subject: `예약 ${id}`,
preview: '',
receivedAt: '2026-10-29T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: { kind: 'scheduled', sendAt },
});
it('sendAt 이 있으면 그 시각(소수 초는 버린다)까지 붙잡는다 — 201 의 pendingSend 는 예약, 보내기 취소 시간을 더하지 않는다', async () => {
const second =
Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000;
const until = new Date(second).toISOString();
const res = await send({
sendAt: new Date(second + 750).toISOString(),
}).expect(201);
expect(pendingOf(res)).toEqual({ kind: 'scheduled', sendAt: until });
expect(lastHold()).toEqual({ kind: 'scheduled', until });
});
it.each<[string, () => string]>([
[
'4분 뒤 — 5분보다 이르다',
() => new Date(Date.now() + 4 * MINUTE_MS).toISOString(),
],
[
'7일하고 1분 뒤',
() => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(),
],
['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()],
['UTC 가 아님(+09:00)', () => '2030-12-01T09:00:00+09:00'],
['날짜가 아님', () => '내일 아침'],
])(
'예약 시각이 %s 이면 400 VAL_001 — 엔진에 보내지 않는다',
async (_label, sendAt) => {
const before = sentMessages.length;
const res = await send({ sendAt: sendAt() }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(before);
},
);
it('sendAt 이 없으면 설정의 보내기 취소 초만큼 붙잡는다 — 0초면 붙잡지 않고 pendingSend 는 null', async () => {
const undo = await send().expect(201);
expect(pendingOf(undo)).toEqual({
kind: 'undo',
sendAt: expect.any(String) as string,
});
expect(lastHold()).toEqual({ kind: 'undo', seconds: 10 });
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
const now = await send().expect(201);
expect(pendingOf(now)).toBeNull();
expect(lastHold()).toBeUndefined();
await patchSettings(ME, { undoSendSeconds: 20 }).expect(200);
await send().expect(201);
expect(lastHold()).toEqual({ kind: 'undo', seconds: 20 });
});
it('예약한 메일 — { items, pageInfo }(total 포함), 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => {
const at = (minutes: number) =>
new Date(Date.UTC(2030, 0, 1, 9, minutes)).toISOString();
scheduledMessages.set(ME, [
summary('s1', at(0)),
summary('s2', at(10)),
summary('s3', at(20)),
]);
const first = await request(server)
.get('/api/scheduled-messages?limit=2')
.set('Cookie', cookieOf(ME))
.expect(200);
const page = dataOf<Page<MessageSummary>>(first);
expect(page.items.map(({ id }) => id)).toEqual(['s1', 's2']);
expect(page.items[0].pendingSend).toEqual({
kind: 'scheduled',
sendAt: at(0),
});
expect(page.pageInfo).toEqual({
hasNext: true,
nextCursor: expect.any(String) as string,
total: 3,
});
const second = await request(server)
.get(
`/api/scheduled-messages?limit=2&cursor=${encodeURIComponent(page.pageInfo.nextCursor ?? '')}`,
)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<Page<MessageSummary>>(second)).toEqual({
items: [expect.objectContaining({ id: 's3' }) as MessageSummary],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
});
const broken = await request(server)
.get('/api/scheduled-messages?cursor=bm90LWEtY3Vyc29y')
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(broken).error?.code).toBe('VAL_001');
await request(server).get('/api/scheduled-messages').expect(401);
});
it('취소 — 200 { draftId }(같은 메일 id), 이미 보냈으면 409 MAIL_014, 없거나 id 형식이 틀리면 404 RES_001 (엔진에 묻지 않는다)', async () => {
const cancel = (id: string) =>
request(server)
.delete(`/api/scheduled-messages/${id}`)
.set('Cookie', cookieOf(ME));
const ok = await cancel('eheldmsg1').expect(200);
expect(body(ok)).toEqual({
success: true,
data: { draftId: 'eheldmsg1' },
});
expect(canceledSends).toContainEqual({ user: ME, id: 'eheldmsg1' });
const released = await cancel(ALREADY_RELEASED_MESSAGE).expect(409);
expect(body(released).error?.code).toBe('MAIL_014');
const gone = await cancel(GONE_MESSAGE).expect(404);
expect(body(gone).error?.code).toBe('RES_001');
const before = canceledSends.length;
const malformed = await cancel('a"b').expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
expect(canceledSends).toHaveLength(before);
});
it('같은 발송을 두 번 취소해도 둘 다 200 { draftId } — 응답을 받지 못한 재시도 (멱등은 jmap-mail.provider.spec.ts)', async () => {
const cancel = () =>
request(server)
.delete('/api/scheduled-messages/etwicecanceled')
.set('Cookie', cookieOf(ME));
for (const res of [await cancel(), await cancel()]) {
expect(res.status).toBe(200);
expect(body(res)).toEqual({
success: true,
data: { draftId: 'etwicecanceled' },
});
}
});
describe('붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (409 MAIL_015)', () => {
const patchOne = (payload: object) =>
request(server)
.patch(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.send(payload);
it.each<[string, object]>([
['옮기기', { mailboxId: 'b' }],
['스팸 신고', { isJunk: true }],
['옮기기와 읽음 함께', { mailboxId: 'b', isRead: true }],
])(
'한 통 %s 는 409 MAIL_015 — 표준 실패 형식',
async (_label, payload) => {
const res = await patchOne(payload).expect(409);
expect(body(res)).toEqual({
success: false,
error: {
code: 'MAIL_015',
message:
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
traceId: expect.any(String) as string,
},
});
},
);
it('한 통의 읽음 · 중요 표시는 된다 — 200', async () => {
const res = await patchOne({ isFlagged: true, isRead: true }).expect(
200,
);
expect(dataOf<MessageSummary>(res).id).toBe(HELD_MESSAGE);
});
it('여러 통 옮기기는 200 — 붙잡힌 메일만 failedIds, 나머지는 옮긴다', async () => {
const res = await request(server)
.patch('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ ids: ['eaaaaab', HELD_MESSAGE], mailboxId: 'b' })
.expect(200);
expect(dataOf<MessagesPatchResult>(res)).toEqual({
updatedIds: ['eaaaaab'],
failedIds: [HELD_MESSAGE],
});
});
it('한 통 삭제(DELETE)는 409 MAIL_015 — 지우지 않는다', async () => {
const before = deletedMessages.length;
const res = await request(server)
.delete(`/api/messages/${HELD_MESSAGE}`)
.set('Cookie', cookieOf(ME))
.expect(409);
expect(body(res).error?.code).toBe('MAIL_015');
expect(deletedMessages).toHaveLength(before);
// 붙잡히지 않은 메일은 지금처럼 204
await request(server)
.delete('/api/messages/eaaaaab')
.set('Cookie', cookieOf(ME))
.expect(204);
expect(deletedMessages).toContainEqual({ user: ME, id: 'eaaaaab' });
});
});
});
describe('외부 이미지 (GET /api/messages/{id}?images=show · GET /api/mail-images/{token})', () => {
const ME = 'images.me@e2e.arasystem.test';
const OTHER = 'images.other@e2e.arasystem.test';
interface ReadDetail {
htmlBody: string | null;
hasBlockedImages: boolean;
imagesShown: boolean;
isInJunkMailbox?: unknown;
}
const read = (query = '', who = ME) =>
request(server)
.get(`/api/messages/${IMAGE_MESSAGE}${query}`)
.set('Cookie', cookieOf(who));
/** 본문의 중계 주소들 (sanitize-html 이 & 를 &amp; 로 쓰지 않는 base64url 표) */
const proxiedSources = (html: string | null) =>
[...(html ?? '').matchAll(/src="(\/api\/mail-images\/[^"]+)"/g)].map(
(match) => match[1],
);
it('images 의 모르는 값 · 모르는 쿼리는 400 VAL_001', async () => {
for (const query of ['?images=1', '?images=SHOW', '?show=1']) {
const res = await read(query).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('묻기(처음 설정)면 바깥 그림을 지우고 hasBlockedImages · imagesShown false — 스팸메일함 여부는 응답에 싣지 않는다', async () => {
const res = await read().expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.hasBlockedImages).toBe(true);
expect(detail.imagesShown).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
expect(proxiedSources(detail.htmlBody)).toEqual([]);
expect(detail).not.toHaveProperty('isInJunkMailbox');
});
it('images=show 면 그림 주소를 이 서버의 중계 주소로 바꾼다 — 받은 그림은 표준 래퍼 없이 그대로, 머리글은 계약대로', async () => {
const res = await read('?images=show').expect(200);
const detail = dataOf<ReadDetail>(res);
expect(detail.imagesShown).toBe(true);
expect(detail.hasBlockedImages).toBe(false);
expect(detail.htmlBody).not.toContain('e2e.example');
const [publicImage, internalImage] = proxiedSources(detail.htmlBody);
expect(internalImage).toBeDefined();
const before = imageRequests.length;
// superagent 는 image/* 를 Buffer 로 받는다
const image = await request(server)
.get(publicImage)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(Buffer.isBuffer(image.body)).toBe(true);
expect(image.body as Buffer).toEqual(pngBytes(48, 16));
expect(image.headers['content-type']).toBe('image/png');
expect(image.headers['content-length']).toBe(
String(pngBytes(48, 16).length),
);
expect(image.headers['x-content-type-options']).toBe('nosniff');
expect(image.headers['cache-control']).toBe('private, max-age=3600');
expect(image.headers['content-disposition']).toBe('inline');
expect(image.headers['content-security-policy']).toBe(
"default-src 'none'; sandbox",
);
// 바깥으로는 확인한 주소로만 · 쿠키 · Referer 없이
expect(imageRequests).toHaveLength(before + 1);
const sent = imageRequests[imageRequests.length - 1];
expect(sent.url.href).toBe(PUBLIC_IMAGE_URL);
expect(sent.addresses).toEqual([
{ address: '93.184.216.34', family: 4 },
]);
expect(Object.keys(sent.headers).sort()).toEqual([
'accept',
'accept-encoding',
'user-agent',
]);
// 사설 주소로 풀리는 이름은 묻지 않고 404 RES_001 (까닭을 가리지 않는다)
const blocked = await request(server)
.get(internalImage)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(blocked)).toEqual({
success: false,
error: {
code: 'RES_001',
message: expect.any(String) as string,
traceId: blocked.headers['x-request-id'],
},
});
expect(imageRequests).toHaveLength(before + 1);
});
it('표는 그 직원 것만 — 다른 직원 · 고친 표 · 없는 표는 404 RES_001, 로그인하지 않았으면 401 AUTH_001', async () => {
const [source] = proxiedSources(
dataOf<ReadDetail>(await read('?images=show').expect(200)).htmlBody,
);
const token = source.slice('/api/mail-images/'.length);
const tampered = `${token.slice(0, -2)}${token.endsWith('AA') ? 'BB' : 'AA'}`;
for (const [path, who] of [
[source, OTHER],
[`/api/mail-images/${tampered}`, ME],
['/api/mail-images/not-a-token', ME],
] as const) {
const res = await request(server)
.get(path)
.set('Cookie', cookieOf(who))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
const anonymous = await request(server).get(source).expect(401);
expect(body(anonymous).error?.code).toBe('AUTH_001');
});
it('설정이 늘 보기(always)면 images 없이도 보인다', async () => {
await patchSettings(ME, { externalImages: 'always' }).expect(200);
const detail = dataOf<ReadDetail>(await read().expect(200));
expect(detail.imagesShown).toBe(true);
expect(proxiedSources(detail.htmlBody)).toHaveLength(2);
await patchSettings(ME, { externalImages: 'ask' }).expect(200);
});
});
describe('보낸 사람 차단 (/api/mail-blocks)', () => {
const ME = 'blocks.me@e2e.arasystem.test';
const add = (address: unknown, extra: object = {}) =>
request(server)
.post('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.send({ address, ...extra });
const list = async () =>
dataOf<{ senders: BlockedSender[]; isActive: boolean }>(
await request(server)
.get('/api/mail-blocks')
.set('Cookie', cookieOf(ME))
.expect(200),
);
it('처음에는 빈 목록 · 동작함, 로그인하지 않았으면 401 AUTH_001', async () => {
await expect(list()).resolves.toEqual({ senders: [], isActive: true });
const res = await request(server).get('/api/mail-blocks').expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('차단 — 앞뒤 공백을 빼고 소문자로, 새로 차단하면 201 · 이미 있으면 200(처음 차단한 시각) · 목록은 최근 것부터', async () => {
const created = await add(' Kim@Spam.Example ').expect(201);
const kim = dataOf<BlockedSender>(created);
expect(kim).toEqual({
address: 'kim@spam.example',
blockedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.+Z$/) as string,
});
const again = await add('kim@spam.example').expect(200);
expect(dataOf<BlockedSender>(again)).toEqual(kim);
await add('@Ads.Example.org').expect(201);
// 여러 사람이 쓰는 메일 서비스도 주소로는 막는다
await add('friend@gmail.com').expect(201);
expect((await list()).senders.map(({ address }) => address)).toEqual([
'friend@gmail.com',
'@ads.example.org',
'kim@spam.example',
]);
});
it.each<[string, unknown]>([
['내 주소(대소문자 무시)', ME.toUpperCase()],
['사내 도메인', '@e2e.arasystem.test'],
['사내 하위 도메인', '@mail.e2e.arasystem.test'],
['사내 직원 주소', 'doyun.lee@e2e.arasystem.test'],
['여러 사람이 쓰는 메일 서비스의 도메인', '@Naver.com'],
['주소 모양이 아님', 'not an address'],
['${ 가 든 주소', '${x}@spam.example'],
['IP 주소 도메인', '@127.0.0.1'],
['점 없는 도메인', '@localhost'],
['255자', `${'a'.repeat(240)}@spam.example`],
['빈 글', ' '],
['글이 아님', 42],
])(
'막을 수 없거나 모양이 틀리면 400 VAL_001 — %s',
async (_label, address) => {
const res = await add(address).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
},
);
it('모르는 칸이 있으면 400 VAL_001', async () => {
const res = await add('lee@spam.example', { note: '메모' }).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('차단 풀기 — URL 인코딩한 주소(대소문자 무시)는 200 { address }, 없거나 모양이 틀리면 404 RES_001', async () => {
await add('lee@spam.example').expect(201);
const removed = await request(server)
.delete(`/api/mail-blocks/${encodeURIComponent('LEE@spam.example')}`)
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(removed)).toEqual({
success: true,
data: { address: 'lee@spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('lee@spam.example');
// 도메인 차단도 같은 길 — `@` 를 인코딩한 경로(%40…)를 풀어 도메인으로 맞춘다 (대소문자 무시)
await add('@Domain-Spam.example').expect(201);
const domainRemoved = await request(server)
.delete('/api/mail-blocks/%40DOMAIN-spam.example')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(domainRemoved)).toEqual({
success: true,
data: { address: '@domain-spam.example' },
});
expect(
(await list()).senders.map(({ address }) => address),
).not.toContain('@domain-spam.example');
for (const path of [
encodeURIComponent('lee@spam.example'),
encodeURIComponent('not an address'),
]) {
const res = await request(server)
.delete(`/api/mail-blocks/${path}`)
.set('Cookie', cookieOf(ME))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -4406,6 +5181,10 @@ describe('응답 계약 (e2e)', () => {
['post', '/api/mail-attachments'],
['patch', '/api/contacts/abc'],
['post', '/api/contact-restorations'],
['patch', '/api/mail-settings'],
['post', '/api/mail-blocks'],
['delete', '/api/mail-blocks/kim%40spam.example'],
['delete', '/api/scheduled-messages/eaaaaab'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
async (method, path) => {
+10
View File
@@ -73,3 +73,13 @@ export function jpegBytes(
Buffer.from([0xff, 0xd9]),
]);
}
/** WebP 머리 — 'RIFF' 크기 'WEBP' 첫 덩어리(VP8 · VP8L · VP8X). `size` 바이트가 되게 0 으로 채운다 (바깥 이미지 중계 — 9단계) */
export function webpBytes(chunk = 'VP8 ', size = 30): Buffer {
const data = Buffer.alloc(Math.max(size, 20));
data.write('RIFF', 0, 'ascii');
data.writeUInt32LE(data.length - 8, 4);
data.write('WEBP', 8, 'ascii');
data.write(chunk, 12, 'ascii');
return data;
}
+3 -1
View File
@@ -25,7 +25,9 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.',
'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
};
@@ -25,7 +25,9 @@ export const ERROR_CODE_MESSAGES = {
MAIL_010: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
MAIL_011: '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
MAIL_012: '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
MAIL_013: '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.',
MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
} as const