generated from ttipo/project-base
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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단계) — 어댑터 안에서만 쓴다
|
||||
|
||||
@@ -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)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 이 & 를 & 로 쓰지 않는 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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user