From 0c3238936a21891108f3489152a261e5acf1a2de Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 10 Nov 2026 11:44:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=A3=BC=EC=86=8C=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=84=9C=EB=AA=85=20=C2=B7=20=EB=B6=80=EC=9E=AC=EC=A4=91=20?= =?UTF-8?q?=EC=84=9C=EC=8B=9D=20=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메일 16단계(개인 설정)의 서버다. - 주소록: POST /api/contact-import-checks · contact-imports · contact-deletions 로 여러 명을 한 번에 확인 · 만들기 · 지우기(화면이 준 uid 로 다시 보내도 두 번 들어가지 않는다), GET /api/contact-exports 로 vCard 3.0 · CSV(BOM · 한국어 머리 · 수식 막기) 내보내기 — 하나씩 만들면 사무실 요청 수 한도에 걸린다. - 여러 서명: /api/mail-signatures · /api/mail-signature-defaults, 서명마다 한 줄로 바꾸는 마이그레이션 (지금 서명은 "기본 서명"으로 — 값 보존), 그림 정리는 모든 서명의 합집합. 옛 /api/mail-signature 는 앱 첫 판을 위해 그대로. - 부재중: 서식 본문(multipart — 문자열 4,096바이트는 variables 조각으로)과 회사 밖 응답(봉투 도메인으로 가름 — 답장도 그 주소로만), 원본 판 3 은 새 칸을 쓴 직원만. - 2단계 인증: 켠 채 휴대폰 바꾸기(새 코드 확인 → 지금 코드 · 비밀번호로 엔진 요청 하나 — 꺼지는 틈 없음). 새 에러 코드 · 새 의존성 없음. Co-Authored-By: Claude Opus 5.5 --- .../1794269810995-MultipleMailSignatures.ts | 115 ++ .../account-security.provider.ts | 19 + .../account-security/dto/two-factor.dto.ts | 47 + .../two-factor-enrollment-id.pipe.ts | 21 + .../two-factor-enrollments.ts | 15 +- .../account-security/two-factor.controller.ts | 103 +- .../two-factor.service.spec.ts | 265 +++- .../account-security/two-factor.service.ts | 190 ++- backend/src/modules/contacts/address-book.ts | 5 +- .../modules/contacts/contact-export.spec.ts | 291 ++++ .../src/modules/contacts/contact-export.ts | 267 ++++ .../contacts/contact-exports.controller.ts | 90 ++ .../modules/contacts/contact-import.spec.ts | 171 +++ .../src/modules/contacts/contact-import.ts | 138 ++ .../contacts/contact-imports.controller.ts | 149 ++ .../modules/contacts/contacts.constants.ts | 22 + .../src/modules/contacts/contacts.module.ts | 11 + .../src/modules/contacts/contacts.provider.ts | 23 +- .../modules/contacts/contacts.service.spec.ts | 451 +++++- .../src/modules/contacts/contacts.service.ts | 343 ++++- .../src/modules/contacts/contacts.types.ts | 64 + .../contacts/dto/contact-export.dto.ts | 30 + .../contacts/dto/contact-import.dto.spec.ts | 175 +++ .../contacts/dto/contact-import.dto.ts | 202 +++ .../dto/mail-signature.dto.spec.ts | 121 ++ .../mail-signature/dto/mail-signature.dto.ts | 205 ++- .../mail-signature.constants.ts | 23 +- .../mail-signature.controller.ts | 35 +- .../mail-signature/mail-signature.entity.ts | 50 +- .../mail-signature/mail-signature.module.ts | 14 +- .../mail-signature.repository.ts | 162 +- .../mail-signature.service.spec.ts | 625 ++++++-- .../mail-signature/mail-signature.service.ts | 565 +++++-- .../mail-signature/mail-signature.types.ts | 44 +- .../mail-signatures.controller.ts | 184 +++ .../mail-signature/signature-image-id.pipe.ts | 27 +- .../mail/dto/mail-vacation.dto.spec.ts | 150 +- .../src/modules/mail/dto/mail-vacation.dto.ts | 132 +- .../mail/html/sanitize-outgoing-html.spec.ts | 56 + .../mail/html/sanitize-stored-html.spec.ts | 65 + .../modules/mail/html/sanitize-stored-html.ts | 54 + .../modules/mail/jmap/filter-source.spec.ts | 156 +- .../src/modules/mail/jmap/filter-source.ts | 147 +- .../jmap-account-security.provider.spec.ts | 75 + .../jmap/jmap-account-security.provider.ts | 41 +- .../mail/jmap/jmap-contacts.provider.spec.ts | 141 ++ .../mail/jmap/jmap-contacts.provider.ts | 58 +- .../mail/jmap/jmap-filter-script.spec.ts | 65 +- .../modules/mail/jmap/jmap-filter-script.ts | 29 +- .../mail/jmap/jmap-mail.provider.spec.ts | 11 +- .../modules/mail/jmap/jmap-ops-engine.spec.ts | 27 +- .../src/modules/mail/jmap/jmap-ops-engine.ts | 11 + backend/src/modules/mail/jmap/jmap.mapper.ts | 5 +- .../src/modules/mail/jmap/mime-text.spec.ts | 63 + backend/src/modules/mail/jmap/mime-text.ts | 32 +- .../modules/mail/jmap/sieve-script.spec.ts | 268 +++- backend/src/modules/mail/jmap/sieve-script.ts | 214 ++- backend/src/modules/mail/mail.constants.ts | 16 + backend/src/modules/mail/mail.service.spec.ts | 217 ++- backend/src/modules/mail/mail.service.ts | 72 +- backend/src/modules/mail/mail.types.ts | 23 +- .../src/modules/mail/mailbox.controller.ts | 11 +- backend/test/app.e2e-spec.ts | 1345 ++++++++++++++++- 63 files changed, 8282 insertions(+), 464 deletions(-) create mode 100644 backend/src/migrations/1794269810995-MultipleMailSignatures.ts create mode 100644 backend/src/modules/account-security/two-factor-enrollment-id.pipe.ts create mode 100644 backend/src/modules/contacts/contact-export.spec.ts create mode 100644 backend/src/modules/contacts/contact-export.ts create mode 100644 backend/src/modules/contacts/contact-exports.controller.ts create mode 100644 backend/src/modules/contacts/contact-import.spec.ts create mode 100644 backend/src/modules/contacts/contact-import.ts create mode 100644 backend/src/modules/contacts/contact-imports.controller.ts create mode 100644 backend/src/modules/contacts/dto/contact-export.dto.ts create mode 100644 backend/src/modules/contacts/dto/contact-import.dto.spec.ts create mode 100644 backend/src/modules/contacts/dto/contact-import.dto.ts create mode 100644 backend/src/modules/mail-signature/dto/mail-signature.dto.spec.ts create mode 100644 backend/src/modules/mail-signature/mail-signatures.controller.ts create mode 100644 backend/src/modules/mail/html/sanitize-stored-html.spec.ts create mode 100644 backend/src/modules/mail/html/sanitize-stored-html.ts diff --git a/backend/src/migrations/1794269810995-MultipleMailSignatures.ts b/backend/src/migrations/1794269810995-MultipleMailSignatures.ts new file mode 100644 index 0000000..e2feeeb --- /dev/null +++ b/backend/src/migrations/1794269810995-MultipleMailSignatures.ts @@ -0,0 +1,115 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계). + * mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다 + * (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts). + * - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정) + * - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음. + * 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다) + * - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스 + * - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다 + * 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**. + * 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다. + * (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다) + */ +export class MultipleMailSignatures1794269810995 implements MigrationInterface { + name = 'MultipleMailSignatures1794269810995'; + + public async up(queryRunner: QueryRunner): Promise { + // 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다) + await queryRunner.query( + `DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `UPDATE "mail_signatures" SET "created_at" = "updated_at"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`, + ); + // 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로) + await queryRunner.query( + `ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`, + ); + // 열쇠 — 주소 → 서명 id + await queryRunner.query( + `ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다 + await queryRunner.query( + `DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`, + ); + await queryRunner.query( + `DROP INDEX "public"."UQ_mail_signatures_new_message_default"`, + ); + await queryRunner.query( + `DROP INDEX "public"."UQ_mail_signatures_reply_default"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`, + ); + // 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true) + await queryRunner.query( + `ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`, + ); + await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`); + } +} diff --git a/backend/src/modules/account-security/account-security.provider.ts b/backend/src/modules/account-security/account-security.provider.ts index 12fb0d2..b9f6c77 100644 --- a/backend/src/modules/account-security/account-security.provider.ts +++ b/backend/src/modules/account-security/account-security.provider.ts @@ -43,6 +43,14 @@ export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown'; */ export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown'; +/** + * 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다: + * - replaced: 바꿨다 — 옛 비밀은 바로 죽는다 + * - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다) + * - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다 + */ +export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown'; + export interface AccountSecurityProvider { /** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */ isTwoFactorEnabled(user: string): Promise; @@ -64,6 +72,17 @@ export interface AccountSecurityProvider { otpCode: string, ): Promise; + /** + * 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로. + * 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다 + */ + replaceTwoFactor( + user: string, + currentPassword: string, + otpCode: string, + otpauthUrl: string, + ): Promise; + /** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */ listAppPasswords(user: string): Promise; diff --git a/backend/src/modules/account-security/dto/two-factor.dto.ts b/backend/src/modules/account-security/dto/two-factor.dto.ts index 769b229..0a0b720 100644 --- a/backend/src/modules/account-security/dto/two-factor.dto.ts +++ b/backend/src/modules/account-security/dto/two-factor.dto.ts @@ -101,6 +101,53 @@ export class TwoFactorActivationDto { currentPassword!: string; } +/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */ +export class TwoFactorVerificationDto { + @ApiProperty({ + description: + '새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)', + example: '123456', + }) + @OtpCodeField() + otpCode!: string; +} + +/** POST /api/two-factor/enrollments/{id}/verification → 200 */ +export class TwoFactorVerifiedDto { + @ApiProperty({ + description: + '늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)', + example: true, + }) + verified!: true; +} + +/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */ +export class TwoFactorReplacementDto { + @ApiProperty({ + description: + '확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)', + example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44', + }) + @IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' }) + enrollmentId!: string; + + @ApiProperty({ + description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다', + example: '654321', + }) + @OtpCodeField() + currentOtpCode!: string; + + @ApiProperty({ + description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`, + format: 'password', + example: 'current-password', + }) + @CurrentPasswordField() + currentPassword!: string; +} + /** POST /api/two-factor/deactivation */ export class TwoFactorDeactivationDto { @ApiProperty({ diff --git a/backend/src/modules/account-security/two-factor-enrollment-id.pipe.ts b/backend/src/modules/account-security/two-factor-enrollment-id.pipe.ts new file mode 100644 index 0000000..a193239 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-enrollment-id.pipe.ts @@ -0,0 +1,21 @@ +import { Injectable, PipeTransform } from '@nestjs/common'; +import { isUUID } from 'class-validator'; + +import { BusinessException } from '@/common/errors/business.exception'; + +/** + * 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001. + * 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008) + */ +@Injectable() +export class TwoFactorEnrollmentIdPipe implements PipeTransform< + unknown, + string +> { + transform(value: unknown): string { + if (typeof value === 'string' && isUUID(value, 'all')) return value; + throw new BusinessException('VAL_001', { + context: { reason: 'malformed-two-factor-enrollment-id' }, + }); + } +} diff --git a/backend/src/modules/account-security/two-factor-enrollments.ts b/backend/src/modules/account-security/two-factor-enrollments.ts index e6c1baa..777ef1e 100644 --- a/backend/src/modules/account-security/two-factor-enrollments.ts +++ b/backend/src/modules/account-security/two-factor-enrollments.ts @@ -12,13 +12,16 @@ export interface TwoFactorEnrollment { readonly secret: Buffer; /** 끝나는 때 (epoch ms) */ readonly expiresAt: number; + /** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */ + readonly verified?: boolean; } /** * 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분. - * 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다). + * 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다). + * 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다. * - * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다. + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다. */ @Injectable() export class TwoFactorEnrollments { @@ -45,6 +48,14 @@ export class TwoFactorEnrollments { return enrollment.id === id ? enrollment : null; } + /** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */ + markVerified(email: string, id: string, now: number = Date.now()): boolean { + const enrollment = this.find(email, id, now); + if (!enrollment) return false; + this.pending.set(email, { ...enrollment, verified: true }); + return true; + } + /** 등록을 버린다 — 비밀 바이트도 지운다 */ forget(email: string): void { const enrollment = this.pending.get(email); diff --git a/backend/src/modules/account-security/two-factor.controller.ts b/backend/src/modules/account-security/two-factor.controller.ts index 231c8d6..78f0628 100644 --- a/backend/src/modules/account-security/two-factor.controller.ts +++ b/backend/src/modules/account-security/two-factor.controller.ts @@ -5,6 +5,7 @@ import { HttpCode, HttpStatus, Ip, + Param, Post, UseGuards, } from '@nestjs/common'; @@ -25,8 +26,12 @@ import { TwoFactorActivationDto, TwoFactorDeactivationDto, TwoFactorEnrollmentDto, + TwoFactorReplacementDto, TwoFactorStatusDto, + TwoFactorVerificationDto, + TwoFactorVerifiedDto, } from './dto/two-factor.dto'; +import { TwoFactorEnrollmentIdPipe } from './two-factor-enrollment-id.pipe'; import { TwoFactorService, type TwoFactorStatusView, @@ -50,7 +55,7 @@ function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto { 'AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)', ) -// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010 +// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010 @Controller('two-factor') export class TwoFactorController { constructor(private readonly twoFactor: TwoFactorService) {} @@ -80,18 +85,19 @@ export class TwoFactorController { @AllowRestrictedSession() @NoStore() @ApiOperation({ - summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)', + summary: + '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)', description: - '서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' + + '서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' + + '16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' + '응답은 Cache-Control: private, no-store.', }) @ApiStandardResponse(TwoFactorEnrollmentDto, { status: 201, description: '등록 — 비밀 · otpauth 주소는 이 응답에만', }) - @ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다') @ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중') - @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + @ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음') async enroll( @CurrentUser() user: AuthenticatedUser, ): Promise { @@ -104,6 +110,93 @@ export class TwoFactorController { }; } + @Post('enrollments/:id/verification') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '휴대폰 바꾸기 ① — 새 휴대폰의 코드 확인 (16단계)', + description: + '새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 30초) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않는다. 엔진에는 아직 아무것도 바꾸지 않는다. ' + + '받은 코드는 2분 동안 다시 받지 않는다(코드 실패 한도 · 같은 코드 막기는 켜기 · 끄기와 같다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다. ' + + '제한된 세션은 403 AUTH_010.', + }) + @ApiStandardResponse(TwoFactorVerifiedDto, { description: '확인했다' }) + @ApiErrorResponse( + 'AUTH_006', + 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 코드 · 등록 id 형식 오류 (모두 400)', + ) + @ApiErrorResponse( + 'AUTH_008', + '등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 — 화면은 처음부터 다시(새 등록)', + ) + @ApiErrorResponse( + 'AUTH_010', + '제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)', + ) + @ApiErrorResponse( + 'REQ_001', + '코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 처리 중', + ) + async verifyEnrollment( + @CurrentUser() user: AuthenticatedUser, + @Param('id', TwoFactorEnrollmentIdPipe) id: string, + @Body() dto: TwoFactorVerificationDto, + @Ip() ip: string, + ): Promise { + await this.twoFactor.verifyEnrollment( + user.email, + { enrollmentId: id, otpCode: dto.otpCode }, + { ip }, + ); + return { verified: true }; + } + + @Post('replacements') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + '휴대폰 바꾸기 ② — 지금 휴대폰의 코드와 지금 비밀번호로 바꾼다 (16단계)', + description: + '①에서 확인한 등록만 받는다. 지금 비밀번호를 먼저 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, 그사이 2단계가 꺼졌으면 AUTH_008) ' + + '지금 휴대폰의 코드와 새 비밀을 엔진 요청 하나로 보낸다 — 2단계가 꺼지는 틈이 없고 옛 휴대폰의 코드는 바로 막힌다. ' + + '지금 코드가 틀리거나 2분 안에 다시 쓴 코드면 AUTH_006(코드 실패로 센다 — 등록은 남아 다시 보낼 수 있다). ' + + '결과를 모르면 503 MAIL_001 — 엔진이 비밀을 가려 보여 주어 다시 읽어도 가를 수 없다(등록은 남는다). 켠 때(enabledAt)는 그대로, 로그인된 기기는 끊지 않는다. ' + + '제한된 세션은 403 AUTH_010.', + }) + @ApiStandardResponse(TwoFactorStatusDto, { description: '바꿨다' }) + @ApiErrorResponse( + 'AUTH_006', + 'AUTH_006 지금 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)', + ) + @ApiErrorResponse( + 'AUTH_008', + '등록이 없음 · 10분 지남 · 더 새 등록 · 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 — 화면은 처음부터 다시', + ) + @ApiErrorResponse( + 'AUTH_010', + '제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)', + ) + @ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중') + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 결과를 알 수 없음(가를 수 없다 — 다시 보내기 전에 새 휴대폰의 코드로 로그인되는지 본다)', + ) + async replace( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: TwoFactorReplacementDto, + @Ip() ip: string, + ): Promise { + const status = await this.twoFactor.replace( + user.email, + { + enrollmentId: dto.enrollmentId, + currentOtpCode: dto.currentOtpCode, + currentPassword: dto.currentPassword, + }, + { ip }, + ); + return toStatusDto(await this.twoFactor.withPolicy(user.email, status)); + } + @Post('activation') @AllowRestrictedSession() @HttpCode(HttpStatus.OK) diff --git a/backend/src/modules/account-security/two-factor.service.spec.ts b/backend/src/modules/account-security/two-factor.service.spec.ts index fba2d95..34759ac 100644 --- a/backend/src/modules/account-security/two-factor.service.spec.ts +++ b/backend/src/modules/account-security/two-factor.service.spec.ts @@ -16,6 +16,7 @@ import type { AccountSecurityProvider, TwoFactorDisableResult, TwoFactorEnableResult, + TwoFactorReplaceResult, } from './account-security.provider'; import { TwoFactorCodeGuard } from './two-factor-code-guard'; import { TwoFactorEnrollments } from './two-factor-enrollments'; @@ -114,7 +115,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( let provider: jest.Mocked< Pick< AccountSecurityProvider, - 'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor' + | 'isTwoFactorEnabled' + | 'enableTwoFactor' + | 'disableTwoFactor' + | 'replaceTwoFactor' > >; let mail: { verifyCredentials: jest.Mock }; @@ -160,6 +164,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( engineEnabled = false; return Promise.resolve('disabled'); }), + replaceTwoFactor: jest.fn< + Promise, + [string, string, string, string] + >(() => Promise.resolve('replaced')), }; mail = { verifyCredentials: jest.fn(() => @@ -376,7 +384,7 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( ).toBe('AUTH_008'); }); - it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => { + it('그사이 다른 곳에서 켜졌으면 켜기는 409 AUTH_008 — 등록은 켜져 있어도 받는다(16단계 휴대폰 바꾸기용 · 엔진에 묻지 않는다)', async () => { const { enrollment, code } = await enroll(); engineEnabled = true; @@ -395,9 +403,11 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( ) )?.code, ).toBe('AUTH_008'); - expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe( - 'AUTH_008', - ); + provider.isTwoFactorEnabled.mockClear(); + await expect(service.startEnrollment(ARA)).resolves.toMatchObject({ + secret: expect.stringMatching(/^[A-Z2-7]{32}$/) as unknown, + }); + expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); }); it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => { @@ -579,6 +589,251 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( } }); + describe('휴대폰 바꾸기 (16단계 — 켠 채 새 비밀로)', () => { + const OLD_CODE = '135790'; + const enabledAt = new Date('2026-10-31T00:12:00.000Z'); + + beforeEach(() => { + engineEnabled = true; + table.rows.set(ARA, { userEmail: ARA, enabledAt }); + // 엔진 대역 — 지금 휴대폰의 코드는 OLD_CODE 하나만 맞다 + provider.replaceTwoFactor.mockImplementation((_user, _password, code) => + Promise.resolve(code === OLD_CODE ? 'replaced' : 'code-rejected'), + ); + }); + + /** 등록하고 새 휴대폰의 코드로 확인한다 */ + const verified = async () => { + const { enrollment, code } = await enroll(); + const newCode = code(); + await service.verifyEnrollment( + ARA, + { enrollmentId: enrollment.enrollmentId, otpCode: newCode }, + { ip: IP }, + ); + return { enrollment, newCode, code }; + }; + + const replace = ( + enrollmentId: string, + overrides: { currentOtpCode?: string; currentPassword?: string } = {}, + ip = IP, + ) => + service.replace( + ARA, + { + enrollmentId, + currentOtpCode: OLD_CODE, + currentPassword: PASSWORD, + ...overrides, + }, + { ip }, + ); + + it('① 새 휴대폰의 코드를 등록의 비밀로 확인(엔진에 묻지 않는다) → ② 지금 비밀번호(본인 Basic 402) → 지금 코드와 새 주소를 엔진 한 요청 — 켠 때는 그대로, 등록은 지운다', async () => { + const { enrollment, newCode } = await verified(); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.replaceTwoFactor).not.toHaveBeenCalled(); + expect(codes.isUsed(ARA, newCode)).toBe(true); + + const status = await replace(enrollment.enrollmentId); + + expect(status).toEqual({ isEnabled: true, enabledAt }); + expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD); + expect(provider.replaceTwoFactor).toHaveBeenCalledWith( + ARA, + PASSWORD, + OLD_CODE, + enrollment.otpauthUrl, + ); + expect(codes.isUsed(ARA, OLD_CODE)).toBe(true); + expect(table.rows.get(ARA)?.enabledAt).toEqual(enabledAt); + expect(logs).toContain( + '2단계 인증 휴대폰을 바꿨습니다: a***@arasystem.test', + ); + expect(logs.join('\n')).not.toMatch( + new RegExp(`${OLD_CODE}|${newCode}|${PASSWORD}|otpauth`), + ); + // 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다 + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'AUTH_008', + ); + }); + + it('확인하지 않은 등록은 409 AUTH_008 — 등록을 버린다(처음부터) · 없는 등록 · 다른 id 도 AUTH_008', async () => { + const { enrollment, code } = await enroll(); + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'AUTH_008', + ); + // 버렸다 — 확인도 할 수 없다 + expect( + ( + await failureOf( + service.verifyEnrollment( + ARA, + { enrollmentId: enrollment.enrollmentId, otpCode: code() }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_008'); + expect( + (await failureOf(replace('00000000-0000-4000-8000-000000000000'))) + ?.code, + ).toBe('AUTH_008'); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.replaceTwoFactor).not.toHaveBeenCalled(); + }); + + it('① 새 휴대폰의 코드가 틀리면 400 AUTH_006 — 코드 실패로 센다(15분 5회 → REQ_001), 확인되지 않는다', async () => { + const { enrollment, code } = await enroll(); + const wrong = code() === '000000' ? '111111' : '000000'; + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + expect( + ( + await failureOf( + service.verifyEnrollment( + ARA, + { enrollmentId: enrollment.enrollmentId, otpCode: wrong }, + { ip: `198.51.100.${i}` }, + ), + ) + )?.code, + ).toBe('AUTH_006'); + } + expect( + ( + await failureOf( + service.verifyEnrollment( + ARA, + { enrollmentId: enrollment.enrollmentId, otpCode: code() }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('REQ_001'); + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'AUTH_008', + ); + }); + + it('② 지금 비밀번호가 틀리면 400 AUTH_005(로그인 실패로 센다) · 앱 비밀번호 모양은 묻지 않고 AUTH_005 — 엔진에 보내지 않고 등록은 남는다', async () => { + const { enrollment } = await verified(); + mail.verifyCredentials.mockResolvedValueOnce(INVALID); + expect( + ( + await failureOf( + replace(enrollment.enrollmentId, { currentPassword: 'wrong' }), + ) + )?.code, + ).toBe('AUTH_005'); + mail.verifyCredentials.mockClear(); + expect( + ( + await failureOf( + replace(enrollment.enrollmentId, { + currentPassword: `app_${'a'.repeat(36)}`, + }), + ) + )?.context, + ).toMatchObject({ reason: 'app-password-shape' }); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.replaceTwoFactor).not.toHaveBeenCalled(); + // 등록은 남았다 — 비밀번호만 고쳐 다시 보낸다 + await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({ + isEnabled: true, + }); + }); + + it('② 그사이 다른 곳에서 꺼졌으면(본인 Basic 200) 409 AUTH_008 — 엔진에 보내지 않고 등록을 버린다', async () => { + const { enrollment } = await verified(); + engineEnabled = false; + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'AUTH_008', + ); + expect(provider.replaceTwoFactor).not.toHaveBeenCalled(); + engineEnabled = true; + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'AUTH_008', + ); + }); + + it('② 엔진이 지금 코드를 받지 않으면(forbidden) 400 AUTH_006 — 코드 실패로 센다, 등록은 남아 맞는 코드로 다시 보낼 수 있다', async () => { + const { enrollment, newCode } = await verified(); + expect( + ( + await failureOf( + replace(enrollment.enrollmentId, { currentOtpCode: '246800' }), + ) + )?.code, + ).toBe('AUTH_006'); + // 다시 쓴 코드(①에서 쓴 새 휴대폰의 코드)는 엔진에 묻지 않고 AUTH_006 + provider.replaceTwoFactor.mockClear(); + expect( + ( + await failureOf( + replace(enrollment.enrollmentId, { currentOtpCode: newCode }), + ) + )?.code, + ).toBe('AUTH_006'); + expect(provider.replaceTwoFactor).not.toHaveBeenCalled(); + + await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({ + isEnabled: true, + }); + }); + + it('② 결과를 모르면 503 MAIL_001 — 다시 읽지 않는다(가를 수 없다), 코드는 쓰지 않은 것으로, 등록은 남는다', async () => { + const { enrollment } = await verified(); + provider.replaceTwoFactor.mockResolvedValueOnce('unknown'); + provider.isTwoFactorEnabled.mockClear(); + + const failure = await failureOf(replace(enrollment.enrollmentId)); + expect(failure?.code).toBe('MAIL_001'); + expect(failure?.context).toMatchObject({ + reason: 'two-factor-replace-outcome-unknown', + }); + expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); + expect(codes.isUsed(ARA, OLD_CODE)).toBe(false); + await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({ + isEnabled: true, + }); + }); + + it('같은 직원의 바꾸기가 처리 중이면 확인 · 바꾸기 모두 429 REQ_001', async () => { + const { enrollment, code } = await enroll(); + const release = locks.acquire(ARA); + try { + expect( + ( + await failureOf( + service.verifyEnrollment( + ARA, + { enrollmentId: enrollment.enrollmentId, otpCode: code() }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('REQ_001'); + expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe( + 'REQ_001', + ); + } finally { + release(); + } + }); + + it('켠 때 기록을 읽지 못해도 바꾸기는 성공 — enabledAt null · error 로그', async () => { + const { enrollment } = await verified(); + table.repository.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(replace(enrollment.enrollmentId)).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + }); + expect(errors.join('\n')).toContain('켠 때 기록을 읽지 못했습니다'); + }); + }); + describe('끄기', () => { beforeEach(() => { engineEnabled = true; diff --git a/backend/src/modules/account-security/two-factor.service.ts b/backend/src/modules/account-security/two-factor.service.ts index 5fdc3bb..12d96d8 100644 --- a/backend/src/modules/account-security/two-factor.service.ts +++ b/backend/src/modules/account-security/two-factor.service.ts @@ -31,6 +31,7 @@ import { TwoFactorCodeGuard } from './two-factor-code-guard'; import { TwoFactorEnrollments, enrollmentExpiry, + type TwoFactorEnrollment, } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; import { TwoFactorPolicyService } from './two-factor-policy.service'; @@ -74,7 +75,9 @@ function errorMessage(error: unknown): string { /** * 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다). * - * - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008 + * - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 켜져 있어도 받는다 — 휴대폰 바꾸기용(16단계) + * - 휴대폰 바꾸기(16단계): ① 새 휴대폰의 코드를 등록의 비밀로 확인해 등록에 "확인됨"을 단다(verifyEnrollment) ② 지금 비밀번호(본인 Basic 402) → + * 지금 휴대폰의 코드와 새 otpauth 주소를 엔진 한 요청으로(replace — 2단계가 꺼지는 틈이 없다). 결과를 모르면 MAIL_001(다시 읽어도 가를 수 없다) * - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다). * 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, * 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다. @@ -134,18 +137,13 @@ export class TwoFactorService { return this.reconcile(address, enabled); } - /** POST /api/two-factor/enrollments — 꺼져 있을 때만 */ + /** + * POST /api/two-factor/enrollments — 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용(16단계) — 같은 등록이다. + * 엔진에 묻지 않는다: 켜기는 비밀번호 확인(402)이, 바꾸기는 엔진이 지금 코드로 가른다 + */ async startEnrollment(user: string): Promise { const address = this.requireEmployee(user); - return this.locks.run(address, async () => { - if (await this.provider.isTwoFactorEnabled(address)) { - throw new BusinessException('AUTH_008', { - context: { - reason: 'two-factor-already-enabled', - email: maskEmail(address), - }, - }); - } + return this.locks.run(address, () => { const now = Date.now(); const enrollment = { id: randomUUID(), @@ -154,15 +152,181 @@ export class TwoFactorService { }; this.enrollments.put(address, enrollment); const secret = encodeBase32(enrollment.secret); - return { + return Promise.resolve({ enrollmentId: enrollment.id, secret, otpauthUrl: this.otpauthUrl(address, secret), expiresAt: new Date(enrollment.expiresAt), - }; + }); }); } + /** + * POST /api/two-factor/enrollments/{id}/verification — 새 휴대폰의 코드를 등록의 비밀로 확인하고 등록에 "확인됨"을 단다 (16단계 휴대폰 바꾸기 ①). + * 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 코드 확인은 켜기 · 끄기와 같은 코드 가드(실패 한도 · 2분 다시 쓰기 막기 — 받은 코드는 기억한다). + * 등록이 없음 · 10분 지남 · 더 새 등록이면 AUTH_008, 코드 틀림 AUTH_006. 엔진에 묻지 않는다 + */ + async verifyEnrollment( + user: string, + input: { readonly enrollmentId: string; readonly otpCode: string }, + client: SecurityClient, + ): Promise { + const address = this.requireEmployee(user); + const ip = client.ip || 'unknown'; + return this.locks.run(address, async () => { + const enrollment = this.requireEnrollment(address, input.enrollmentId); + await this.codes.check( + { email: address, ip }, + input.otpCode, + () => + Promise.resolve( + verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null + ? { kind: 'rejected' as const } + : { kind: 'accepted' as const, value: undefined }, + ), + ); + if (!this.enrollments.markVerified(address, input.enrollmentId)) { + // 코드를 보는 사이 10분이 지났다 + throw this.enrollmentMissing(address, 'two-factor-enrollment-expired'); + } + }); + } + + /** + * POST /api/two-factor/replacements — 2단계 인증을 켠 채 휴대폰을 바꾼다 (16단계 ②). 확인한 등록 · 지금 휴대폰의 코드 · 지금 비밀번호. + * 1. 등록이 확인됨이 아니면 AUTH_008(등록을 버린다 — 처음부터) + * 2. 지금 비밀번호를 본인 Basic 으로 — 402 여야 한다(켜짐 · 비밀번호 맞음). 401 · 앱 비밀번호 모양은 AUTH_005(로그인 실패 한도로 센다 — 모양은 세지 않는다), + * 200 이면 그사이 다른 곳에서 꺼졌다 → AUTH_008(등록을 버린다). 엔진은 비밀번호 틀림과 코드 틀림을 가르지 않아 비밀번호를 먼저 본다 + * 3. 다시 쓴 코드 · 코드 실패 차지(끄기와 같은 결) → 엔진 한 요청(지금 코드 + 새 otpauth 주소). forbidden 은 지금 코드 틀림 AUTH_006, + * 결과를 모르면 MAIL_001(엔진은 otpUrl 을 가려 다시 읽어도 가를 수 없다 — 그 코드는 쓰지 않은 것으로, 등록은 남긴다) + * 4. 바꾸면 등록의 비밀을 지운다. 켠 때(enabledAt)는 그대로. 로그인된 기기는 끊지 않는다(켜기 · 끄기와 같다) + */ + async replace( + user: string, + input: { + readonly enrollmentId: string; + readonly currentOtpCode: string; + readonly currentPassword: string; + }, + client: SecurityClient, + ): Promise { + const address = this.requireEmployee(user); + const ip = client.ip || 'unknown'; + return this.locks.run(address, async () => { + const enrollment = this.requireEnrollment(address, input.enrollmentId); + if (enrollment.verified !== true) { + this.enrollments.forget(address); + throw this.enrollmentMissing( + address, + 'two-factor-enrollment-unverified', + ); + } + if (isAppPasswordShape(input.currentPassword)) { + throw this.currentPasswordRejected(address, 'app-password-shape'); + } + const otpauthUrl = this.otpauthUrl( + address, + encodeBase32(enrollment.secret), + ); + + const ticket = this.attempts.begin({ email: address, ip }); + try { + const check = await this.mail.verifyCredentials( + address, + input.currentPassword, + ); + if (check.kind === 'invalid') { + ticket.fail(); + throw this.currentPasswordRejected( + address, + 'current-password-rejected', + ); + } + if (check.kind === 'valid') { + // 엔진에서 그사이 꺼졌다 — 바꿀 것이 없다. 처음부터(켜기) + ticket.succeed(); + this.enrollments.forget(address); + throw new BusinessException('AUTH_008', { + context: { + reason: 'two-factor-disabled-elsewhere', + email: maskEmail(address), + }, + }); + } + // 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다 + ticket.verified(); + + await this.codes.check( + { email: address, ip }, + input.currentOtpCode, + async () => { + const outcome = await this.provider.replaceTwoFactor( + address, + input.currentPassword, + input.currentOtpCode, + otpauthUrl, + ); + if (outcome === 'code-rejected') + return { kind: 'rejected' as const }; + if (outcome === 'unknown') { + throw new BusinessException('MAIL_001', { + context: { + reason: 'two-factor-replace-outcome-unknown', + email: maskEmail(address), + }, + }); + } + return { kind: 'accepted' as const, value: undefined }; + }, + ); + ticket.succeed(); + } finally { + ticket.abort(); + } + + // 엔진에서 바뀌었다 — 등록의 비밀을 지운다 + this.enrollments.forget(address); + this.logger.log(`2단계 인증 휴대폰을 바꿨습니다: ${maskEmail(address)}`); + return { isEnabled: true, enabledAt: await this.readEnabledAt(address) }; + }); + } + + /** 기다리는 등록 — 없음 · 10분 지남 · 더 새 등록이면 AUTH_008 */ + private requireEnrollment( + address: string, + enrollmentId: string, + ): TwoFactorEnrollment { + const enrollment = this.enrollments.find(address, enrollmentId); + if (!enrollment) { + throw this.enrollmentMissing(address, 'two-factor-enrollment-missing'); + } + return enrollment; + } + + private enrollmentMissing( + address: string, + reason: string, + ): BusinessException { + return new BusinessException('AUTH_008', { + context: { reason, email: maskEmail(address) }, + }); + } + + /** 켠 때 기록 — 읽지 못하면 error 로그만 남기고 null (엔진에서는 이미 바뀌었다) */ + private async readEnabledAt(address: string): Promise { + try { + return ( + (await this.settings.findOneBy({ userEmail: address }))?.enabledAt ?? + null + ); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 2단계 인증 휴대폰을 바꿨지만 켠 때 기록을 읽지 못했습니다(엔진에서는 바뀌었다): user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + return null; + } + } + /** POST /api/two-factor/activation */ async activate( user: string, diff --git a/backend/src/modules/contacts/address-book.ts b/backend/src/modules/contacts/address-book.ts index 7c747a8..ffbaba0 100644 --- a/backend/src/modules/contacts/address-book.ts +++ b/backend/src/modules/contacts/address-book.ts @@ -320,12 +320,14 @@ export function weightAfterChanges( export interface WrittenChanges { readonly createdIds: ReadonlyMap; readonly cards: ReadonlyMap; + /** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */ + readonly refusedKeys?: ReadonlySet; } /** * 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다. * 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다. - * 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다) + * 엔진이 받지 않은 만들기(refusedKeys)는 건너뛴다. 그 밖의 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다) */ export function applyAddressBookWrite( snapshot: AddressBookSnapshot, @@ -352,6 +354,7 @@ export function applyAddressBookWrite( .map((card) => applyGroupUpdate(card, updates.get(card.id))); for (const card of changes.create) { + if (written.refusedKeys?.has(card.key)) continue; const id = written.createdIds.get(card.key); const read = id === undefined ? undefined : written.cards.get(id); if (read === undefined) return null; diff --git a/backend/src/modules/contacts/contact-export.spec.ts b/backend/src/modules/contacts/contact-export.spec.ts new file mode 100644 index 0000000..6a745a1 --- /dev/null +++ b/backend/src/modules/contacts/contact-export.spec.ts @@ -0,0 +1,291 @@ +import { + contactExportContentType, + contactExportFileName, + contactsToCsv, + contactsToVcard, + CSV_HEADER, + foldVcardLine, + type ExportedContact, +} from './contact-export'; + +const contact = ( + overrides: Partial = {}, +): ExportedContact => ({ + uid: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: '견적 담당', + groupNames: ['거래처'], + ...overrides, +}); + +/** 접힌 줄을 푼다 (CRLF + 공백 하나를 지운다 — RFC 2425 5.8.1) */ +const unfold = (text: string) => text.replace(/\r\n /g, ''); + +describe('내보내기 — vCard 3.0', () => { + it('한 사람 — 줄 끝 CRLF, VERSION 은 BEGIN 바로 다음, 우리 칸만 (계약 3절 "내보내기")', () => { + expect(contactsToVcard([contact()])).toBe( + [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + 'FN:박지훈', + 'N:박지훈;;;;', + 'EMAIL;TYPE=INTERNET,WORK:jihoon.park@hanbit.example', + 'TEL;TYPE=CELL:010-3192-5540', + 'ORG:한빛상사;구매팀', + 'TITLE:과장', + 'NOTE:견적 담당', + 'CATEGORIES:거래처', + 'END:VCARD', + '', + ].join('\r\n'), + ); + }); + + it('종류 — 이메일 WORK · HOME · INTERNET, 전화 CELL · WORK · HOME · VOICE', () => { + const text = contactsToVcard([ + contact({ + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'b@x.example', label: 'personal' }, + { address: 'c@x.example', label: 'other' }, + ], + phones: [ + { number: '1', label: 'mobile' }, + { number: '2', label: 'work' }, + { number: '3', label: 'home' }, + { number: '4', label: 'other' }, + ], + }), + ]); + expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,WORK:a@x.example\r\n'); + expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,HOME:b@x.example\r\n'); + expect(text).toContain('\r\nEMAIL;TYPE=INTERNET:c@x.example\r\n'); + expect(text).toContain( + '\r\nTEL;TYPE=CELL:1\r\nTEL;TYPE=WORK:2\r\nTEL;TYPE=HOME:3\r\nTEL;TYPE=VOICE:4\r\n', + ); + }); + + it('이스케이프 — \\ ; , 줄바꿈 (그룹 이름 안의 쉼표도)', () => { + const text = contactsToVcard([ + contact({ + name: '김; 아라, 팀장', + company: 'A;B', + department: null, + note: '첫 줄\n둘째 줄 C:\\temp', + groupNames: ['가,나', '다'], + }), + ]); + expect(text).toContain('\r\nFN:김\\; 아라\\, 팀장\r\n'); + expect(text).toContain('\r\nN:김\\; 아라\\, 팀장;;;;\r\n'); + expect(text).toContain('\r\nORG:A\\;B;\r\n'); + expect(text).toContain('\r\nNOTE:첫 줄\\n둘째 줄 C:\\\\temp\r\n'); + expect(text).toContain('\r\nCATEGORIES:가\\,나,다\r\n'); + }); + + it('빈 칸 — 이름이 없으면 FN 은 첫 이메일(N 은 비운다), 없는 칸 · uid 는 줄을 넣지 않는다, 회사가 없고 부서만 있으면 ORG:;부서', () => { + const text = contactsToVcard([ + contact({ + uid: null, + name: null, + emails: [ + { address: 'first@x.example', label: 'work' }, + { address: 'second@x.example', label: 'other' }, + ], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: null, + department: '구매팀', + title: null, + note: null, + groupNames: [], + }), + ]); + expect(text).toBe( + [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:first@x.example', + 'N:;;;;', + 'EMAIL;TYPE=INTERNET,WORK:first@x.example', + 'EMAIL;TYPE=INTERNET:second@x.example', + 'TEL;TYPE=CELL:010-3192-5540', + 'ORG:;구매팀', + 'END:VCARD', + '', + ].join('\r\n'), + ); + }); + + it('이름 · 이메일이 없으면 FN 은 첫 전화번호, 그것도 없으면 빈 FN (N 은 늘 이름 칸 그대로)', () => { + const phoneOnly = contactsToVcard([ + contact({ + name: null, + emails: [], + phones: [ + { number: '02-555-0100', label: 'work' }, + { number: '010-3192-5540', label: 'mobile' }, + ], + }), + ]); + expect(phoneOnly).toContain('\r\nFN:02-555-0100\r\nN:;;;;\r\n'); + + const nothing = contactsToVcard([ + contact({ name: null, emails: [], phones: [] }), + ]); + expect(nothing).toContain('\r\nFN:\r\nN:;;;;\r\n'); + // 이름이 있으면 이름 — 주소 · 번호로 채우지 않는다 + expect(contactsToVcard([contact()])).toContain('\r\nFN:박지훈\r\n'); + }); + + it('줄 접기 — 75바이트마다(이어지는 줄은 공백 하나로 시작해 74바이트), 한글(3바이트)을 가르지 않는다', () => { + const line = `NOTE:${'가'.repeat(60)}`; + const folded = foldVcardLine(line); + const lines = folded.split('\r\n'); + expect(lines.length).toBeGreaterThan(1); + for (const [index, part] of lines.entries()) { + expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75); + if (index > 0) expect(part.startsWith(' ')).toBe(true); + // 가른 곳이 글자 사이다 — 바이트로 바꿔도 깨진 글자(U+FFFD)가 없다 + expect(Buffer.from(part, 'utf8').toString('utf8')).not.toContain( + '\uFFFD', + ); + } + expect(unfold(folded)).toBe(line); + // 75바이트 이하는 그대로 + expect(foldVcardLine('A'.repeat(75))).toBe('A'.repeat(75)); + expect(foldVcardLine('A'.repeat(76))).toBe(`${'A'.repeat(75)}\r\n A`); + // 이모지(4바이트 · 서로게이트 쌍)도 가르지 않는다 + const emoji = foldVcardLine(`FN:${'😀'.repeat(30)}`); + expect(unfold(emoji)).toBe(`FN:${'😀'.repeat(30)}`); + for (const part of emoji.split('\r\n')) { + expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75); + expect(/[\uD800-\uDBFF]$/.test(part)).toBe(false); + expect(/^ ?[\uDC00-\uDFFF]/.test(part)).toBe(false); + } + }); + + it('여러 명은 한 파일에 이어 붙인다 · 연락처가 없으면 빈 파일', () => { + const text = contactsToVcard([contact(), contact({ name: '김아라' })]); + expect(text.match(/BEGIN:VCARD/g)).toHaveLength(2); + expect(text.endsWith('END:VCARD\r\n')).toBe(true); + expect(contactsToVcard([])).toBe(''); + }); +}); + +describe('내보내기 — CSV', () => { + it('UTF-8 BOM · CRLF · 한국어 머리 — 같은 칸 여럿은 "; ", 이메일은 종류 없이 모두', () => { + const text = contactsToCsv([ + contact({ + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'b@x.example', label: 'personal' }, + ], + phones: [ + { number: '010-1111-2222', label: 'mobile' }, + { number: '010-3333-4444', label: 'mobile' }, + { number: '02-555-0100', label: 'work' }, + { number: '031-000-0000', label: 'home' }, + { number: '070-1234-5678', label: 'other' }, + ], + groupNames: ['거래처', '협력사'], + }), + ]); + expect(text.startsWith('\uFEFF')).toBe(true); + expect(text).toBe( + '\uFEFF' + + '이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' + + '박지훈,a@x.example; b@x.example,010-1111-2222; 010-3333-4444,02-555-0100,031-000-0000,070-1234-5678,한빛상사,구매팀,과장,견적 담당,거래처; 협력사\r\n', + ); + expect(CSV_HEADER).toHaveLength(11); + }); + + it("= + - @ 로 시작하는 칸은 앞에 ' (엑셀 수식 막기) · 쉼표 · 따옴표 · 줄바꿈은 따옴표로 감싼다", () => { + const text = contactsToCsv([ + contact({ + name: '=HYPERLINK("http://x")', + emails: [], + phones: [{ number: '+82 10-1234-5678', label: 'mobile' }], + company: '-주식회사', + department: '@영업', + title: '과장, 팀장', + note: '첫 줄\n둘째 줄', + groupNames: [], + }), + ]); + const row = text.split('\r\n')[1]; + expect(row).toBe( + `"'=HYPERLINK(""http://x"")",,'+82 10-1234-5678,,,,'-주식회사,'@영업,"과장, 팀장","첫 줄\n둘째 줄",`, + ); + }); + + it("탭 · CR 로 시작하는 칸도 앞에 ' (OWASP CSV Injection) — CR 이 든 칸은 따옴표로 감싼다", () => { + const text = contactsToCsv([ + contact({ + name: '\t=1+1', + emails: [], + phones: [], + company: '\r=1+1', + department: null, + title: '가\t나', + note: null, + groupNames: [], + }), + ]); + // 가운데의 탭은 그대로 — 첫 글자만 본다 + expect(text.slice(text.indexOf('\r\n') + 2)).toBe( + `'\t=1+1,,,,,,"'\r=1+1",,가\t나,,\r\n`, + ); + }); + + it('빈 칸은 비운다 · 연락처가 없으면 머리 줄만', () => { + const text = contactsToCsv([ + contact({ + name: null, + phones: [], + company: null, + department: null, + title: null, + note: null, + groupNames: [], + }), + ]); + expect(text.split('\r\n')[1]).toBe(',jihoon.park@hanbit.example,,,,,,,,,'); + expect(contactsToCsv([])).toBe(`\uFEFF${CSV_HEADER.join(',')}\r\n`); + }); +}); + +describe('내보내기 — 파일 이름 · 형식', () => { + // 2026-11-10 15:30 UTC = 2026-11-11 00:30 KST + const at = new Date('2026-11-10T15:30:00.000Z'); + + it('주소록-YYYY-MM-DD(한국 날짜) · 그룹이면 주소록-<그룹 이름>-YYYY-MM-DD', () => { + expect(contactExportFileName('vcard', null, at)).toBe( + '주소록-2026-11-11.vcf', + ); + expect(contactExportFileName('csv', '거래처', at)).toBe( + '주소록-거래처-2026-11-11.csv', + ); + }); + + it('그룹 이름의 예약 글자는 _ · 앞뒤 공백과 점은 뗀다 · 남는 것이 없으면 그룹 칸을 빼고 · 50글자까지', () => { + expect(contactExportFileName('csv', ' 팀/파트:A* ', at)).toBe( + '주소록-팀_파트_A_-2026-11-11.csv', + ); + expect(contactExportFileName('csv', ' .. ', at)).toBe( + '주소록-2026-11-11.csv', + ); + expect(contactExportFileName('vcard', '가'.repeat(80), at)).toBe( + `주소록-${'가'.repeat(50)}-2026-11-11.vcf`, + ); + }); + + it('Content-Type — text/vcard · text/csv (UTF-8)', () => { + expect(contactExportContentType('vcard')).toBe('text/vcard; charset=utf-8'); + expect(contactExportContentType('csv')).toBe('text/csv; charset=utf-8'); + }); +}); diff --git a/backend/src/modules/contacts/contact-export.ts b/backend/src/modules/contacts/contact-export.ts new file mode 100644 index 0000000..2d14789 --- /dev/null +++ b/backend/src/modules/contacts/contact-export.ts @@ -0,0 +1,267 @@ +import { kstDateString } from '@/shared/utils/kst-date'; +import { MAX_CONTACT_GROUP_NAME_LENGTH } from './contacts.constants'; +import type { + ContactEmailLabel, + ContactExportFormat, + ContactFields, + ContactPhoneLabel, +} from './contacts.types'; + +/** + * 주소록 내보내기 파일 (순수 함수) — 계약 3절 "내보내기" (16단계). 우리 칸만 쓴다(엔진 카드의 다른 칸은 읽지 않는다). + * + * vCard 3.0 (RFC 2426): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않게 — 이어지는 줄은 공백 하나로 시작) · + * 글 값은 `\` `;` `,` 줄바꿈을 이스케이프. FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL · TEL · ORG(회사;부서) · + * TITLE · NOTE · CATEGORIES(그룹 이름) · UID. + * + * CSV: UTF-8 BOM · CRLF · 한국어 머리(아래 CSV_HEADER) — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다. + * 칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180). + * 우리 가져오기(화면)가 이 두 모양을 다시 받는다 — 머리 이름 · 차례를 바꾸면 frontend shared/utils/contact-import 도 함께 + */ + +/** 내보낼 연락처 하나 — 우리 칸 + 카드의 uid · 속한 그룹 이름(그룹 이름 차례) */ +export interface ExportedContact extends ContactFields { + readonly uid: string | null; + readonly groupNames: readonly string[]; +} + +export const VCARD_CONTENT_TYPE = 'text/vcard; charset=utf-8'; +export const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'; + +const CRLF = '\r\n'; +/** vCard 한 줄의 바이트 상한 (줄 끝 제외 — 이어지는 줄은 앞의 공백까지 센다) */ +const VCARD_LINE_OCTETS = 75; + +const VCARD_EMAIL_TYPES: Record = { + work: 'INTERNET,WORK', + personal: 'INTERNET,HOME', + other: 'INTERNET', +}; + +const VCARD_PHONE_TYPES: Record = { + mobile: 'CELL', + work: 'WORK', + home: 'HOME', + other: 'VOICE', +}; + +/** CSV 머리 — 칸 차례 (계약 3절 "내보내기") */ +export const CSV_HEADER = [ + '이름', + '이메일', + '휴대폰', + '회사 전화', + '집 전화', + '기타 전화', + '회사', + '부서', + '직함', + '메모', + '그룹', +] as const; + +/** 같은 칸에 여럿을 잇는 글 */ +const CSV_JOINER = '; '; +/** 엑셀이 수식으로 읽는 첫 글자(OWASP CSV Injection — `= + - @` · 탭 · CR) — 앞에 `'` 를 붙인다 */ +const CSV_FORMULA_START = /^[=+\-@\t\r]/; +/** 따옴표로 감쌀 칸 */ +const CSV_NEEDS_QUOTES = /[",\r\n]/; +/** UTF-8 BOM(U+FEFF) — 엑셀이 한글을 UTF-8 로 읽게 (소스에 보이지 않는 글자를 두지 않는다) */ +const UTF8_BOM = String.fromCharCode(0xfeff); + +/** 파일 이름 앞머리 · 형식마다 확장자 */ +const FILE_NAME_PREFIX = '주소록'; +const FILE_EXTENSIONS: Record = { + vcard: '.vcf', + csv: '.csv', +}; +/** 파일 이름에 쓸 수 없는 글자(경로 구분자 · Windows 예약 글자) — '_' 로 */ +const FILE_NAME_RESERVED = /[\\/:*?"<>|]/g; +/** 그룹 이름 앞뒤에서 떼는 글자 — 공백과 점 (앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */ +const FILE_NAME_EDGE = /^[\s.]$/u; + +/** vCard 글 값의 이스케이프 — `\` `;` `,` 줄바꿈(\n) (한 번 훑는다) */ +function escapeVcardText(value: string): string { + return value.replace(/[\\;,]|\r\n|\r|\n/g, (match) => { + if (match === '\\') return '\\\\'; + if (match === ';') return '\\;'; + if (match === ',') return '\\,'; + return '\\n'; + }); +} + +/** UTF-8 바이트 수 — 코드 포인트 하나 */ +function utf8Length(codePoint: number): number { + if (codePoint < 0x80) return 1; + if (codePoint < 0x800) return 2; + if (codePoint < 0x10000) return 3; + return 4; +} + +/** 한 줄을 75바이트마다 접는다 — 글자(코드 포인트)를 가르지 않는다. 이어지는 줄은 공백 하나로 시작한다 */ +export function foldVcardLine(line: string): string { + const parts: string[] = []; + let current = ''; + let bytes = 0; + let limit = VCARD_LINE_OCTETS; + for (const char of line) { + const size = utf8Length(char.codePointAt(0) ?? 0); + if (bytes + size > limit) { + parts.push(current); + current = ''; + bytes = 0; + // 이어지는 줄 앞의 공백 하나도 75바이트에 든다 + limit = VCARD_LINE_OCTETS - 1; + } + current += char; + bytes += size; + } + parts.push(current); + return parts.join(`${CRLF} `); +} + +/** + * 보이는 이름(FN) — 이름, 없으면 첫 이메일, 그것도 없으면 첫 전화번호(vCard 3.0 은 FN 을 꼭 담는다 — 빈 FN 은 받는 쪽이 "이름 없음"으로 보인다). + * N 은 이름 칸 그대로 둔다(이름이 없으면 빈 칸 — 다시 가져올 때 주소를 이름으로 읽지 않게) + */ +function displayNameOf(contact: ExportedContact): string { + return ( + contact.name ?? + contact.emails[0]?.address ?? + contact.phones[0]?.number ?? + '' + ); +} + +/** 연락처 하나의 vCard 3.0 */ +function vcardOf(contact: ExportedContact): string { + const name = contact.name ?? ''; + const lines = ['BEGIN:VCARD', 'VERSION:3.0']; + if (contact.uid !== null) lines.push(`UID:${escapeVcardText(contact.uid)}`); + lines.push(`FN:${escapeVcardText(displayNameOf(contact))}`); + lines.push(`N:${escapeVcardText(name)};;;;`); + for (const email of contact.emails) { + lines.push( + `EMAIL;TYPE=${VCARD_EMAIL_TYPES[email.label]}:${escapeVcardText(email.address)}`, + ); + } + for (const phone of contact.phones) { + lines.push( + `TEL;TYPE=${VCARD_PHONE_TYPES[phone.label]}:${escapeVcardText(phone.number)}`, + ); + } + if (contact.company !== null || contact.department !== null) { + lines.push( + `ORG:${escapeVcardText(contact.company ?? '')};${escapeVcardText(contact.department ?? '')}`, + ); + } + if (contact.title !== null) { + lines.push(`TITLE:${escapeVcardText(contact.title)}`); + } + if (contact.note !== null) { + lines.push(`NOTE:${escapeVcardText(contact.note)}`); + } + if (contact.groupNames.length > 0) { + lines.push( + `CATEGORIES:${contact.groupNames.map(escapeVcardText).join(',')}`, + ); + } + lines.push('END:VCARD'); + return lines.map((line) => `${foldVcardLine(line)}${CRLF}`).join(''); +} + +/** vCard 3.0 파일 — 한 파일에 여러 장 (연락처가 없으면 빈 파일) */ +export function contactsToVcard(contacts: readonly ExportedContact[]): string { + return contacts.map(vcardOf).join(''); +} + +/** CSV 한 칸 — 수식 막기 · 필요하면 따옴표 */ +function csvCell(value: string): string { + const guarded = CSV_FORMULA_START.test(value) ? `'${value}` : value; + return CSV_NEEDS_QUOTES.test(guarded) + ? `"${guarded.replace(/"/g, '""')}"` + : guarded; +} + +/** 그 종류의 전화번호들 — 한 칸 */ +function phonesOf(contact: ExportedContact, label: ContactPhoneLabel): string { + return contact.phones + .filter((phone) => phone.label === label) + .map((phone) => phone.number) + .join(CSV_JOINER); +} + +function csvRow(contact: ExportedContact): string { + return [ + contact.name ?? '', + contact.emails.map((email) => email.address).join(CSV_JOINER), + phonesOf(contact, 'mobile'), + phonesOf(contact, 'work'), + phonesOf(contact, 'home'), + phonesOf(contact, 'other'), + contact.company ?? '', + contact.department ?? '', + contact.title ?? '', + contact.note ?? '', + contact.groupNames.join(CSV_JOINER), + ] + .map(csvCell) + .join(','); +} + +/** CSV 파일 — BOM + 머리 줄 + 연락처마다 한 줄 (연락처가 없으면 머리 줄만) */ +export function contactsToCsv(contacts: readonly ExportedContact[]): string { + const rows = [CSV_HEADER.join(','), ...contacts.map(csvRow)]; + return `${UTF8_BOM}${rows.map((row) => `${row}${CRLF}`).join('')}`; +} + +/** 앞뒤의 공백 · 점을 뗀다 — 반복 정규식 없이 한 번 훑는다(길이에 비례) */ +function trimEdges(chars: readonly string[]): string[] { + let start = 0; + let end = chars.length; + while (start < end && FILE_NAME_EDGE.test(chars[start])) start += 1; + while (end > start && FILE_NAME_EDGE.test(chars[end - 1])) end -= 1; + return chars.slice(start, end); +} + +/** 파일 이름의 그룹 이름 — 예약 글자는 '_', 앞뒤 공백 · 점은 떼고 50글자(코드 포인트)까지. 남는 것이 없으면 null */ +function groupNamePart(name: string): string | null { + const chars = trimEdges(Array.from(name.replace(FILE_NAME_RESERVED, '_'))); + const cleaned = trimEdges(chars.slice(0, MAX_CONTACT_GROUP_NAME_LENGTH)).join( + '', + ); + return cleaned === '' ? null : cleaned; +} + +/** + * 내려받을 파일 이름 — `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv` (날짜는 한국 날짜). + * 헤더 인코딩은 shared/utils/content-disposition.ts 가 한다 + */ +export function contactExportFileName( + format: ContactExportFormat, + groupName: string | null, + at: Date, +): string { + const group = groupName === null ? null : groupNamePart(groupName); + const parts = [ + FILE_NAME_PREFIX, + ...(group ? [group] : []), + kstDateString(at), + ]; + return `${parts.join('-')}${FILE_EXTENSIONS[format]}`; +} + +/** 형식마다 Content-Type */ +export function contactExportContentType(format: ContactExportFormat): string { + return format === 'vcard' ? VCARD_CONTENT_TYPE : CSV_CONTENT_TYPE; +} + +/** 형식마다 파일 내용 */ +export function contactExportContent( + format: ContactExportFormat, + contacts: readonly ExportedContact[], +): string { + return format === 'vcard' + ? contactsToVcard(contacts) + : contactsToCsv(contacts); +} diff --git a/backend/src/modules/contacts/contact-exports.controller.ts b/backend/src/modules/contacts/contact-exports.controller.ts new file mode 100644 index 0000000..3697ac9 --- /dev/null +++ b/backend/src/modules/contacts/contact-exports.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Get, + HttpStatus, + Query, + Res, + StreamableFile, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { Response } from 'express'; + +import { + ApiErrorResponse, + ApiSessionErrors, +} 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 { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; +import { + DOWNLOAD_CACHE_CONTROL, + DOWNLOAD_CSP, +} from '@/shared/utils/download-headers'; +import { CSV_CONTENT_TYPE, VCARD_CONTENT_TYPE } from './contact-export'; +import { CONTACT_EXPORTS_ROUTE } from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { ContactExportQueryDto } from './dto/contact-export.dto'; + +// 내 주소록 — 내보내기 (계약 3절 "내보내기", 16단계). 파일 응답이라 표준 래퍼 없이 내려준다 (실패는 표준 실패 JSON) +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_EXPORTS_ROUTE) +export class ContactExportsController { + constructor(private readonly contacts: ContactsService) {} + + @Get() + @SkipTransform() + @ApiOperation({ + summary: '주소록 내보내기 — vCard 3.0 · CSV 파일 (표준 래퍼 없음)', + description: + '내 주소록 전체 또는 그룹 하나(groupId)를 이름 차례로. 사내 목록은 넣지 않는다. 우리 칸만(엔진 카드의 다른 칸 — 주소 · 생일 · 사진 등은 넣지 않는다). ' + + 'vcard: vCard 3.0 · CRLF · 75바이트 줄 접기 · FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL(TYPE=INTERNET,WORK / INTERNET,HOME / INTERNET) · TEL(CELL / WORK / HOME / VOICE) · ' + + 'ORG(회사;부서) · TITLE · NOTE · CATEGORIES(그룹 이름) · UID. ' + + 'csv: UTF-8 BOM · CRLF · 머리 「이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹」 — 같은 칸에 여럿이면 "; " 로 잇고, ' + + "= + - @ · 탭 · CR 로 시작하는 칸은 앞에 ' 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). " + + '파일 이름 「주소록-YYYY-MM-DD.vcf」 · 「주소록-<그룹 이름>-YYYY-MM-DD.csv」(한국 날짜) — Content-Disposition: attachment. Cache-Control: private, no-store. ' + + '화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 로 받는다.', + }) + @ApiProduces(VCARD_CONTENT_TYPE, CSV_CONTENT_TYPE) + @ApiResponse({ + status: HttpStatus.OK, + description: '내보낸 파일 (.vcf · .csv)', + schema: { type: 'string', format: 'binary' }, + }) + @ApiErrorResponse('VAL_001', '모르는 형식 · 그룹 id 모양 · 모르는 쿼리') + @ApiErrorResponse('RES_001', '없는 groupId') + async export( + @CurrentUser() user: AuthenticatedUser, + @Query() query: ContactExportQueryDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const file = await this.contacts.exportContacts( + user, + query.format, + query.groupId, + ); + // 개인 정보 — 남기지 않는다. 브라우저가 열지 않고 내려받게(형식 짐작 막기 · 열어도 스크립트가 돌지 않게) + res.setHeader('Cache-Control', DOWNLOAD_CACHE_CONTROL); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', DOWNLOAD_CSP); + return new StreamableFile(file.content, { + type: file.contentType, + disposition: buildAttachmentDisposition(file.fileName), + length: file.content.length, + }); + } +} diff --git a/backend/src/modules/contacts/contact-import.spec.ts b/backend/src/modules/contacts/contact-import.spec.ts new file mode 100644 index 0000000..8dacb5d --- /dev/null +++ b/backend/src/modules/contacts/contact-import.spec.ts @@ -0,0 +1,171 @@ +import { + deletionChunkSize, + importDuplicateIndex, + isExistingContact, + namePhonesKey, + phoneDigitsKey, + planGroupJoins, + type GroupRoom, +} from './contact-import'; +import type { ContactFields, ContactPhone } from './contacts.types'; + +const mobile = (number: string): ContactPhone => ({ number, label: 'mobile' }); + +const person = (overrides: Partial = {}) => ({ + name: null, + emails: [], + phones: [], + ...overrides, +}); + +describe('가져오기 — 이미 있는 연락처 (계약 3절 "가져오기")', () => { + it('전화 숫자 모음 — 숫자만 남기고 겹침 · 차례를 무시한다, 숫자가 없는 번호는 뺀다', () => { + expect( + phoneDigitsKey([ + mobile('010-1234-5678'), + { number: '(02) 555-0100', label: 'work' }, + mobile('01012345678'), + mobile('#*'), + ]), + ).toBe('01012345678,025550100'); + expect(phoneDigitsKey([])).toBe(''); + }); + + it('이름 + 전화 열쇠 — 이름은 앞뒤 공백만 뺀다(가운데 공백 · 대소문자는 그대로), 이름이 없으면 null', () => { + expect(namePhonesKey({ name: ' 김아라 ', phones: [] })).toBe( + namePhonesKey({ name: '김아라', phones: [] }), + ); + expect(namePhonesKey({ name: '김 아라', phones: [] })).not.toBe( + namePhonesKey({ name: '김아라', phones: [] }), + ); + expect(namePhonesKey({ name: 'Ara', phones: [] })).not.toBe( + namePhonesKey({ name: 'ara', phones: [] }), + ); + expect(namePhonesKey({ name: null, phones: [mobile('010')] })).toBeNull(); + expect(namePhonesKey({ name: ' ', phones: [] })).toBeNull(); + }); + + it('이메일이 있는 항목 — 그 가운데 하나라도 주소록에 있으면(앞뒤 공백 · 대소문자 무시) 이미 있음', () => { + const index = importDuplicateIndex([ + person({ + name: '박지훈', + emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }], + }), + ]); + expect( + isExistingContact( + index, + person({ + emails: [ + { address: 'new@x.example', label: 'work' }, + { address: ' jihoon@hanbit.EXAMPLE ', label: 'other' }, + ], + }), + ), + ).toBe(true); + // 이메일이 있으면 이름 · 전화는 보지 않는다 + expect( + isExistingContact( + index, + person({ + name: '박지훈', + emails: [{ address: 'other@x.example', label: 'work' }], + }), + ), + ).toBe(false); + }); + + it('이메일이 없는 항목 — 이름과 전화 숫자 모음이 모두 같은 연락처가 있으면 이미 있음 (주소록 쪽은 이메일이 있어도 된다)', () => { + const index = importDuplicateIndex([ + person({ + name: '김철수', + emails: [{ address: 'kim@x.example', label: 'work' }], + phones: [ + mobile('010-1111-2222'), + { number: '02 555 0100', label: 'work' }, + ], + }), + person({ name: '이영희' }), + ]); + expect( + isExistingContact( + index, + person({ + name: '김철수 ', + phones: [ + { number: '0255501 00', label: 'other' }, + mobile('01011112222'), + ], + }), + ), + ).toBe(true); + // 번호 하나만 같으면 다른 사람으로 본다 + expect( + isExistingContact( + index, + person({ name: '김철수', phones: [mobile('010-1111-2222')] }), + ), + ).toBe(false); + // 둘 다 전화가 없으면 이름만 같아도 + expect(isExistingContact(index, person({ name: '이영희' }))).toBe(true); + expect(isExistingContact(index, person({ name: '이영희2' }))).toBe(false); + }); +}); + +describe('가져오기 — 그룹 넣기 계획', () => { + const room = (members: string[], count = members.length): GroupRoom => ({ + memberUids: new Set(members), + memberCount: count, + }); + + it('보낸 차례대로 넣고, 한도에 닿은 그룹은 그 뒤 사람만 빼고(skipped) 나머지 그룹에는 넣는다', () => { + const rooms = new Map([ + ['g1', room(['u-old'], 2)], + ['g2', room([])], + ]); + const plan = planGroupJoins( + rooms, + [ + { uid: 'a', groupIds: ['g1', 'g2'] }, + { uid: 'b', groupIds: ['g1', 'g2'] }, + { uid: 'c', groupIds: ['g2'] }, + ], + 3, + ); + expect(plan.adds.get('g1')).toEqual(['a']); + expect(plan.adds.get('g2')).toEqual(['a', 'b', 'c']); + expect(plan.skipped.get('b')).toEqual(['g1']); + expect(plan.skipped.has('a')).toBe(false); + }); + + it('이미 구성원인 uid 는 세지도 넣지도 않는다 · 모르는 그룹은 건너뛴다', () => { + const plan = planGroupJoins( + new Map([['g1', room(['a'], 3)]]), + [ + { uid: 'a', groupIds: ['g1', 'nope'] }, + { uid: 'b', groupIds: ['g1'] }, + ], + 3, + ); + expect(plan.adds.get('g1')).toBeUndefined(); + expect(plan.skipped.get('b')).toEqual(['g1']); + expect(plan.skipped.has('a')).toBe(false); + }); +}); + +describe('한꺼번에 지우기 — 한 번 몫', () => { + it('지울 연락처 수 + 고칠 그룹 수(겹침 없이)가 상한을 넘지 않게 앞에서부터', () => { + expect(deletionChunkSize([[], [], []], 500)).toBe(3); + expect(deletionChunkSize([['g1'], ['g1'], ['g2']], 4)).toBe(2); + expect(deletionChunkSize([['g1'], ['g1'], ['g1']], 4)).toBe(3); + expect( + deletionChunkSize( + Array.from({ length: 500 }, () => ['g1']), + 500, + ), + ).toBe(499); + // 첫 사람이 홀로 넘어도 한 명은 지운다 + expect(deletionChunkSize([['a', 'b', 'c']], 2)).toBe(1); + expect(deletionChunkSize([], 500)).toBe(0); + }); +}); diff --git a/backend/src/modules/contacts/contact-import.ts b/backend/src/modules/contacts/contact-import.ts new file mode 100644 index 0000000..b4588a2 --- /dev/null +++ b/backend/src/modules/contacts/contact-import.ts @@ -0,0 +1,138 @@ +import { emailKey } from './address-book'; +import type { ContactFields, ContactPhone } from './contacts.types'; + +/** + * 가져오기 · 한꺼번에 지우기의 순수 함수 (계약 3절 "가져오기 · 내보내기" — 16단계). + * + * "이미 있는 연락처" 규칙 — 화면이 같은 파일 안의 겹침을 **같은 규칙**으로 본다(frontend shared/utils/contact-import — 한쪽을 고치면 둘 다): + * - 이메일이 있는 항목: 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자 — emailKey) 내 주소록의 어느 연락처에 있으면 이미 있음 + * - 이메일이 없는 항목: 이름(앞뒤 공백을 뺀 글)이 같고 전화번호의 숫자 모음(숫자만 남긴 번호들 — 차례 · 겹침 무시)이 같은 연락처가 있으면 이미 있음 + * (주소록 쪽 연락처는 이메일이 있어도 된다. 둘 다 전화가 없으면 이름만 같아도 이미 있음) + */ + +const NON_DIGITS = /\D+/g; +/** 이름과 전화 숫자 모음을 잇는 글 — 이름 칸에 올 수 없는 제어 문자 */ +const KEY_SEPARATOR = '\u0000'; + +/** 전화번호들의 숫자 모음 — 숫자만 남긴 번호를 겹침 없이 차례로 (숫자가 없는 번호는 뺀다) */ +export function phoneDigitsKey(phones: readonly ContactPhone[]): string { + const digits = new Set(); + for (const phone of phones) { + const only = phone.number.replace(NON_DIGITS, ''); + if (only !== '') digits.add(only); + } + return [...digits].sort().join(','); +} + +/** 이메일이 없는 항목을 견주는 열쇠 — 이름(앞뒤 공백을 뺀 글) + 전화 숫자 모음. 이름이 없으면 null */ +export function namePhonesKey( + fields: Pick, +): string | null { + const name = fields.name?.trim() ?? ''; + return name === '' + ? null + : `${name}${KEY_SEPARATOR}${phoneDigitsKey(fields.phones)}`; +} + +/** 내 주소록의 "이미 있음" 찾기 자료 — 이메일 모음 · 이름 + 전화 열쇠 모음 */ +export interface ImportDuplicateIndex { + readonly emails: ReadonlySet; + readonly namePhones: ReadonlySet; +} + +/** 주소록의 연락처들로 찾기 자료를 만든다 — 연락처 수에 비례 */ +export function importDuplicateIndex( + contacts: readonly Pick[], +): ImportDuplicateIndex { + const emails = new Set(); + const namePhones = new Set(); + for (const contact of contacts) { + for (const email of contact.emails) emails.add(emailKey(email.address)); + const key = namePhonesKey(contact); + if (key !== null) namePhones.add(key); + } + return { emails, namePhones }; +} + +/** 이 항목이 내 주소록에 이미 있는가 (위 규칙) */ +export function isExistingContact( + index: ImportDuplicateIndex, + item: Pick, +): boolean { + if (item.emails.length > 0) { + return item.emails.some((email) => + index.emails.has(emailKey(email.address)), + ); + } + const key = namePhonesKey(item); + return key !== null && index.namePhones.has(key); +} + +/** 그룹 하나의 지금 자리 — 구성원 uid(이 주소록에 없는 카드를 가리키는 것 포함)와 센 구성원 수(이 주소록에 있는 연락처만) */ +export interface GroupRoom { + readonly memberUids: ReadonlySet; + readonly memberCount: number; +} + +/** 그룹에 넣을 사람 하나 — uid 와 넣을 그룹 */ +export interface GroupJoin { + readonly uid: string; + readonly groupIds: readonly string[]; +} + +export interface GroupJoinPlan { + /** 그룹 id → 새로 넣을 uid (넣는 차례) */ + readonly adds: ReadonlyMap; + /** uid → 구성원 수 한도가 차서 넣지 못한 그룹 id (보낸 차례) */ + readonly skipped: ReadonlyMap; +} + +/** + * 가져온 사람들을 그룹에 넣는 계획 — 보낸 차례대로 넣고, 그룹이 max 명에 닿으면 그 뒤 사람은 그 그룹만 빼고(skipped) 나머지 그룹에는 넣는다. + * 이미 구성원인 uid 는 세지도 넣지도 않는다. 모르는 그룹 id 는 부르는 쪽이 먼저 막는다(여기서는 건너뛴다) + */ +export function planGroupJoins( + rooms: ReadonlyMap, + joins: readonly GroupJoin[], + max: number, +): GroupJoinPlan { + const counts = new Map(); + const adds = new Map(); + const skipped = new Map(); + for (const join of joins) { + for (const groupId of join.groupIds) { + const room = rooms.get(groupId); + if (!room) continue; + const added = adds.get(groupId) ?? []; + if (room.memberUids.has(join.uid) || added.includes(join.uid)) continue; + const count = counts.get(groupId) ?? room.memberCount; + if (count >= max) { + skipped.set(join.uid, [...(skipped.get(join.uid) ?? []), groupId]); + continue; + } + counts.set(groupId, count + 1); + added.push(join.uid); + adds.set(groupId, added); + } + } + return { adds, skipped }; +} + +/** + * 한꺼번에 지우기의 한 번 몫 — 앞에서부터 몇 명을 지울지. 지울 연락처 수 + 고칠 그룹 수(그 연락처가 든 그룹 — 겹침 없이)가 max 를 넘지 않게 + * (엔진 한 요청의 카드 수 상한). 첫 사람이 홀로 넘어도 한 명은 지운다(그런 주소록은 없다 — 그룹 100개) + */ +export function deletionChunkSize( + groupsOf: readonly (readonly string[])[], + max: number, +): number { + const groups = new Set(); + let size = 0; + for (const ids of groupsOf) { + const fresh = ids.filter((id) => !groups.has(id)); + if (size > 0 && size + 1 + groups.size + fresh.length > max) break; + for (const id of fresh) groups.add(id); + size += 1; + } + return size; +} diff --git a/backend/src/modules/contacts/contact-imports.controller.ts b/backend/src/modules/contacts/contact-imports.controller.ts new file mode 100644 index 0000000..395e494 --- /dev/null +++ b/backend/src/modules/contacts/contact-imports.controller.ts @@ -0,0 +1,149 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiSessionErrors, + 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 { + CONTACT_DELETIONS_ROUTE, + CONTACT_IMPORT_CHECKS_ROUTE, + CONTACT_IMPORTS_ROUTE, + MAX_CONTACT_DELETIONS, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_IMPORT_BATCH, + MAX_CONTACTS, +} from './contacts.constants'; +import { ContactsService } from './contacts.service'; +import { + ContactDeletionDto, + ContactImportBatchDto, + ContactImportCheckDto, + ContactImportResultDto, + ContactsDeletedDto, +} from './dto/contact-import.dto'; + +const ITEM_RULES = + `한 사람의 칸 규칙은 연락처 추가(POST /api/contacts)와 같고 uid(urn:uuid: + 소문자 UUID — 화면이 만든다)를 더한다. ` + + `${MAX_CONTACT_IMPORT_BATCH}명까지 · 한 묶음 안의 같은 uid · 이름도 이메일도 없는 사람 · 같은 연락처 안의 같은 주소는 400 VAL_001(하나라도 걸리면 묶음 전체).`; + +// 내 주소록 — 가져오기 확인 (계약 3절 "가져오기", 16단계). 파일은 화면이 읽는다 — 서버는 연락처 칸만 받는다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_IMPORT_CHECKS_ROUTE) +export class ContactImportChecksController { + constructor(private readonly contacts: ContactsService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '가져올 연락처가 이미 있는지 확인 (만들지 않는다)', + description: + '사람마다 new · exists — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있으면, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있으면, ' + + `또는 같은 uid 의 연락처가 있으면 exists. remaining 은 더 넣을 수 있는 수(${MAX_CONTACTS.toLocaleString('en-US')} − 지금 연락처 수). ` + + `같은 파일 안의 겹침은 화면이 같은 규칙으로 본다. groupIds 는 보지 않는다. ${ITEM_RULES}`, + }) + @ApiStandardResponse(ContactImportCheckDto, { + description: '사람마다 판정과 남은 자리', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드', + ) + check( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactImportBatchDto, + ) { + return this.contacts.checkImport(user, dto.contacts); + } +} + +// 내 주소록 — 가져오기 넣기 (계약 3절 "가져오기", 16단계). 엔진 요청 하나에 묶음을 만든다 +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_IMPORTS_ROUTE) +export class ContactImportsController { + constructor(private readonly contacts: ContactsService) {} + + @Post() + @ApiOperation({ + summary: '가져온 연락처 넣기 (100명씩)', + description: + '줄마다 보낸 uid 로 만든다 — 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다(빠진 그룹만 마저 넣는다). ' + + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도는 묶음 전체로 미리 본다 — 넘으면 409 CONTACT_002, 아무것도 만들지 않는다. ` + + `그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명을 넘기는 넣기는 그 그룹만 빼고 만들고 skippedGroupIds 에 적는다(실패가 아니다). ` + + '메일 서버가 일부만 받으면 받은 것만 items 에 — 빠진 uid 는 넣지 못한 것이다. 겹침(같은 이메일 등)은 보지 않는다(확인 경로의 몫). ' + + ITEM_RULES, + }) + @ApiStandardResponse(ContactImportResultDto, { + status: 201, + description: '넣은 연락처(같은 uid 로 이미 있던 것 포함)', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드', + ) + @ApiErrorResponse('RES_001', '없는 groupId') + @ApiErrorResponse( + 'CONTACT_002', + `연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도 — 묶음 전체로 보고 아무것도 만들지 않았다`, + ) + importContacts( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactImportBatchDto, + ) { + return this.contacts.importContacts(user, dto.contacts); + } +} + +// 내 주소록 — 연락처 한꺼번에 지우기 (가져오기 [되돌리기] — 계약 3절 "가져오기", 16단계) +@ApiTags('Contacts') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@ApiErrorResponse('MAIL_001', '메일 엔진 장애') +@Controller(CONTACT_DELETIONS_ROUTE) +export class ContactDeletionsController { + constructor(private readonly contacts: ContactsService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '연락처 한꺼번에 지우기 (영구 삭제 · 되돌리기 표 없음)', + description: + `1~${MAX_CONTACT_DELETIONS}개. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다. 내 주소록에 없는 id(이미 지움 · 그룹 id)는 notFoundIds — 실패가 아니다. ` + + '메일 서버 한 요청의 상한을 넘으면 서버가 나눠 지운다 — 중간에 실패하면(503) 앞 몫은 지워진 채다(같은 목록을 다시 보내면 notFoundIds 로 나온다).', + }) + @ApiStandardResponse(ContactsDeletedDto, { + description: '지운 id · 없던 id (보낸 차례)', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 개수 · id 모양 · 같은 id 두 번 · 모르는 필드', + ) + remove( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ContactDeletionDto, + ) { + return this.contacts.removeContacts(user, dto.ids); + } +} diff --git a/backend/src/modules/contacts/contacts.constants.ts b/backend/src/modules/contacts/contacts.constants.ts index 5407182..853da56 100644 --- a/backend/src/modules/contacts/contacts.constants.ts +++ b/backend/src/modules/contacts/contacts.constants.ts @@ -14,6 +14,28 @@ export const CONTACT_GROUPS_ROUTE = 'contact-groups'; export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations'; export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries'; +/** + * 16단계 라우트 — 가져오기의 확인 · 넣기, 한꺼번에 지우기(가져오기 [되돌리기]), 내보내기 (계약 3절 "가져오기 · 내보내기") + * (`/api/contact-import-checks` · `/api/contact-imports` · `/api/contact-deletions` · `/api/contact-exports`) + */ +export const CONTACT_IMPORT_CHECKS_ROUTE = 'contact-import-checks'; +export const CONTACT_IMPORTS_ROUTE = 'contact-imports'; +export const CONTACT_DELETIONS_ROUTE = 'contact-deletions'; +export const CONTACT_EXPORTS_ROUTE = 'contact-exports'; + +/** 가져오기 · 확인 한 요청의 연락처 수 — 1~100 (화면은 100명씩 차례로 보낸다) */ +export const MAX_CONTACT_IMPORT_BATCH = 100; +/** 한꺼번에 지우기 한 요청의 id 수 — 1~500 */ +export const MAX_CONTACT_DELETIONS = 500; +/** + * 엔진 한 번의 바꾸기(ContactCard/set)에 담는 카드 수 — 만들기 · 고치기 · 지우기를 모두 센다. + * 엔진의 maxObjectsInSet(500 — 2026-11-10 세션 능력 확인)과 같다. 한꺼번에 지우기는 그룹 고치기와 합쳐 이것을 넘으면 나눠 쓴다 + */ +export const MAX_CARD_CHANGES_PER_WRITE = 500; +/** 가져오기 항목의 uid — `urn:uuid:` + 소문자 UUID (화면이 만든다 — 다시 보낸 요청이 두 번 들어가지 않게) */ +export const CONTACT_IMPORT_UID_PATTERN = + /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + /** * 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다). * ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다 diff --git a/backend/src/modules/contacts/contacts.module.ts b/backend/src/modules/contacts/contacts.module.ts index f6c3ff8..4993960 100644 --- a/backend/src/modules/contacts/contacts.module.ts +++ b/backend/src/modules/contacts/contacts.module.ts @@ -3,7 +3,13 @@ import { Module } from '@nestjs/common'; import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider'; import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider'; import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { ContactExportsController } from './contact-exports.controller'; import { ContactGroupsController } from './contact-groups.controller'; +import { + ContactDeletionsController, + ContactImportChecksController, + ContactImportsController, +} from './contact-imports.controller'; import { ContactRestorationsController } from './contact-restorations.controller'; import { ContactsController } from './contacts.controller'; import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider'; @@ -25,6 +31,11 @@ import { DirectoryService } from './directory.service'; ContactGroupsController, ContactRestorationsController, DirectoryEntriesController, + // 16단계 — 가져오기 확인 · 넣기 · 한꺼번에 지우기 · 내보내기 + ContactImportChecksController, + ContactImportsController, + ContactDeletionsController, + ContactExportsController, ], providers: [ { provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider }, diff --git a/backend/src/modules/contacts/contacts.provider.ts b/backend/src/modules/contacts/contacts.provider.ts index 295a5c2..97fd03e 100644 --- a/backend/src/modules/contacts/contacts.provider.ts +++ b/backend/src/modules/contacts/contacts.provider.ts @@ -103,16 +103,30 @@ export type AddressBookCardUpdate = readonly memberUids?: readonly string[]; }; -/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */ +/** + * 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다). + * 카드 수(만들기 + 고치기 + 지우기)는 서비스가 MAX_CARD_CHANGES_PER_WRITE(엔진의 한 요청 상한) 안으로 맞춘다 + */ export interface AddressBookChanges { readonly create: readonly NewAddressBookCard[]; readonly update: readonly AddressBookCardUpdate[]; readonly destroy: readonly string[]; } +/** 바꾸기의 선택 (16단계 — 가져오기 · 한꺼번에 지우기) */ +export interface AddressBookWriteOptions { + /** + * 엔진이 받지 않은 **만들기**를 던지지 않고 결과(written 의 refusedKeys)에 적는다 — 가져오기. + * 받은 만들기 · 고치기는 들어간다. 고치기 · 지우기의 거절과 동시 고치기 거절(busy)은 이 선택과 상관없이 지금처럼 다룬다 + */ + readonly allowPartialCreate?: boolean; + /** 지울 카드의 원본(되돌리기 표)을 읽지 않는다 — 한꺼번에 지우기. 그 판에 카드가 있는지는 그대로 본다(없으면 conflict) */ + readonly withoutOriginals?: boolean; +} + /** * 바꾸기의 결과 - * - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) · + * - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다 — allowPartialCreate 면 refusedKeys 를 뺀 것) · * 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본) * - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다) * - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다 @@ -125,6 +139,8 @@ export type AddressBookWriteOutcome = readonly createdIds: ReadonlyMap; readonly cards: ReadonlyMap; readonly removed: ReadonlyMap; + /** 엔진이 받지 않은 만들기의 key — allowPartialCreate 로 쓸 때만 있다(없으면 모두 받았다) */ + readonly refusedKeys?: ReadonlySet; } | { readonly kind: 'conflict' } | { @@ -143,12 +159,13 @@ export interface ContactsProvider { * 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음). * 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict). * 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 — - * 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001 + * 받은 것은 이미 바뀌었을 수 있다 — options.allowPartialCreate 면 받지 않은 만들기만은 던지지 않고 결과에 적는다). 주소록이 없는데 만들 카드가 있으면 MAIL_001 */ writeAddressBook( user: string, version: string, changes: AddressBookChanges, + options?: AddressBookWriteOptions, ): Promise; } diff --git a/backend/src/modules/contacts/contacts.service.spec.ts b/backend/src/modules/contacts/contacts.service.spec.ts index 381ba9c..5330d19 100644 --- a/backend/src/modules/contacts/contacts.service.spec.ts +++ b/backend/src/modules/contacts/contacts.service.spec.ts @@ -2,11 +2,14 @@ import { Logger } from '@nestjs/common'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; +import { kstDateString } from '@/shared/utils/kst-date'; import { cardWeight } from './address-book'; import { CONTACT_RESTORE_MAX_CARD_BYTES, CONTACT_RESTORE_TTL_MS, MAX_ADDRESS_BOOK_WEIGHT, + MAX_CARD_CHANGES_PER_WRITE, + MAX_CONTACT_DELETIONS, MAX_CONTACT_GROUP_MEMBERS, MAX_CONTACT_GROUPS, MAX_CONTACTS, @@ -17,6 +20,7 @@ import type { AddressBookContactCard, AddressBookGroupCard, AddressBookSnapshot, + AddressBookWriteOptions, AddressBookWriteOutcome, ContactsProvider, RemovedCardOriginal, @@ -24,6 +28,7 @@ import type { import { ContactsService } from './contacts.service'; import type { ContactFields, + ContactImportItem, ContactInput, DirectoryEntry, } from './contacts.types'; @@ -61,6 +66,10 @@ class FakeAddressBook implements ContactsProvider { failure: BusinessException | null = null; /** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */ readonly busy: ('lost' | 'applied')[] = []; + /** 엔진이 만들기를 받지 않을 uid (16단계 — 가져오기의 일부만 받기) */ + readonly refuseUids = new Set(); + /** 쓰기마다 받은 선택 */ + readonly writeOptions: (AddressBookWriteOptions | undefined)[] = []; private nextId = 1; readonly getAddressBookVersion = jest.fn(() => @@ -77,7 +86,9 @@ class FakeAddressBook implements ContactsProvider { _user: string, version: string, changes: AddressBookChanges, + options?: AddressBookWriteOptions, ): Promise => { + this.writeOptions.push(options); if (this.outsideChanges > 0) { this.outsideChanges -= 1; this.state += 1; @@ -91,14 +102,30 @@ class FakeAddressBook implements ContactsProvider { this.state += 1; return Promise.reject(failure); } + const refusedKeys = new Set( + changes.create + .filter((card) => this.refuseUids.has(card.uid)) + .map((card) => card.key), + ); + if (refusedKeys.size > 0 && !options?.allowPartialCreate) { + // 엔진이 받지 않은 만들기 — 일부만 받기가 아니면 그 코드로 던진다(받은 것은 들어갔다) + this.apply(changes, refusedKeys); + return Promise.reject( + new BusinessException('VAL_001', { + context: { reason: 'address-book-change-refused' }, + }), + ); + } const removed = new Map(); - for (const id of changes.destroy) { - const card = this.contacts.get(id) ?? this.groups.get(id); - if (card) removed.set(id, this.original(card)); + if (!options?.withoutOriginals) { + for (const id of changes.destroy) { + const card = this.contacts.get(id) ?? this.groups.get(id); + if (card) removed.set(id, this.original(card)); + } } const mode = this.busy.shift(); if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed }); - const { createdIds, cards } = this.apply(changes); + const { createdIds, cards } = this.apply(changes, refusedKeys); if (mode === 'applied') { return Promise.resolve({ kind: 'busy', removed }); } @@ -108,6 +135,7 @@ class FakeAddressBook implements ContactsProvider { createdIds, cards, removed, + ...(options?.allowPartialCreate ? { refusedKeys } : {}), }); }, ); @@ -165,7 +193,10 @@ class FakeAddressBook implements ContactsProvider { return { source, bytes: Buffer.byteLength(source, 'utf8') }; } - private apply(changes: AddressBookChanges): { + private apply( + changes: AddressBookChanges, + refusedKeys: ReadonlySet = new Set(), + ): { createdIds: Map; cards: Map; } { @@ -173,6 +204,7 @@ class FakeAddressBook implements ContactsProvider { const createdIds = new Map(); const cards = new Map(); for (const card of changes.create) { + if (refusedKeys.has(card.key)) continue; const id = `n${this.nextId++}`; createdIds.set(card.key, id); if (card.kind === 'contact') { @@ -1364,4 +1396,413 @@ describe('ContactsService (주소록)', () => { expect(book.contacts.size).toBe(2); }); }); + + describe('16단계 — 가져오기 · 한꺼번에 지우기 · 내보내기 (계약 3절 "가져오기 · 내보내기")', () => { + const uidOf = (n: number) => + `urn:uuid:00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; + const item = ( + n: number, + overrides: Partial = {}, + ): ContactImportItem => ({ + ...emptyFields, + name: `사람${n}`, + emails: [{ address: `p${n}@import.example`, label: 'work' }], + groupIds: [], + uid: uidOf(n), + ...overrides, + }); + const logged = () => + logSpy.mock.calls.map(([message]) => String(message)).join('\n'); + + describe('가져오기 확인 (checkImport)', () => { + it('같은 이메일(대소문자 무시) · 이름 + 전화 숫자 · 같은 uid 는 exists, 나머지는 new — remaining 은 3,000 − 지금 수, 쓰지 않는다', async () => { + book.seedContact('c1', { + name: '박지훈', + emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }], + }); + book.seedContact('c2', { + name: '김철수', + phones: [{ number: '010-1111-2222', label: 'mobile' }], + }); + + const result = await service.checkImport(ARA, [ + item(1, { + emails: [{ address: ' jihoon@hanbit.EXAMPLE', label: 'personal' }], + }), + item(2, { + name: '김철수', + emails: [], + phones: [{ number: '01011112222', label: 'other' }], + }), + item(3, { uid: 'urn:uuid:c1' }), + item(4), + item(5, { name: '김철수', emails: [], phones: [] }), + ]); + + expect(result).toEqual({ + items: [ + { uid: uidOf(1), status: 'exists' }, + { uid: uidOf(2), status: 'exists' }, + { uid: 'urn:uuid:c1', status: 'exists' }, + { uid: uidOf(4), status: 'new' }, + { uid: uidOf(5), status: 'new' }, + ], + remaining: MAX_CONTACTS - 2, + }); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('remaining 은 0 아래로 내려가지 않는다 (다른 프로그램이 3,000명 넘게 넣은 주소록)', async () => { + for (let index = 0; index < MAX_CONTACTS + 2; index += 1) { + book.seedContact(`s${index}`); + } + await expect( + service.checkImport(ARA, [item(1)]), + ).resolves.toMatchObject({ remaining: 0 }); + }); + + it('묶음 검사 — 0명 · 101명 · 같은 uid · 이름도 이메일도 없음 · 같은 주소 두 번은 엔진에 묻기 전에 VAL_001', async () => { + const batches: ContactImportItem[][] = [ + [], + Array.from({ length: 101 }, (_, n) => item(n)), + [item(1), item(2, { uid: uidOf(1) })], + [item(1, { name: null, emails: [] })], + [ + item(1, { + emails: [ + { address: 'a@x.example', label: 'work' }, + { address: 'A@X.example', label: 'other' }, + ], + }), + ], + ]; + for (const batch of batches) { + expect((await failureOf(service.checkImport(ARA, batch))).code).toBe( + 'VAL_001', + ); + expect( + (await failureOf(service.importContacts(ARA, batch))).code, + ).toBe('VAL_001'); + } + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + }); + + describe('가져오기 넣기 (importContacts)', () => { + it('보낸 uid 로 엔진 요청 하나에 만들고 고른 그룹에 넣는다 — 응답은 보낸 차례, 다시 읽지 않고 바로 목록에 보인다', async () => { + book.seedGroup('g1', '거래처'); + book.seedGroup('g2', '협력사'); + + const result = await service.importContacts(ARA, [ + item(2, { groupIds: ['g1', 'g2'] }), + item(1, { groupIds: ['g1'] }), + ]); + + expect(book.writeAddressBook).toHaveBeenCalledTimes(1); + expect(book.writeOptions).toEqual([{ allowPartialCreate: true }]); + const [write] = book.writes; + expect(write.create.map((card) => card.uid)).toEqual([ + uidOf(2), + uidOf(1), + ]); + expect(result.items).toEqual([ + { + uid: uidOf(2), + id: expect.any(String) as unknown, + skippedGroupIds: [], + }, + { + uid: uidOf(1), + id: expect.any(String) as unknown, + skippedGroupIds: [], + }, + ]); + expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2), uidOf(1)]); + expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(2)]); + + const reads = book.reads; + const page = await service.list(ARA, listQuery()); + expect(page.items.map((contact) => contact.name)).toEqual([ + '사람1', + '사람2', + ]); + expect(page.items[1].groupIds).toEqual(['g1', 'g2']); + expect(book.reads).toBe(reads); + }); + + it('같은 묶음을 다시 보내면(응답을 잃음) 만들지 않고 같은 id 를 준다 — 빠진 그룹만 마저 넣는다', async () => { + book.seedGroup('g1', '거래처'); + const first = await service.importContacts(ARA, [item(1), item(2)]); + const writes = book.writes.length; + + const again = await service.importContacts(ARA, [ + item(1), + item(2, { groupIds: ['g1'] }), + ]); + + expect(again.items.map((entry) => entry.id)).toEqual( + first.items.map((entry) => entry.id), + ); + expect(book.contacts.size).toBe(2); + // 다시 보낸 쓰기에는 만들기가 없고 그룹 넣기만 있다 + expect(book.writes).toHaveLength(writes + 1); + expect(book.writes.at(-1)?.create).toEqual([]); + expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2)]); + }); + + it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 그 그룹만 빼고(skippedGroupIds) 만들고 다른 그룹에는 넣는다`, async () => { + const members: string[] = []; + for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) { + members.push(book.seedContact(`m${index}`).uid ?? ''); + } + book.seedGroup('g1', '거의 참', members); + book.seedGroup('g2', '여유'); + + const result = await service.importContacts(ARA, [ + item(1, { groupIds: ['g1', 'g2'] }), + item(2, { groupIds: ['g1', 'g2'] }), + ]); + + expect(result.items.map((entry) => entry.skippedGroupIds)).toEqual([ + [], + ['g1'], + ]); + expect(book.groups.get('g1')?.memberUids).toHaveLength( + MAX_CONTACT_GROUP_MEMBERS, + ); + expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(1), uidOf(2)]); + expect(logged()).toContain('구성원 한도로 그룹에 넣지 못함 1건'); + }); + + it(`연락처가 ${MAX_CONTACTS}명을 넘게 되면 묶음 전체를 CONTACT_002 — 아무것도 만들지 않는다 · 없는 그룹은 RES_001`, async () => { + for (let index = 0; index < MAX_CONTACTS - 1; index += 1) { + book.seedContact(`s${index}`); + } + const failure = await failureOf( + service.importContacts(ARA, [item(1), item(2)]), + ); + expect(failure.code).toBe('CONTACT_002'); + expect(failure.context?.reason).toBe('contacts-limit'); + expect( + ( + await failureOf( + service.importContacts(ARA, [item(1, { groupIds: ['nope'] })]), + ) + ).code, + ).toBe('RES_001'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + + // 한 명은 들어간다 + await expect( + service.importContacts(ARA, [item(1)]), + ).resolves.toMatchObject({ items: [{ uid: uidOf(1) }] }); + }); + + it(`크기 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 를 넘게 하는 묶음은 CONTACT_002 — 아무것도 만들지 않는다`, async () => { + // 792만 남짓 — 한 명은 들어가고 100명 묶음(사람마다 1,100 남짓)은 넘는다 + const note = 'x'.repeat(990_000); + for (let index = 0; index < 8; index += 1) { + book.seedContact(`big${index}`, { note }); + } + const failure = await failureOf( + service.importContacts( + ARA, + Array.from({ length: 100 }, (_, n) => + item(n, { note: 'y'.repeat(1_000) }), + ), + ), + ); + expect(failure.code).toBe('CONTACT_002'); + expect(failure.context?.reason).toBe('address-book-too-large'); + expect(book.writeAddressBook).not.toHaveBeenCalled(); + }); + + it('엔진이 일부만 받으면 받은 것만 돌려준다 — 던지지 않고, 기억한 주소록도 엔진과 같다(다시 읽지 않는다)', async () => { + book.refuseUids.add(uidOf(2)); + + const result = await service.importContacts(ARA, [ + item(1), + item(2), + item(3), + ]); + + expect(result.items.map((entry) => entry.uid)).toEqual([ + uidOf(1), + uidOf(3), + ]); + expect(book.contacts.size).toBe(2); + const reads = book.reads; + const page = await service.list(ARA, listQuery()); + expect(page.items.map((contact) => contact.name)).toEqual([ + '사람1', + '사람3', + ]); + expect(book.reads).toBe(reads); + expect(logged()).toContain('넣지 못함 1명'); + }); + + it('엔진이 바쁘다고 했지만 들어갔으면(applied) 다음 시도가 같은 uid 로 찾아 두 번 만들지 않는다', async () => { + book.busy.push('applied'); + const result = await service.importContacts(ARA, [item(1), item(2)]); + expect(result.items.map((entry) => entry.uid)).toEqual([ + uidOf(1), + uidOf(2), + ]); + expect(book.contacts.size).toBe(2); + expect(book.writeAddressBook).toHaveBeenCalledTimes(1); + }); + + it('그룹 카드가 쓰는 uid 는 만들지 않는다(엔진이 받지 않는다) — 응답에서 빠진다', async () => { + book.seedGroup('g1', '거래처'); + const result = await service.importContacts(ARA, [ + item(1, { uid: 'urn:uuid:g1' }), + item(2), + ]); + expect(result.items.map((entry) => entry.uid)).toEqual([uidOf(2)]); + expect(book.contacts.size).toBe(1); + }); + + it('로그에는 개수만 — 이름 · 주소 · uid 는 남기지 않는다', async () => { + await service.importContacts(ARA, [item(1, { note: '비밀 메모' })]); + expect(logged()).toContain('연락처를 가져왔습니다'); + expect(logged()).not.toMatch( + /사람1|import\.example|비밀 메모|urn:uuid|ara\.kim@/i, + ); + }); + }); + + describe('한꺼번에 지우기 (removeContacts)', () => { + it('지우고 그룹 구성원에서도 뺀다 — 없는 id · 그룹 id 는 notFoundIds(보낸 차례), 되돌리기 원본은 읽지 않는다', async () => { + book.seedContact('c1'); + book.seedContact('c2'); + book.seedContact('c3'); + book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c3']); + + const result = await service.removeContacts(ARA, [ + 'c3', + 'gone', + 'c1', + 'g1', + ]); + + expect(result).toEqual({ + deletedIds: ['c3', 'c1'], + notFoundIds: ['gone', 'g1'], + }); + expect(book.writeOptions).toEqual([{ withoutOriginals: true }]); + expect([...book.contacts.keys()]).toEqual(['c2']); + expect(book.groups.get('g1')?.memberUids).toEqual([]); + expect(logged()).toContain('연락처를 한꺼번에 지웠습니다: 2명'); + }); + + it(`지울 연락처 + 고칠 그룹이 엔진 한 요청의 상한(${MAX_CARD_CHANGES_PER_WRITE})을 넘으면 앞에서부터 나눠 쓴다`, async () => { + const ids: string[] = []; + const uids: string[] = []; + for (let index = 0; index < MAX_CONTACT_DELETIONS; index += 1) { + const card = book.seedContact(`d${index}`); + ids.push(card.id); + uids.push(card.uid ?? ''); + } + book.seedGroup('g1', '모두', uids); + + const result = await service.removeContacts(ARA, ids); + + expect(result.deletedIds).toHaveLength(MAX_CONTACT_DELETIONS); + expect(result.notFoundIds).toEqual([]); + expect(book.writes.map((write) => write.destroy.length)).toEqual([ + MAX_CARD_CHANGES_PER_WRITE - 1, + 1, + ]); + for (const write of book.writes) { + expect( + write.destroy.length + write.update.length, + ).toBeLessThanOrEqual(MAX_CARD_CHANGES_PER_WRITE); + } + expect(book.contacts.size).toBe(0); + expect(book.groups.get('g1')?.memberUids).toEqual([]); + }); + + it('같은 uid 의 다른 카드가 남으면 그 uid 는 그룹에 그대로 둔다', async () => { + book.seedContact('c1', { uid: 'urn:uuid:shared' }); + book.seedContact('c2', { uid: 'urn:uuid:shared' }); + book.seedGroup('g1', '거래처', ['urn:uuid:shared']); + + await service.removeContacts(ARA, ['c1']); + expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:shared']); + expect(book.writes.at(-1)?.update).toEqual([]); + }); + + it('바쁘다고 했지만 들어갔으면 — 다음 시도에서 없어진 id 도 지운 것으로 센다(notFound 가 아니다)', async () => { + book.seedContact('c1'); + book.seedContact('c2'); + book.busy.push('applied'); + + await expect( + service.removeContacts(ARA, ['c1', 'c2']), + ).resolves.toEqual({ deletedIds: ['c1', 'c2'], notFoundIds: [] }); + }); + + it('0개 · 501개 · 같은 id 두 번은 VAL_001 — 엔진에 묻지 않는다', async () => { + for (const ids of [ + [], + Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `x${n}`), + ['c1', 'c1'], + ]) { + expect((await failureOf(service.removeContacts(ARA, ids))).code).toBe( + 'VAL_001', + ); + } + expect(book.getAddressBookVersion).not.toHaveBeenCalled(); + }); + }); + + describe('내보내기 (exportContacts)', () => { + beforeEach(() => { + book.seedContact('c-pa', { + name: '박지훈', + emails: [{ address: 'jihoon@hanbit.example', label: 'work' }], + }); + book.seedContact('c-ga', { + name: '가나다', + phones: [{ number: '010-1234-5678', label: 'mobile' }], + }); + book.seedGroup('g1', '거래처', ['urn:uuid:c-pa']); + }); + + it('vCard — 내 주소록 전체를 이름 차례로, 그룹 이름은 CATEGORIES · 파일 이름은 한국 날짜', async () => { + const file = await service.exportContacts(ARA, 'vcard'); + const text = file.content.toString('utf8'); + + expect(file.fileName).toBe( + `주소록-${kstDateString(new Date(now))}.vcf`, + ); + expect(file.contentType).toBe('text/vcard; charset=utf-8'); + expect(file.count).toBe(2); + expect(text.indexOf('FN:가나다')).toBeLessThan( + text.indexOf('FN:박지훈'), + ); + expect(text).toContain('UID:urn:uuid:c-pa\r\n'); + expect(text).toContain('CATEGORIES:거래처\r\n'); + expect(logged()).toContain( + '주소록을 내보냈습니다: 형식=vcard, 연락처 2명', + ); + expect(logged()).not.toMatch(/박지훈|가나다|hanbit|ara\.kim@/); + }); + + it('CSV — 그룹 하나만(groupId), 파일 이름에 그룹 이름 · 없는 그룹은 RES_001', async () => { + const file = await service.exportContacts(ARA, 'csv', 'g1'); + const rows = file.content.toString('utf8').split('\r\n'); + + expect(file.fileName).toBe( + `주소록-거래처-${kstDateString(new Date(now))}.csv`, + ); + expect(rows).toHaveLength(3); + expect(rows[1]).toBe('박지훈,jihoon@hanbit.example,,,,,,,,,거래처'); + expect( + (await failureOf(service.exportContacts(ARA, 'csv', 'nope'))).code, + ).toBe('RES_001'); + }); + }); + }); }); diff --git a/backend/src/modules/contacts/contacts.service.ts b/backend/src/modules/contacts/contacts.service.ts index a13e2ad..b3dfec7 100644 --- a/backend/src/modules/contacts/contacts.service.ts +++ b/backend/src/modules/contacts/contacts.service.ts @@ -20,6 +20,19 @@ import { type WrittenChanges, } from './address-book'; import { ContactBookCache } from './contact-book-cache'; +import { + contactExportContent, + contactExportContentType, + contactExportFileName, + type ExportedContact, +} from './contact-export'; +import { + deletionChunkSize, + importDuplicateIndex, + isExistingContact, + planGroupJoins, + type GroupRoom, +} from './contact-import'; import { cursorOffset, listScope, pageOf } from './contact-paging'; import { ContactRestoreStore, @@ -38,8 +51,11 @@ import { CONTACT_RESTORE_TTL_MS, CONTACT_WRITE_ATTEMPTS, MAX_ADDRESS_BOOK_WEIGHT, + MAX_CARD_CHANGES_PER_WRITE, + MAX_CONTACT_DELETIONS, MAX_CONTACT_GROUP_MEMBERS, MAX_CONTACT_GROUPS, + MAX_CONTACT_IMPORT_BATCH, MAX_CONTACTS, MAX_GROUP_MEMBERS_PER_REQUEST, } from './contacts.constants'; @@ -48,6 +64,7 @@ import { type AddressBookCardUpdate, type AddressBookChanges, type AddressBookContactCard, + type AddressBookWriteOptions, type ContactsProvider, type NewAddressBookCard, type RemovedCardOriginal, @@ -56,14 +73,20 @@ import type { Contact, ContactDeleted, ContactEmail, + ContactExportFile, + ContactExportFormat, ContactFields, ContactGroup, ContactGroupDeleted, ContactGroupMembersAdded, + ContactImportCheck, + ContactImportItem, + ContactImportResult, ContactInput, ContactPatch, ContactPhone, ContactRestored, + ContactsDeleted, ContactSuggestionLine, DirectoryEntry, } from './contacts.types'; @@ -79,6 +102,8 @@ const NEW_CONTACT_KEY = 'contact'; const NEW_GROUP_KEY = 'group'; const NEW_MEMBER_KEY_PREFIX = 'member-'; const RESTORED_KEY = 'restored'; +/** 가져오기의 한 사람 — 보낸 차례 번호를 붙인다 (import-0 …) */ +const IMPORT_KEY_PREFIX = 'import-'; const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] }; @@ -105,6 +130,8 @@ interface ChangePlan { readonly result: (after: BookView, written: WriteResult) => T; /** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */ readonly log?: string; + /** 엔진에 쓰는 선택 — 가져오기(일부만 받기) · 한꺼번에 지우기(원본 읽지 않기) (16단계) */ + readonly write?: AddressBookWriteOptions; } /** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */ @@ -269,7 +296,9 @@ function joinGroups(groups: readonly BookGroup[], uid: string) { * 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 — * CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts) * - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts) - * - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다 + * - 16단계 — 가져오기(확인 · 100명씩 넣기 — 화면이 준 uid 로 두 번 들어가지 않게, 엔진이 일부만 받아도 던지지 않는다 · contact-import.ts), + * 한꺼번에 지우기(가져오기 [되돌리기] — 되돌리기 표 없음), 내보내기(vCard · CSV — contact-export.ts) + * - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표 · 파일 내용은 남기지 않는다 * * ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다. * 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404). @@ -288,12 +317,15 @@ export class ContactsService { private readonly loading = new Map(); /** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */ private readonly writeQueues = new Map>(); + /** 되돌리기 표의 시계 · 내보낸 파일 이름의 날짜 */ + private readonly now: () => number; constructor( @Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider, private readonly directory: DirectoryService, @Optional() @Inject(CONTACTS_CLOCK) clock?: () => number, ) { + this.now = clock ?? (() => Date.now()); this.restorations = new ContactRestoreStore( { ttlMs: CONTACT_RESTORE_TTL_MS, @@ -302,7 +334,7 @@ export class ContactsService { maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES, tokenBytes: CONTACT_RESTORE_TOKEN_BYTES, }, - clock ?? (() => Date.now()), + this.now, ); } @@ -840,6 +872,261 @@ export class ContactsService { }); } + /** + * POST /api/contact-import-checks — 가져올 연락처가 내 주소록에 이미 있는지 · 더 넣을 수 있는 수 (계약 3절 "가져오기" — 16단계). 만들지 않는다. + * 이미 있음: 같은 uid 의 연락처(다시 보낸 묶음) 또는 contact-import.ts 의 규칙(이메일 · 이름 + 전화). 항목 검사(이름이나 이메일 · 같은 주소 · 같은 uid)는 + * 넣기와 같다 — 하나라도 걸리면 VAL_001. groupIds 는 보지 않는다(새 그룹은 넣기 직전에 만든다) + */ + async checkImport( + user: AuthenticatedUser, + items: readonly ContactImportItem[], + ): Promise { + this.assertImportItems(items); + const book = await this.freshBook(user.email); + const index = importDuplicateIndex( + book.contacts.map((entry) => entry.contact), + ); + return { + items: items.map((item) => ({ + uid: item.uid, + status: + book.contactsByUid.has(item.uid) || isExistingContact(index, item) + ? 'exists' + : 'new', + })), + remaining: Math.max(0, MAX_CONTACTS - book.contacts.length), + }; + } + + /** + * POST /api/contact-imports — 가져온 연락처를 엔진 요청 하나로 만든다 (계약 3절 "가져오기" — 16단계). + * - 줄마다 화면이 준 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음 · 앞 시도가 들어감) 만들지 않고 그 연락처를 돌려준다 — 빠진 그룹만 마저 넣는다 + * - 연락처 수 · 크기 한도는 묶음 전체로 본다 — 넘으면 CONTACT_002, 아무것도 만들지 않는다. 없는 그룹은 RES_001 + * - 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고(skippedGroupIds) 만든다 — 실패가 아니다 + * - 엔진이 일부만 받으면 받은 것만 돌려준다(빠진 uid 는 화면이 "넣지 못함"으로) — 던지지 않는다. 그룹 카드가 쓰는 uid 도 만들지 않는다(엔진이 받지 않는다) + * - 겹침(같은 이메일 등)은 보지 않는다 — 확인(checkImport)의 몫 + */ + async importContacts( + actor: AuthenticatedUser, + items: readonly ContactImportItem[], + ): Promise { + this.assertImportItems(items); + const result = await this.change(actor, (book) => { + const groups = new Map(); + for (const item of items) { + for (const groupId of item.groupIds) { + if (!groups.has(groupId)) { + groups.set(groupId, this.requireGroup(book, groupId)); + } + } + } + + /** 이미 있는 같은 uid 의 연락처 */ + const landed = new Map(); + /** uid → 이 바꾸기 안의 key */ + const keys = new Map(); + items.forEach((item, index) => { + const existing = book.contactsByUid.get(item.uid)?.[0]; + if (existing) landed.set(item.uid, existing); + else if (!book.uidsInUse.has(item.uid)) { + keys.set(item.uid, `${IMPORT_KEY_PREFIX}${index}`); + } + }); + if (book.contacts.length + keys.size > MAX_CONTACTS) { + throw overLimit('contacts-limit', { + contacts: book.contacts.length, + creating: keys.size, + }); + } + + const rooms = new Map( + [...groups].map(([id, group]) => [ + id, + { + memberUids: new Set(group.card.memberUids), + memberCount: group.group.memberCount, + }, + ]), + ); + const joins = planGroupJoins( + rooms, + items.filter((item) => landed.has(item.uid) || keys.has(item.uid)), + MAX_CONTACT_GROUP_MEMBERS, + ); + const update: AddressBookCardUpdate[] = []; + for (const [groupId, group] of groups) { + const adding = joins.adds.get(groupId); + if (adding && adding.length > 0) { + update.push({ + kind: 'group', + id: groupId, + memberUids: withMembers(group, adding), + }); + } + } + return { + changes: { + create: items.flatMap((item): NewAddressBookCard[] => { + const key = keys.get(item.uid); + return key === undefined + ? [] + : [ + { + kind: 'contact', + key, + uid: item.uid, + fields: fieldsOf(item), + }, + ]; + }), + update, + destroy: [], + }, + write: { allowPartialCreate: true }, + result: (_after, written) => ({ + items: items.flatMap((item) => { + const key = keys.get(item.uid); + const id = + landed.get(item.uid)?.card.id ?? + (key === undefined ? undefined : written.createdIds.get(key)); + return id === undefined + ? [] + : [ + { + uid: item.uid, + id, + skippedGroupIds: [...(joins.skipped.get(item.uid) ?? [])], + }, + ]; + }), + }), + }; + }); + const skipped = result.items.reduce( + (sum, item) => sum + item.skippedGroupIds.length, + 0, + ); + this.logger.log( + `${traceTag()} 연락처를 가져왔습니다: 보낸 ${items.length}명 가운데 ${result.items.length}명(이미 있던 같은 uid 포함), 넣지 못함 ${items.length - result.items.length}명, 구성원 한도로 그룹에 넣지 못함 ${skipped}건 (user=${maskEmail(normalizeUser(actor.email))})`, + ); + return result; + } + + /** + * POST /api/contact-deletions — 연락처 여러 개를 영구 삭제한다 (가져오기 [되돌리기] — 계약 3절 "가져오기" — 16단계). 되돌리기 표는 주지 않는다. + * 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로). 내 주소록에 없는 id(그룹 id 포함)는 notFoundIds. + * 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 상한을 넘으면 앞에서부터 나눠 쓴다 — 중간에 실패하면 앞 몫은 지워진 채다(다시 보내면 notFoundIds) + */ + async removeContacts( + actor: AuthenticatedUser, + ids: readonly string[], + ): Promise { + if (ids.length < 1 || ids.length > MAX_CONTACT_DELETIONS) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-deletions-count', requested: ids.length }, + }); + } + if (new Set(ids).size !== ids.length) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-deletions-duplicate' }, + }); + } + const deleted = new Set(); + let pending: readonly string[] = ids; + while (pending.length > 0) { + const batch = pending; + /** 이 몫에서 지우려 했던 id — 엔진이 바쁘다고 했는데 들어갔으면 다음 시도에서 없어진 것을 지운 것으로 센다 */ + const tried = new Set(); + const step = await this.change(actor, (book, attempt) => { + const present = batch.filter((id) => book.contactsById.has(id)); + const landed = attempt.uncertain + ? batch.filter((id) => tried.has(id) && !book.contactsById.has(id)) + : []; + const groupIdsByUid = new Map(); + for (const group of book.groups) { + for (const uid of group.card.memberUids) { + const groupIds = groupIdsByUid.get(uid) ?? []; + groupIds.push(group.card.id); + groupIdsByUid.set(uid, groupIds); + } + } + const size = deletionChunkSize( + present.map((id) => { + const uid = book.contactsById.get(id)?.card.uid ?? null; + return uid === null ? [] : (groupIdsByUid.get(uid) ?? []); + }), + MAX_CARD_CHANGES_PER_WRITE, + ); + const now = present.slice(0, size); + for (const id of now) tried.add(id); + const update = this.leavingGroups(book, now); + return { + changes: { create: [], update, destroy: now }, + write: { withoutOriginals: true }, + result: () => ({ + deleted: [...landed, ...now], + rest: present.slice(size), + }), + log: `연락처를 한꺼번에 지웠습니다: ${now.length}명, 그룹에서 뺌 ${update.length}개`, + }; + }); + for (const id of step.deleted) deleted.add(id); + pending = step.rest; + } + return { + deletedIds: ids.filter((id) => deleted.has(id)), + notFoundIds: ids.filter((id) => !deleted.has(id)), + }; + } + + /** + * GET /api/contact-exports — 내 주소록 전체 또는 그룹 하나를 vCard 3.0 · CSV 로 (계약 3절 "내보내기" — 16단계). + * 이름 차례, 우리 칸만(contact-export.ts). 사내 목록은 넣지 않는다. 없는 그룹은 RES_001. 로그에는 형식 · 개수 · 그룹 id 만 + */ + async exportContacts( + user: AuthenticatedUser, + format: ContactExportFormat, + groupId?: string, + ): Promise { + const book = await this.freshBook(user.email); + const group = + groupId === undefined ? null : this.requireGroup(book, groupId); + const entries = + group === null + ? book.contacts + : group.memberIds.flatMap((id) => { + const entry = book.contactsById.get(id); + return entry ? [entry] : []; + }); + const groupNames = new Map( + book.groups.map((entry) => [entry.card.id, entry.card.name]), + ); + const contacts = entries.map( + (entry): ExportedContact => ({ + ...fieldsOf(entry.contact), + uid: entry.card.uid, + groupNames: entry.contact.groupIds.flatMap((id) => { + const name = groupNames.get(id); + return name ? [name] : []; + }), + }), + ); + const content = Buffer.from(contactExportContent(format, contacts), 'utf8'); + this.logger.log( + `${traceTag()} 주소록을 내보냈습니다: 형식=${format}, 연락처 ${contacts.length}명, 범위=${group === null ? '전체' : `그룹 ${group.card.id}`} (user=${maskEmail(normalizeUser(user.email))})`, + ); + return { + fileName: contactExportFileName( + format, + group?.card.name ?? null, + new Date(this.now()), + ), + contentType: contactExportContentType(format), + content, + count: contacts.length, + }; + } + /** * GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다. * 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001 @@ -1071,6 +1358,7 @@ export class ContactsService { user, book.version, planned.changes, + planned.write, ); } catch (error) { // 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다 @@ -1175,6 +1463,57 @@ export class ContactsService { } } + /** + * 가져오기 · 확인의 묶음 — 1~100명, 사람마다 연락처 추가와 같은 검사(이름이나 이메일 · 같은 주소), 묶음 안의 같은 uid 는 안 된다. + * 하나라도 걸리면 엔진에 묻기 전에 VAL_001 (화면이 같은 규칙으로 미리 거른다) + */ + private assertImportItems(items: readonly ContactImportItem[]): void { + if (items.length < 1 || items.length > MAX_CONTACT_IMPORT_BATCH) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-import-count', requested: items.length }, + }); + } + const uids = new Set(); + for (const item of items) { + this.assertNameOrEmail(item); + this.assertDistinctEmails(item.emails); + if (uids.has(item.uid)) { + throw new BusinessException('VAL_001', { + context: { reason: 'contact-import-duplicate-uid' }, + }); + } + uids.add(item.uid); + } + } + + /** + * 연락처들을 지울 때 고칠 그룹 — 지우는 카드의 uid 를 구성원에서 뺀다. 같은 uid 의 다른 카드가 남으면(이번에 지우지 않는 카드) 그 uid 는 그대로 둔다. + * 그룹 구성원을 한 번 훑는다 + */ + private leavingGroups( + book: BookView, + ids: readonly string[], + ): AddressBookCardUpdate[] { + const removing = new Set(ids); + const uids = new Set(); + for (const id of ids) { + const uid = book.contactsById.get(id)?.card.uid ?? null; + if (uid === null) continue; + const owners = book.contactsByUid.get(uid) ?? []; + if (owners.every((owner) => removing.has(owner.card.id))) uids.add(uid); + } + if (uids.size === 0) return []; + return book.groups + .filter((group) => group.card.memberUids.some((uid) => uids.has(uid))) + .map( + (group): AddressBookCardUpdate => ({ + kind: 'group', + id: group.card.id, + memberUids: withMembers(group, [], uids), + }), + ); + } + /** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */ private assertWeight(book: BookView, changes: AddressBookChanges): void { const after = weightAfterChanges(book, changes); diff --git a/backend/src/modules/contacts/contacts.types.ts b/backend/src/modules/contacts/contacts.types.ts index 043708f..71ca173 100644 --- a/backend/src/modules/contacts/contacts.types.ts +++ b/backend/src/modules/contacts/contacts.types.ts @@ -104,6 +104,70 @@ export interface ContactGroupMembersAdded { readonly createdCount: number; } +/** + * 가져오기의 한 사람 (계약 3절 ContactImportItem — 16단계) — 연락처 추가 본문(ContactInput)과 같은 칸 + 화면이 만든 uid. + * 같은 uid 가 이미 주소록에 있으면(다시 보낸 요청) 새로 만들지 않고 그 연락처를 돌려준다 + */ +export interface ContactImportItem extends ContactInput { + /** `urn:uuid:` + 소문자 UUID */ + readonly uid: string; +} + +/** 가져오기 확인의 판정 — new 넣을 연락처 · exists 이미 있는 연락처(계약 3절 "가져오기") */ +export type ContactImportStatus = 'new' | 'exists'; + +export const CONTACT_IMPORT_STATUSES: readonly ContactImportStatus[] = [ + 'new', + 'exists', +]; + +/** POST /api/contact-import-checks 의 응답 */ +export interface ContactImportCheck { + /** 보낸 차례 그대로 한 줄씩 */ + readonly items: readonly { + readonly uid: string; + readonly status: ContactImportStatus; + }[]; + /** 더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로는 내려가지 않는다) */ + readonly remaining: number; +} + +/** 가져온 연락처 하나 — 화면이 준 uid · 엔진의 카드 id · 구성원 500명이 차서 넣지 못한 그룹 */ +export interface ContactImported { + readonly uid: string; + readonly id: string; + readonly skippedGroupIds: readonly string[]; +} + +/** POST /api/contact-imports 의 응답 — 넣은(또는 이미 있던 같은 uid 의) 연락처만, 보낸 차례 그대로. 빠진 uid 는 엔진이 받지 않은 것 */ +export interface ContactImportResult { + readonly items: readonly ContactImported[]; +} + +/** POST /api/contact-deletions 의 응답 — 보낸 차례 그대로 */ +export interface ContactsDeleted { + readonly deletedIds: readonly string[]; + /** 내 주소록에 없는 id (이미 지움 · 그룹 id 포함) */ + readonly notFoundIds: readonly string[]; +} + +/** 내보내기 형식 — vCard 3.0 · CSV(UTF-8 BOM) */ +export type ContactExportFormat = 'vcard' | 'csv'; + +export const CONTACT_EXPORT_FORMATS: readonly ContactExportFormat[] = [ + 'vcard', + 'csv', +]; + +/** 내보낼 파일 — 이름 · 형식 · 내용 */ +export interface ContactExportFile { + readonly fileName: string; + readonly contentType: string; + readonly content: Buffer; + /** 담은 연락처 수 (로그) */ + readonly count: number; +} + /** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */ export type DirectoryEntryKind = 'person' | 'shared'; diff --git a/backend/src/modules/contacts/dto/contact-export.dto.ts b/backend/src/modules/contacts/dto/contact-export.dto.ts new file mode 100644 index 0000000..c1abf12 --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-export.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +import { + CONTACT_EXPORT_FORMATS, + type ContactExportFormat, +} from '@/modules/contacts/contacts.types'; +import { ContactGroupIdQuery } from './contact.dto'; + +/** GET /api/contact-exports 의 쿼리 (계약 3절 "내보내기" — 16단계) — 모르는 형식 · 쿼리는 400 VAL_001 */ +export class ContactExportQueryDto { + @ApiProperty({ + description: + 'vcard — vCard 3.0(.vcf, 다른 메일 · 휴대폰으로 옮길 때) · csv — CSV(UTF-8 BOM, 엑셀에서 볼 때)', + enum: [...CONTACT_EXPORT_FORMATS], + example: 'vcard', + }) + @IsIn(CONTACT_EXPORT_FORMATS, { + message: '내보낼 형식을 다시 골라 주세요.', + }) + format!: ContactExportFormat; + + @ApiPropertyOptional({ + description: + '이 그룹의 구성원만 — 없으면 내 주소록 전체. 없는 그룹이면 404 RES_001', + example: 'jy', + }) + @ContactGroupIdQuery() + groupId?: string; +} diff --git a/backend/src/modules/contacts/dto/contact-import.dto.spec.ts b/backend/src/modules/contacts/dto/contact-import.dto.spec.ts new file mode 100644 index 0000000..48a7f7d --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-import.dto.spec.ts @@ -0,0 +1,175 @@ +import { BadRequestException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate, type ValidationError } from 'class-validator'; + +import { createValidationPipe } from '@/app.setup'; +import { + MAX_CONTACT_DELETIONS, + MAX_CONTACT_IMPORT_BATCH, +} from '@/modules/contacts/contacts.constants'; +import { ContactExportQueryDto } from './contact-export.dto'; +import { + ContactDeletionDto, + ContactImportBatchDto, +} from './contact-import.dto'; + +/** 걸린 규칙 이름 — 중첩된 칸까지 */ +function constraintNames(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}), + ...constraintNames(error.children ?? []), + ]); +} + +/** 전역 ValidationPipe 와 같은 옵션으로 (stopAtFirstError 는 고른다 — 빼면 걸린 규칙을 모두 본다) */ +async function namesOf( + type: new () => T, + body: object, + stopAtFirstError = false, +): Promise { + const dto = plainToInstance(type, body); + return constraintNames( + await validate(dto, { + whitelist: true, + forbidNonWhitelisted: true, + stopAtFirstError, + }), + ); +} + +const UID = 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + +const person = (overrides: object = {}) => ({ + name: '박지훈', + emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '한빛상사', + department: '구매팀', + title: '과장', + note: null, + groupIds: [], + uid: UID, + ...overrides, +}); + +describe('가져오기 본문 (POST /api/contact-import-checks · /api/contact-imports — 16단계)', () => { + it('맞는 묶음 — 사람마다 연락처 추가 본문 + uid, 글은 앞뒤 공백을 지운다', async () => { + expect( + await namesOf(ContactImportBatchDto, { contacts: [person()] }), + ).toEqual([]); + const dto = plainToInstance(ContactImportBatchDto, { + contacts: [person({ name: ' 김아라 ', company: ' ' })], + }); + expect(await validate(dto)).toEqual([]); + expect(dto.contacts[0].name).toBe('김아라'); + expect(dto.contacts[0].company).toBeNull(); + }); + + it('uid — urn:uuid: + 소문자 UUID 만', async () => { + for (const uid of [ + UID.toUpperCase(), + UID.replace('urn:uuid:', ''), + `${UID}0`, + 'urn:uuid:not-a-uuid', + 7, + null, + ]) { + expect( + await namesOf(ContactImportBatchDto, { contacts: [person({ uid })] }), + ).not.toEqual([]); + } + const missing: Record = person(); + delete missing.uid; + expect( + await namesOf(ContactImportBatchDto, { contacts: [missing] }), + ).not.toEqual([]); + }); + + it(`묶음 — 배열 · 1~${MAX_CONTACT_IMPORT_BATCH}명, 사람마다 연락처 추가와 같은 칸 검사 · 모르는 칸은 안 된다`, async () => { + for (const contacts of [ + null, + 'x', + [], + Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () => person()), + [person({ emails: [{ address: 'not-an-email', label: 'work' }] })], + [person({ phones: [{ number: '내선 12', label: 'work' }] })], + [person({ note: '가'.repeat(1_001) })], + [person({ extra: true })], + ]) { + expect(await namesOf(ContactImportBatchDto, { contacts })).not.toEqual( + [], + ); + } + expect( + await namesOf(ContactImportBatchDto, { + contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH }, () => + person(), + ), + }), + ).toEqual([]); + }); + + it('개수가 넘으면 사람마다의 검사를 하지 않는다 — 형식 → 개수 → 중첩 차례 (stopAtFirstError)', async () => { + const names = await namesOf( + ContactImportBatchDto, + { + contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () => + person({ emails: [{ address: 'bad', label: 'work' }] }), + ), + }, + true, + ); + expect(names).toEqual(['arrayMaxSize']); + const pipe = createValidationPipe(); + await expect( + pipe.transform( + { contacts: [] }, + { type: 'body', metatype: ContactImportBatchDto }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('한꺼번에 지우기 본문 (POST /api/contact-deletions — 16단계)', () => { + it(`id 1~${MAX_CONTACT_DELETIONS}개 · id 모양 · 겹치지 않게`, async () => { + expect(await namesOf(ContactDeletionDto, { ids: ['jw', 'jx'] })).toEqual( + [], + ); + for (const ids of [ + null, + [], + Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `c${n}`), + ['jw', 'jw'], + ['../x'], + [7], + ]) { + expect(await namesOf(ContactDeletionDto, { ids })).not.toEqual([]); + } + expect( + await namesOf(ContactDeletionDto, { + ids: Array.from({ length: MAX_CONTACT_DELETIONS }, (_, n) => `c${n}`), + }), + ).toEqual([]); + }); +}); + +describe('내보내기 쿼리 (GET /api/contact-exports — 16단계)', () => { + it('format 은 vcard · csv 만(없으면 안 된다) · groupId 는 id 모양 · 모르는 쿼리는 안 된다', async () => { + expect(await namesOf(ContactExportQueryDto, { format: 'vcard' })).toEqual( + [], + ); + expect( + await namesOf(ContactExportQueryDto, { format: 'csv', groupId: 'jy' }), + ).toEqual([]); + for (const query of [ + {}, + { format: 'xlsx' }, + { format: 'VCARD' }, + { format: ['vcard', 'csv'] }, + { format: 'csv', groupId: '../x' }, + { format: 'csv', limit: '10' }, + ]) { + expect(await namesOf(ContactExportQueryDto, query)).not.toEqual([]); + } + }); +}); diff --git a/backend/src/modules/contacts/dto/contact-import.dto.ts b/backend/src/modules/contacts/dto/contact-import.dto.ts new file mode 100644 index 0000000..a53aa6b --- /dev/null +++ b/backend/src/modules/contacts/dto/contact-import.dto.ts @@ -0,0 +1,202 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { + CONTACT_ID_PATTERN, + CONTACT_IMPORT_UID_PATTERN, + MAX_CONTACT_DELETIONS, + MAX_CONTACT_IMPORT_BATCH, +} from '@/modules/contacts/contacts.constants'; +import { + CONTACT_IMPORT_STATUSES, + type ContactImportCheck, + type ContactImported, + type ContactImportItem, + type ContactImportResult, + type ContactImportStatus, + type ContactsDeleted, +} from '@/modules/contacts/contacts.types'; +import { ContactInputDto } from './contact.dto'; + +/** + * 가져오기 · 한꺼번에 지우기 — 본문과 응답 (계약 3절 "가져오기 · 내보내기" — 16단계). + * 한 사람(ContactImportItem)은 연락처 추가 본문(ContactInputDto)과 같은 검사에 uid 를 더한 것이다. 이름이나 이메일 · 같은 연락처 안의 같은 주소 · + * 묶음 안의 같은 uid 는 서비스가 본다(모두 400 VAL_001). 칸의 검사는 적은 차례대로 돈다(contact.dto.ts 머리말 — 형식 → 길이 · 개수 → 모양) + */ + +/** `urn:uuid:` + 소문자 UUID 의 길이 */ +const IMPORT_UID_LENGTH = 45; + +function ImportUid(): PropertyDecorator { + return applyDecorators( + IsString({ message: '가져올 연락처의 표시가 올바르지 않습니다.' }), + MaxCodeUnits(IMPORT_UID_LENGTH, { + message: '가져올 연락처의 표시가 올바르지 않습니다.', + }), + Matches(CONTACT_IMPORT_UID_PATTERN, { + message: '가져올 연락처의 표시가 올바르지 않습니다.', + }), + ); +} + +/** 가져올 연락처 목록 — 형식 → 개수(1~100) → 사람마다(중첩) 차례로 (개수가 넘으면 중첩 검사를 하지 않는다 — stopAtFirstError) */ +function ImportList(): PropertyDecorator { + return applyDecorators( + IsArray({ message: '가져올 연락처 목록 형식이 올바르지 않습니다.' }), + ArrayMinSize(1, { message: '가져올 연락처를 하나 이상 보내 주세요.' }), + ArrayMaxSize(MAX_CONTACT_IMPORT_BATCH, { + message: `한 번에 ${MAX_CONTACT_IMPORT_BATCH}명까지 보낼 수 있습니다.`, + }), + ValidateNested({ each: true }), + Type(() => ContactImportItemDto), + ); +} + +/** 지울 연락처 id 목록 — 형식 → 개수(1~500) → id 모양 → 겹침 */ +function DeletionIdList(): PropertyDecorator { + return applyDecorators( + IsArray({ message: '지울 연락처 목록 형식이 올바르지 않습니다.' }), + ArrayMinSize(1, { message: '지울 연락처를 하나 이상 보내 주세요.' }), + ArrayMaxSize(MAX_CONTACT_DELETIONS, { + message: `한 번에 ${MAX_CONTACT_DELETIONS}명까지 지울 수 있습니다.`, + }), + Matches(CONTACT_ID_PATTERN, { + each: true, + message: '지울 연락처를 다시 골라 주세요.', + }), + ArrayDistinct({ message: '같은 연락처를 두 번 보낼 수 없습니다.' }), + ); +} + +export class ContactImportItemDto + extends ContactInputDto + implements ContactImportItem +{ + @ApiProperty({ + description: + '화면이 사람마다 만든 uid — `urn:uuid:` + 소문자 UUID. 엔진 카드의 uid 가 된다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않는다. 한 묶음 안에서 겹치면 400 VAL_001', + example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + maxLength: IMPORT_UID_LENGTH, + }) + @ImportUid() + uid!: string; +} + +/** 가져오기 · 확인의 본문 — 1~100명 */ +export class ContactImportBatchDto { + @ApiProperty({ + type: [ContactImportItemDto], + minItems: 1, + maxItems: MAX_CONTACT_IMPORT_BATCH, + description: `가져올 연락처 — 1~${MAX_CONTACT_IMPORT_BATCH}명. 칸 규칙은 연락처 추가(POST /api/contacts)와 같다`, + }) + @ImportList() + contacts!: ContactImportItemDto[]; +} + +export class ContactImportCheckItemDto { + @ApiProperty({ + description: '보낸 uid', + example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + }) + uid!: string; + + @ApiProperty({ + description: + 'new 넣을 연락처 · exists 이미 있는 연락처 — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있음, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있음, 또는 같은 uid 의 연락처가 있음', + enum: [...CONTACT_IMPORT_STATUSES], + example: 'new', + }) + status!: ContactImportStatus; +} + +/** POST /api/contact-import-checks → 200 */ +export class ContactImportCheckDto implements ContactImportCheck { + @ApiProperty({ + type: [ContactImportCheckItemDto], + description: '보낸 차례 그대로 한 줄씩', + }) + items!: ContactImportCheckItemDto[]; + + @ApiProperty({ + description: + '더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다). 화면은 이것으로 넣을 수를 미리 줄인다', + example: 2_660, + }) + remaining!: number; +} + +export class ContactImportedDto implements ContactImported { + @ApiProperty({ + description: '보낸 uid', + example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + }) + uid!: string; + + @ApiProperty({ + description: + '연락처 id — 새로 만든 카드, 또는 같은 uid 로 이미 있던 카드(다시 보낸 묶음)', + example: 'jw', + }) + id!: string; + + @ApiProperty({ + type: [String], + description: + '구성원 500명이 차서 넣지 못한 그룹 id (보낸 groupIds 차례) — 실패가 아니다. 연락처는 만들었고 나머지 그룹에는 넣었다', + example: [], + }) + skippedGroupIds!: string[]; +} + +/** POST /api/contact-imports → 201 */ +export class ContactImportResultDto implements ContactImportResult { + @ApiProperty({ + type: [ContactImportedDto], + description: + '넣은 연락처(같은 uid 로 이미 있던 것 포함) — 보낸 차례 그대로. 보낸 uid 가 빠져 있으면 메일 서버가 받지 않은 것이다(화면은 "넣지 못함"으로)', + }) + items!: ContactImportedDto[]; +} + +/** POST /api/contact-deletions 의 본문 — 1~500개 · 겹치지 않게 */ +export class ContactDeletionDto { + @ApiProperty({ + type: [String], + minItems: 1, + maxItems: MAX_CONTACT_DELETIONS, + description: `지울 연락처 id — 1~${MAX_CONTACT_DELETIONS}개, 겹치지 않게(겹치면 400 VAL_001)`, + example: ['jw', 'jx'], + }) + @DeletionIdList() + ids!: string[]; +} + +/** POST /api/contact-deletions → 200 */ +export class ContactsDeletedDto implements ContactsDeleted { + @ApiProperty({ + type: [String], + description: '지운 연락처 id — 보낸 차례 그대로', + example: ['jw'], + }) + deletedIds!: string[]; + + @ApiProperty({ + type: [String], + description: + '내 주소록에 없는 id — 이미 지움 · 그룹 id 등 (보낸 차례 그대로)', + example: ['jx'], + }) + notFoundIds!: string[]; +} diff --git a/backend/src/modules/mail-signature/dto/mail-signature.dto.spec.ts b/backend/src/modules/mail-signature/dto/mail-signature.dto.spec.ts new file mode 100644 index 0000000..b5c07f9 --- /dev/null +++ b/backend/src/modules/mail-signature/dto/mail-signature.dto.spec.ts @@ -0,0 +1,121 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SIGNATURE_NAME_MAX_LENGTH } from '@/modules/mail-signature/mail-signature.constants'; +import { + MailSignatureDefaultsDto, + SaveMailSignatureDto, + UpdateLegacyMailSignatureDto, +} from './mail-signature.dto'; + +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) — 걸린 규칙 이름만 */ +async function constraintsOf( + type: new () => object, + body: object, +): Promise { + const errors = await validate(plainToInstance(type, body), { + whitelist: true, + forbidNonWhitelisted: true, + }); + return errors.flatMap((error) => Object.keys(error.constraints ?? {})); +} + +describe('서명 만들기 · 고치기 본문 (POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 16단계)', () => { + const save = (body: object) => constraintsOf(SaveMailSignatureDto, body); + + it('이름은 앞뒤 공백을 지운 값으로 1~30자(JS 문자열 길이) — 본문 칸은 보내지 않거나 null 이어도 된다', async () => { + expect(await save({ name: '대외' })).toEqual([]); + expect( + await save({ name: '대외', bodyHtml: '

김아라

', body: '김아라' }), + ).toEqual([]); + expect(await save({ name: '대외', bodyHtml: null, body: null })).toEqual( + [], + ); + expect(await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH) })).toEqual( + [], + ); + expect( + plainToInstance(SaveMailSignatureDto, { name: ' 대외 ' }).name, + ).toBe('대외'); + // 이모지 하나는 2자로 센다 — 화면의 .length 와 같다 + expect( + await save({ name: '🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2) }), + ).toEqual([]); + }); + + it('이름이 비거나(공백뿐 포함) · 30자를 넘거나 · 제어 문자 · 글이 아니면 VAL_001', async () => { + expect(await save({ name: '' })).toEqual(['minCodeUnits']); + expect(await save({ name: ' ' })).toEqual(['minCodeUnits']); + expect( + await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH + 1) }), + ).toEqual(['maxCodeUnits']); + expect( + await save({ name: `${'🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2)}x` }), + ).toEqual(['maxCodeUnits']); + for (const name of ['사내\n영문', '사내\t영문', 'a\u0000']) { + expect(await save({ name })).toEqual(['matches']); + } + // 앞뒤의 공백 · 줄바꿈은 지운 값으로 본다(trim) + expect(await save({ name: '\t사내\n' })).toEqual([]); + expect(await save({})).toEqual(expect.arrayContaining(['isString'])); + expect(await save({ name: 1 })).toEqual( + expect.arrayContaining(['isString']), + ); + }); + + it('본문 칸이 글이 아니거나 모르는 칸(옛 두 표시 포함)이면 VAL_001', async () => { + expect(await save({ name: '대외', bodyHtml: 1 })).toEqual(['isString']); + expect(await save({ name: '대외', body: [] })).toEqual(['isString']); + expect(await save({ name: '대외', isForNew: true })).toEqual([ + 'whitelistValidation', + ]); + }); +}); + +describe('메일에 넣을 서명 (PUT /api/mail-signature-defaults — 16단계)', () => { + const defaults = (body: object) => + constraintsOf(MailSignatureDefaultsDto, body); + + it('두 키를 모두 보낸다 — 서명 id 또는 null(넣지 않음). 모양은 서비스가 본다(내 서명이 아니면 404)', async () => { + const id = '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d'; + expect(await defaults({ newMessageId: id, replyId: null })).toEqual([]); + expect(await defaults({ newMessageId: null, replyId: null })).toEqual([]); + expect(await defaults({ newMessageId: 'not-a-uuid', replyId: id })).toEqual( + [], + ); + }); + + it('빠진 키 · 글이 아닌 값 · 너무 긴 값 · 모르는 칸은 VAL_001', async () => { + expect(await defaults({ newMessageId: null })).toEqual( + expect.arrayContaining(['isString']), + ); + expect(await defaults({ newMessageId: 1, replyId: null })).toEqual( + expect.arrayContaining(['isString']), + ); + expect( + await defaults({ newMessageId: 'x'.repeat(65), replyId: null }), + ).toEqual(['maxCodeUnits']); + expect( + await defaults({ newMessageId: null, replyId: null, forwardId: null }), + ).toEqual(['whitelistValidation']); + }); +}); + +describe('옛 경로 저장 본문 (PUT /api/mail-signature — 16단계 전의 웹 탭)', () => { + it('지금과 같은 네 칸 — 두 표시는 boolean 이어야 한다', async () => { + expect( + await constraintsOf(UpdateLegacyMailSignatureDto, { + bodyHtml: '

김아라

', + body: '김아라', + isForNew: true, + isForReply: false, + }), + ).toEqual([]); + expect( + await constraintsOf(UpdateLegacyMailSignatureDto, { + isForNew: 'true', + isForReply: true, + }), + ).toEqual(['isBoolean']); + }); +}); diff --git a/backend/src/modules/mail-signature/dto/mail-signature.dto.ts b/backend/src/modules/mail-signature/dto/mail-signature.dto.ts index 02fc855..3104280 100644 --- a/backend/src/modules/mail-signature/dto/mail-signature.dto.ts +++ b/backend/src/modules/mail-signature/dto/mail-signature.dto.ts @@ -1,17 +1,35 @@ +import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { + IsBoolean, + IsOptional, + IsString, + Matches, + ValidateIf, +} from 'class-validator'; import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + SIGNATURES_MAX, SIGNATURE_HTML_MAX_LENGTH, SIGNATURE_IMAGES_PER_SIGNATURE, SIGNATURE_IMAGE_MAX_BYTES, + SIGNATURE_NAME_MAX_LENGTH, SIGNATURE_TEXT_MAX_LENGTH, } from '@/modules/mail-signature/mail-signature.constants'; import type { + LegacyMailSignature, MailSignature, + MailSignatureDefaults, SignatureImage, } from '@/modules/mail-signature/mail-signature.types'; import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format'; +import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants'; const IMAGE_TYPES: SignatureImageType[] = [ 'image/png', @@ -19,24 +37,109 @@ const IMAGE_TYPES: SignatureImageType[] = [ 'image/gif', ]; -/** PUT /api/mail-signature — 계약 2절 "서명" */ -export class UpdateMailSignatureDto { +/** 서명 id 칸의 길이 상한 — 서버가 만든 UUID(36자)라 넉넉히. 넘거나 형식이 틀린 id 는 없는 서명과 같다(404 RES_001) */ +const SIGNATURE_ID_MAX_LENGTH = 64; + +/** null 은 "넣지 않음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 두 키를 모두 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +const BODY_HTML_DESCRIPTION = + `서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(). ` + + `정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` + + '어느 서명도 더는 가리키지 않는 그림은 저장할 때 지운다'; +const BODY_HTML_EXAMPLE = + '

김아라 · 아라시스템

'; +const BODY_DESCRIPTION = `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`; + +/** 서명 이름 — 앞뒤 공백을 뺀 1~30자(JS 문자열 길이), 제어 문자 없음. 검사는 적은 차례대로(형식 → 길이 → 모양) */ +function SignatureName() { + return applyDecorators( + ApiProperty({ + description: + `서명 이름 — 앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자(JS 문자열 길이), 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. ` + + '내 다른 서명과 같은 이름(영문 대소문자 무시)이면 400 VAL_001', + example: '대외', + minLength: 1, + maxLength: SIGNATURE_NAME_MAX_LENGTH, + }), + TrimString(), + IsString({ message: '서명 이름 형식이 올바르지 않습니다.' }), + MinCodeUnits(1, { message: '서명 이름을 입력해 주세요.' }), + MaxCodeUnits(SIGNATURE_NAME_MAX_LENGTH, { + message: `서명 이름은 ${SIGNATURE_NAME_MAX_LENGTH}자 이하로 입력해 주세요.`, + }), + Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '서명 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }), + ); +} + +/** 「메일에 넣을 서명」 칸 하나 — 내 서명 id 또는 null(넣지 않음). 키는 늘 보낸다 */ +function DefaultSignatureId(description: string) { + return applyDecorators( + ApiProperty({ + description: `${description} — 내 서명 id, null 이면 넣지 않음. 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 404 RES_001`, + nullable: true, + type: String, + example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d', + }), + ValidateIf(unlessNull), + IsString({ message: '서명을 다시 골라 주세요.' }), + MaxCodeUnits(SIGNATURE_ID_MAX_LENGTH, { + message: '서명을 다시 골라 주세요.', + }), + ); +} + +/** POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 계약 2절 "서명" (16단계, 전체 교체) */ +export class SaveMailSignatureDto { + @SignatureName() + name!: string; + @ApiPropertyOptional({ - description: - `서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(). ` + - `정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` + - '서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다', + description: BODY_HTML_DESCRIPTION, nullable: true, type: String, - example: - '

김아라 · 아라시스템

', + example: BODY_HTML_EXAMPLE, }) @IsOptional() @IsString({ message: '서명 형식이 올바르지 않습니다.' }) bodyHtml?: string | null; @ApiPropertyOptional({ - description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`, + description: BODY_DESCRIPTION, + nullable: true, + type: String, + example: '김아라 · 아라시스템', + }) + @IsOptional() + @IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' }) + body?: string | null; +} + +/** GET · PUT /api/mail-signature-defaults — 계약 2절 "서명"의 「메일에 넣을 서명」 (16단계) */ +export class MailSignatureDefaultsDto implements MailSignatureDefaults { + @DefaultSignatureId('새 메일에 넣는 서명') + newMessageId!: string | null; + + @DefaultSignatureId('답장 · 전달에 넣는 서명(인용 위)') + replyId!: string | null; +} + +/** PUT /api/mail-signature — 옛 경로(16단계 전의 웹 탭). 「새 메일」 기본 서명의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다 */ +export class UpdateLegacyMailSignatureDto { + @ApiPropertyOptional({ + description: BODY_HTML_DESCRIPTION, + nullable: true, + type: String, + example: BODY_HTML_EXAMPLE, + }) + @IsOptional() + @IsString({ message: '서명 형식이 올바르지 않습니다.' }) + bodyHtml?: string | null; + + @ApiPropertyOptional({ + description: BODY_DESCRIPTION, nullable: true, type: String, example: '김아라 · 아라시스템', @@ -45,11 +148,19 @@ export class UpdateMailSignatureDto { @IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' }) body?: string | null; - @ApiProperty({ description: '새 메일에 넣는다', example: true }) + @ApiProperty({ + description: + 'true = 이 서명을 새 메일의 기본으로, false = 새 메일에 넣지 않음', + example: true, + }) @IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' }) isForNew!: boolean; - @ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true }) + @ApiProperty({ + description: + 'true = 이 서명을 답장·전달의 기본으로, false = 이 서명이 기본이었으면 넣지 않음(다른 서명이 기본이면 그대로)', + example: true, + }) @IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' }) isForReply!: boolean; } @@ -82,26 +193,86 @@ export class SignatureImageDto implements SignatureImage { height!: number; } +/** 서명 하나 (16단계) */ export class MailSignatureDto implements MailSignature { @ApiProperty({ - description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`, + description: '서명 id (서버가 만든 UUID)', + example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d', + }) + id!: string; + + @ApiProperty({ description: '서명 이름 (1~30자)', example: '대외' }) + name!: string; + + @ApiProperty({ + description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 없으면 null`, nullable: true, type: String, }) bodyHtml!: string | null; @ApiProperty({ - description: '텍스트 서명 — 저장한 적 없거나 비었으면 null', + description: '텍스트 서명 — 없으면 null', nullable: true, type: String, }) body!: string | null; - @ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true }) + @ApiProperty({ + description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`, + type: [SignatureImageDto], + }) + images!: SignatureImageDto[]; + + @ApiProperty({ + description: '마지막으로 저장한 시각 (ISO 8601 UTC)', + example: '2026-11-10T05:12:00.000Z', + }) + updatedAt!: string; +} + +/** GET /api/mail-signatures — 내 서명 목록 */ +export class MailSignaturePageDto { + @ApiProperty({ + type: [MailSignatureDto], + description: `만든 차례 — ${SIGNATURES_MAX}개까지`, + }) + items!: MailSignatureDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: '한 번에 모두 내려준다 (hasNext=false, total = 서명 수)', + }) + pageInfo!: PageInfoDto; +} + +/** GET · PUT /api/mail-signature — 옛 경로의 서명 (앱 첫 판 · 16단계 전의 웹 탭) */ +export class LegacyMailSignatureDto implements LegacyMailSignature { + @ApiProperty({ + description: `옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 기본 서명이 둘 다 없거나 비었으면 null`, + nullable: true, + type: String, + }) + bodyHtml!: string | null; + + @ApiProperty({ + description: + '옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 텍스트 — 기본 서명이 둘 다 없거나 비었으면 null', + nullable: true, + type: String, + }) + body!: string | null; + + @ApiProperty({ + description: + '「새 메일」 기본 서명이 있다 — 없으면 false (그때 본문이 있으면 「답장 · 전달」 기본 서명이다)', + example: true, + }) isForNew!: boolean; @ApiProperty({ - description: '답장·전달에 넣는다 — 인용 위 (기본 true)', + description: + '「답장 · 전달」 기본이 이 서명이다 — 「새 메일」 기본과 다른 서명이면 false (앱 첫 판은 그때 답장에 서명을 넣지 않는다)', example: true, }) isForReply!: boolean; @@ -113,7 +284,7 @@ export class MailSignatureDto implements MailSignature { images!: SignatureImageDto[]; @ApiProperty({ - description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null', + description: '저장한 시각 (ISO 8601 UTC) — 기본 서명이 둘 다 없으면 null', nullable: true, type: String, example: '2026-10-27T05:12:00.000Z', diff --git a/backend/src/modules/mail-signature/mail-signature.constants.ts b/backend/src/modules/mail-signature/mail-signature.constants.ts index 4fc827a..83debc0 100644 --- a/backend/src/modules/mail-signature/mail-signature.constants.ts +++ b/backend/src/modules/mail-signature/mail-signature.constants.ts @@ -20,15 +20,28 @@ export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES; */ export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096; -/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */ +/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(POST · PUT)이 400 VAL_001 */ export const SIGNATURE_IMAGES_PER_SIGNATURE = 3; /** - * 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001. - * 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다. - * 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다. + * 한 사람이 둘 수 있는 서명 그림 수 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001. + * 서명 여럿(16단계 — 10개 × 3장)이 함께 쓰는 칸이라 10 → 20 으로 늘렸다. 편집기에서 그림을 넣었다 빼기를 되풀이해도 + * (빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 하나의 한도보다 넉넉히 둔다. + * 저장하지 않은 그림은 하루 뒤 정리 작업이, 어느 서명도 더는 가리키지 않는 그림은 다음 저장이 지운다. */ -export const SIGNATURE_IMAGES_STORED_MAX = 10; +export const SIGNATURE_IMAGES_STORED_MAX = 20; + +/** 한 사람이 둘 수 있는 서명 수 (16단계) — 11번째 만들기는 400 VAL_001 */ +export const SIGNATURES_MAX = 10; + +/** 서명 이름의 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (16단계). 같은 이름(ASCII 대소문자 무시)은 400 VAL_001 */ +export const SIGNATURE_NAME_MAX_LENGTH = 30; + +/** + * 옛 서명(한 사람에 하나)을 옮긴 이름 — 마이그레이션 `MultipleMailSignatures` 와 옛 경로 `PUT /api/mail-signature` 가 + * 기본 서명(「새 메일」 · 「답장 · 전달」)이 둘 다 없을 때 만드는 서명의 이름이다 (계약 2절 "서명" — 16단계) + */ +export const LEGACY_SIGNATURE_NAME = '기본 서명'; /** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */ export const SIGNATURE_HTML_MAX_LENGTH = 10_000; diff --git a/backend/src/modules/mail-signature/mail-signature.controller.ts b/backend/src/modules/mail-signature/mail-signature.controller.ts index d2be4e8..d517d24 100644 --- a/backend/src/modules/mail-signature/mail-signature.controller.ts +++ b/backend/src/modules/mail-signature/mail-signature.controller.ts @@ -37,9 +37,9 @@ import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { - MailSignatureDto, + LegacyMailSignatureDto, SignatureImageDto, - UpdateMailSignatureDto, + UpdateLegacyMailSignatureDto, } from './dto/mail-signature.dto'; import { SIGNATURE_HTML_MAX_LENGTH, @@ -57,7 +57,8 @@ import { SignatureImageIdPipe } from './signature-image-id.pipe'; const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif']; // 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다. -// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다). +// 16단계부터 서명은 여럿이다(mail-signatures.controller.ts) — 여기의 GET · PUT 은 「새 메일」 기본 서명(없으면 「답장 · 전달」 기본)을 다루는 옛 경로(앱 첫 판 · 옛 웹 탭)이고, +// 그림 경로는 모든 서명이 함께 쓴다. 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다). @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -68,33 +69,37 @@ export class MailSignatureController { @Get() @ApiOperation({ - summary: '내 서명', + summary: '「새 메일」 기본 서명 — 옛 경로 (앱 첫 판)', description: - '저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.', + '16단계부터 서명은 여럿이다(/api/mail-signatures). 이 경로는 「새 메일」 기본 서명을 15단계까지의 모양으로 준다 — ' + + 'isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 같은 서명. ' + + '「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 isForNew false · isForReply true 로 준다. ' + + '둘 다 없으면 { bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }.', }) - @ApiStandardResponse(MailSignatureDto, { description: '서명' }) + @ApiStandardResponse(LegacyMailSignatureDto, { description: '서명' }) getSignature(@CurrentUser() user: AuthenticatedUser) { - return this.signatures.getSignature(user.email); + return this.signatures.getLegacySignature(user.email); } @Put() @ApiOperation({ - summary: '서명 저장 (전체 교체)', + summary: '「새 메일」 기본 서명 저장 — 옛 경로 (16단계 전의 웹 탭)', description: + '옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 없으면 만든다)의 본문을 바꾸고, isForNew · isForReply 를 「메일에 넣을 서명」 둘로 옮긴다. ' + `bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` + `body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` + - '서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.', + '어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.', }) - @ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' }) + @ApiStandardResponse(LegacyMailSignatureDto, { description: '저장한 서명' }) @ApiErrorResponse( 'VAL_001', - `형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`, + `형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 서명을 새로 만들어야 하는데 이미 10개`, ) saveSignature( @CurrentUser() user: AuthenticatedUser, - @Body() dto: UpdateMailSignatureDto, + @Body() dto: UpdateLegacyMailSignatureDto, ) { - return this.signatures.saveSignature(user.email, dto); + return this.signatures.saveLegacySignature(user.email, dto); } @Post('images') @@ -103,8 +108,8 @@ export class MailSignatureController { summary: '서명 그림 올리기 (파일 1개)', description: `PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` + - `한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` + - `(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` + + `한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` + + `(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — 서명 저장이 본다). ` + '서명을 저장하지 않으면 하루 뒤 지운다.', }) @ApiConsumes('multipart/form-data') diff --git a/backend/src/modules/mail-signature/mail-signature.entity.ts b/backend/src/modules/mail-signature/mail-signature.entity.ts index ac9f54a..d125cf3 100644 --- a/backend/src/modules/mail-signature/mail-signature.entity.ts +++ b/backend/src/modules/mail-signature/mail-signature.entity.ts @@ -1,35 +1,63 @@ -import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; /** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ const EMAIL_COLUMN_LENGTH = 254; +/** + * 서명 이름 칸 — 이름은 30자(JS 문자열 길이 — SIGNATURE_NAME_MAX_LENGTH)까지라 넉넉히 둔다. + * varchar 는 코드 포인트로 세어 JS 길이 30 이면 늘 들어간다 (서로게이트 쌍 하나가 JS 로는 2자 · 여기서는 1자) + */ +const NAME_COLUMN_LENGTH = 64; /** - * 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 — - * 엔진을 바꿔도 남고, DB 백업에 함께 들어간다. - * HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다. + * 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지)다. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 — + * 엔진을 바꿔도 남고, DB 백업에 함께 들어간다. HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다. + * + * 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시로 둔다 — 한 사람에 하나씩만(부분 유일 인덱스). + * 서명을 지우면 표시도 함께 없어져 그 자리는 "넣지 않음"이 된다. 퇴사 마무리는 주소로 이 표의 줄을 지운다(modules/ops/ops-data.service.ts). + * 목록은 만든 차례(created_at, 같으면 id)다. */ @Entity({ name: 'mail_signatures' }) +@Index('IDX_mail_signatures_user_email_created_at', ['userEmail', 'createdAt']) +@Index('UQ_mail_signatures_new_message_default', ['userEmail'], { + unique: true, + where: '"is_new_message_default"', +}) +@Index('UQ_mail_signatures_reply_default', ['userEmail'], { + unique: true, + where: '"is_reply_default"', +}) export class MailSignatureEntity { - @PrimaryColumn({ + /** 서버가 만든 UUID (소문자) */ + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH, }) userEmail!: string; + /** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시 — 서비스가 사용자 잠금 안에서 본다) */ + @Column({ type: 'varchar', length: NAME_COLUMN_LENGTH }) + name!: string; + @Column({ name: 'body_html', type: 'text', nullable: true }) bodyHtml!: string | null; @Column({ name: 'body_text', type: 'text', nullable: true }) bodyText!: string | null; - /** 새 메일에 넣는다 */ - @Column({ name: 'is_for_new', type: 'boolean', default: true }) - isForNew!: boolean; + /** 「새 메일」에 넣는 서명 — 한 사람에 하나까지 */ + @Column({ name: 'is_new_message_default', type: 'boolean', default: false }) + isNewMessageDefault!: boolean; - /** 답장·전달에 넣는다 (인용 위) */ - @Column({ name: 'is_for_reply', type: 'boolean', default: true }) - isForReply!: boolean; + /** 「답장 · 전달」에 넣는 서명(인용 위) — 한 사람에 하나까지 */ + @Column({ name: 'is_reply_default', type: 'boolean', default: false }) + isReplyDefault!: boolean; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; @Column({ name: 'updated_at', type: 'timestamptz' }) updatedAt!: Date; diff --git a/backend/src/modules/mail-signature/mail-signature.module.ts b/backend/src/modules/mail-signature/mail-signature.module.ts index ecf3159..c436856 100644 --- a/backend/src/modules/mail-signature/mail-signature.module.ts +++ b/backend/src/modules/mail-signature/mail-signature.module.ts @@ -12,11 +12,15 @@ import { MailSignatureController } from './mail-signature.controller'; import { MailSignatureEntity } from './mail-signature.entity'; import { MailSignatureRepository } from './mail-signature.repository'; import { MailSignatureService } from './mail-signature.service'; +import { + MailSignatureDefaultsController, + MailSignaturesController, +} from './mail-signatures.controller'; import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format'; import { SignatureImageStorage } from './signature-image-storage'; /** - * 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다. + * 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다. 16단계부터 한 사람에 서명 여러 개(10개까지). * 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다). * * 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다. @@ -47,7 +51,13 @@ import { SignatureImageStorage } from './signature-image-storage'; }, }), ], - controllers: [MailSignatureController], + controllers: [ + // 여러 서명 · 「메일에 넣을 서명」 둘 (16단계) + MailSignaturesController, + MailSignatureDefaultsController, + // 옛 경로(「새 메일」 기본 서명 — 앱 첫 판) · 서명 그림 + MailSignatureController, + ], providers: [ MailSignatureRepository, { diff --git a/backend/src/modules/mail-signature/mail-signature.repository.ts b/backend/src/modules/mail-signature/mail-signature.repository.ts index 992c66b..40e90df 100644 --- a/backend/src/modules/mail-signature/mail-signature.repository.ts +++ b/backend/src/modules/mail-signature/mail-signature.repository.ts @@ -1,36 +1,75 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm'; +import { + DataSource, + In, + IsNull, + LessThan, + type EntityManager, + type Repository, +} from 'typeorm'; import { MailSignatureImageEntity } from './mail-signature-image.entity'; import { MailSignatureEntity } from './mail-signature.entity'; +import type { MailSignatureDefaults } from './mail-signature.types'; /** * 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다). - * 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII. + * 서명 줄이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII. */ const SIGNATURE_SAVE_LOCK = 0x53494731; -/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */ -export type PrepareSignature = ( - images: readonly MailSignatureImageEntity[], -) => { - readonly signature: MailSignatureEntity; - readonly referencedImageIds: readonly string[]; -}; +/** 목록 차례 — 만든 차례, 같은 시각이면 id */ +const SIGNATURE_ORDER = { createdAt: 'ASC', id: 'ASC' } as const; -/** 서명 저장 결과 */ -export interface SavedSignature { - readonly signature: MailSignatureEntity; - /** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */ +/** 잠근 뒤 본 그 사람의 서명 · 그림 */ +export interface SignatureSnapshot { + /** 만든 차례 */ + readonly signatures: readonly MailSignatureEntity[]; + /** 올린 차례 */ + readonly images: readonly MailSignatureImageEntity[]; +} + +/** 새로 넣을 서명 — 기본 표시는 plan 의 defaults 로만 바꾼다(넣을 때는 늘 꺼짐) */ +export type NewSignatureRow = Omit< + MailSignatureEntity, + 'isNewMessageDefault' | 'isReplyDefault' +>; + +/** 고칠 서명 — 이름 · 본문 · 저장 시각만 */ +export type SignatureRowUpdate = Pick< + MailSignatureEntity, + 'id' | 'name' | 'bodyHtml' | 'bodyText' | 'updatedAt' +>; + +/** 잠근 뒤 정한 바꿀 것 — 서비스가 스냅숏을 보고 정한다(한도 · 이름 겹침은 서비스가 던진다 — 그러면 아무것도 바꾸지 않는다) */ +export interface SignaturePlan { + readonly insert?: NewSignatureRow; + readonly update?: SignatureRowUpdate; + readonly removeId?: string; + /** 「메일에 넣을 서명」 둘을 이 값으로 — 없으면 그대로 (지운 서명의 표시는 줄과 함께 없어진다) */ + readonly defaults?: MailSignatureDefaults; + /** 바꾼 뒤 모든 서명 HTML 이 가리키는 그림 (합집합) — 이 밖의 그림은 지운다(막 올린 것은 남긴다) */ + readonly referencedImageIds: readonly string[]; +} + +export type PlanSignatureChange = ( + snapshot: SignatureSnapshot, +) => SignaturePlan; + +/** 서명 바꾸기 결과 */ +export interface SignatureChangeResult { + /** 바꾼 뒤 그 사람의 서명 — 만든 차례, 기본 표시 포함 */ + readonly signatures: MailSignatureEntity[]; + /** 바꾼 뒤 남은 그 사람의 그림 (올린 차례) */ readonly images: MailSignatureImageEntity[]; /** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */ readonly removed: MailSignatureImageEntity[]; } /** - * 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다. - * 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다. + * 서명·서명 그림 표 접근 — 판단(정화·한도·이름 겹침·누가 볼 수 있나)은 MailSignatureService 가 한다. + * 서명 · 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 id 로는 아무것도 나오지 않는다. */ @Injectable() export class MailSignatureRepository { @@ -42,8 +81,12 @@ export class MailSignatureRepository { private readonly dataSource: DataSource, ) {} - findSignature(userEmail: string): Promise { - return this.signatures.findOneBy({ userEmail }); + /** 그 사람의 서명 — 만든 차례 */ + listSignatures(userEmail: string): Promise { + return this.signatures.find({ + where: { userEmail }, + order: SIGNATURE_ORDER, + }); } /** 그 사람의 그림 — 올린 순서대로 */ @@ -70,31 +113,62 @@ export class MailSignatureRepository { } /** - * 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게). - * 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다 - * 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 — - * 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS). + * 서명을 바꾼다(만들기 · 고치기 · 지우기 · 기본 바꾸기) — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게). + * 1) 사용자 잠금 2) 그 사람의 서명과 그림을 읽는다(그림은 FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `plan` 이 바꿀 것을 정한다 + * 4) 넣기 · 고치기 · 지우기 → 기본 표시(끄고 나서 켠다 — 부분 유일 인덱스는 줄마다 바로 보므로) → + * 모든 서명이 가리키는 그림은 붙은 것으로 표시하고, 어느 서명도 가리키지 않는 그림은 지운다 — + * 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS) + * 5) 바꾼 뒤의 서명을 다시 읽어 돌려준다 */ - saveSignature( + change( userEmail: string, - prepare: PrepareSignature, + plan: PlanSignatureChange, now: Date, keepUploadedAfter: Date, - ): Promise { + ): Promise { return this.dataSource.transaction(async (manager) => { await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [ SIGNATURE_SAVE_LOCK, userEmail, ]); + const signatures = await manager.find(MailSignatureEntity, { + where: { userEmail }, + order: SIGNATURE_ORDER, + }); const images = await manager.find(MailSignatureImageEntity, { where: { userEmail }, order: { createdAt: 'ASC' }, lock: { mode: 'pessimistic_write' }, }); - const { signature, referencedImageIds } = prepare(images); - await manager.upsert(MailSignatureEntity, signature, ['userEmail']); + const next = plan({ signatures, images }); - const referenced = new Set(referencedImageIds); + if (next.insert) { + await manager.insert(MailSignatureEntity, { + ...next.insert, + userEmail, + isNewMessageDefault: false, + isReplyDefault: false, + }); + } + if (next.update) { + const { id, name, bodyHtml, bodyText, updatedAt } = next.update; + await manager.update( + MailSignatureEntity, + { id, userEmail }, + { name, bodyHtml, bodyText, updatedAt }, + ); + } + if (next.removeId) { + await manager.delete(MailSignatureEntity, { + id: next.removeId, + userEmail, + }); + } + if (next.defaults) { + await this.applyDefaults(manager, userEmail, next.defaults); + } + + const referenced = new Set(next.referencedImageIds); const attach = images .filter( (image) => referenced.has(image.id) && image.attachedAt === null, @@ -105,7 +179,6 @@ export class MailSignatureRepository { !referenced.has(image.id) && (image.attachedAt !== null || image.createdAt <= keepUploadedAfter), ); - if (attach.length > 0) { await manager.update( MailSignatureImageEntity, @@ -118,9 +191,13 @@ export class MailSignatureRepository { id: In(stale.map((image) => image.id)), }); } + const attached = new Set(attach); return { - signature, + signatures: await manager.find(MailSignatureEntity, { + where: { userEmail }, + order: SIGNATURE_ORDER, + }), images: images .filter((image) => !stale.includes(image)) .map((image) => @@ -131,6 +208,33 @@ export class MailSignatureRepository { }); } + /** + * 기본 표시를 그 값으로 — 칸마다 먼저 다른 줄의 표시를 끄고 그 줄을 켠다(한 문장으로 바꾸면 줄마다 보는 유일 인덱스에 걸릴 수 있다). + * id 는 서비스가 그 사람의 서명인지 본 값이다 (주인 조건도 함께 건다) + */ + private async applyDefaults( + manager: EntityManager, + userEmail: string, + defaults: MailSignatureDefaults, + ): Promise { + const columns = [ + ['is_new_message_default', defaults.newMessageId], + ['is_reply_default', defaults.replyId], + ] as const; + for (const [column, id] of columns) { + await manager.query( + `UPDATE "mail_signatures" SET "${column}" = false WHERE "user_email" = $1 AND "${column}" AND "id" IS DISTINCT FROM $2::uuid`, + [userEmail, id], + ); + if (id !== null) { + await manager.query( + `UPDATE "mail_signatures" SET "${column}" = true WHERE "user_email" = $1 AND "id" = $2::uuid AND NOT "${column}"`, + [userEmail, id], + ); + } + } + } + /** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */ findUnattachedBefore(before: Date): Promise { return this.images.find({ diff --git a/backend/src/modules/mail-signature/mail-signature.service.spec.ts b/backend/src/modules/mail-signature/mail-signature.service.spec.ts index 0dd5470..401c697 100644 --- a/backend/src/modules/mail-signature/mail-signature.service.spec.ts +++ b/backend/src/modules/mail-signature/mail-signature.service.spec.ts @@ -8,6 +8,8 @@ import { BusinessException } from '@/common/errors/business.exception'; import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants'; import { MailSignatureImageEntity } from './mail-signature-image.entity'; import { + LEGACY_SIGNATURE_NAME, + SIGNATURES_MAX, SIGNATURE_IMAGES_PER_SIGNATURE, SIGNATURE_IMAGES_STORED_MAX, SIGNATURE_IMAGE_MAX_BYTES, @@ -17,34 +19,37 @@ import { import { MailSignatureEntity } from './mail-signature.entity'; import type { MailSignatureRepository, - PrepareSignature, - SavedSignature, + PlanSignatureChange, + SignatureChangeResult, + SignaturePlan, } from './mail-signature.repository'; import { MailSignatureService } from './mail-signature.service'; +import type { MailSignatureDefaults } from './mail-signature.types'; import { SignatureImageStorage } from './signature-image-storage'; const ARA = 'ara.kim@arasystem.test'; const DOYUN = 'doyun.lee@arasystem.test'; const src = (id: string) => `/api/mail-signature/images/${id}`; +const NOT_MINE = '11111111-1111-4111-8111-111111111111'; /** - * 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다. - * 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다. + * 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건 · 기본 표시 · 바꾼 뒤 지울 그림)을 메모리에서 한다. + * 실제 SQL(트랜잭션 · 사용자 잠금 · 부분 유일 인덱스)은 e2e 가 본다. */ class FakeRepository { - readonly signatures = new Map(); + signatures: MailSignatureEntity[] = []; images: MailSignatureImageEntity[] = []; /** 다음 insertImage 를 실패시킨다 */ failNextInsert = false; - /** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */ - deletedBeforeNextSave: string | null = null; - readonly saveCalls: { - referenced: readonly string[]; + /** 다음 바꾸기가 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */ + deletedBeforeNextChange: string | null = null; + readonly changeCalls: { + plan: SignaturePlan; keepUploadedAfter: Date; }[] = []; - findSignature(user: string): Promise { - return Promise.resolve(this.signatures.get(user) ?? null); + listSignatures(user: string): Promise { + return Promise.resolve(this.mine(user).map((row) => ({ ...row }))); } listImages(user: string): Promise { @@ -76,38 +81,91 @@ class FakeRepository { return Promise.resolve(); } - saveSignature( + change( user: string, - prepare: PrepareSignature, + plan: PlanSignatureChange, now: Date, keepUploadedAfter: Date, - ): Promise { - if (this.deletedBeforeNextSave) { - const gone = this.deletedBeforeNextSave; - this.deletedBeforeNextSave = null; + ): Promise { + if (this.deletedBeforeNextChange) { + const gone = this.deletedBeforeNextChange; + this.deletedBeforeNextChange = null; this.images = this.images.filter((row) => row.id !== gone); } - const mine = this.images.filter((row) => row.userEmail === user); - const { signature, referencedImageIds: referenced } = prepare(mine); - this.saveCalls.push({ referenced, keepUploadedAfter }); - this.signatures.set(signature.userEmail, signature); - for (const row of mine) { - if (referenced.includes(row.id) && row.attachedAt === null) { + const images = this.images.filter((row) => row.userEmail === user); + let next: SignaturePlan; + try { + next = plan({ + signatures: this.mine(user).map((row) => ({ ...row })), + images, + }); + } catch (error) { + // 정한 것을 던지면(한도 · 이름 겹침) 아무것도 바꾸지 않는다 — 실제 표는 트랜잭션을 되돌린다 + return Promise.reject( + error instanceof Error ? error : new Error(String(error)), + ); + } + this.changeCalls.push({ plan: next, keepUploadedAfter }); + + if (next.insert) { + this.signatures.push({ + ...next.insert, + isNewMessageDefault: false, + isReplyDefault: false, + }); + } + if (next.update) { + const { id, name, bodyHtml, bodyText, updatedAt } = next.update; + this.signatures = this.signatures.map((row) => + row.id === id && row.userEmail === user + ? { ...row, name, bodyHtml, bodyText, updatedAt } + : row, + ); + } + if (next.removeId) { + this.signatures = this.signatures.filter( + (row) => !(row.id === next.removeId && row.userEmail === user), + ); + } + if (next.defaults) this.applyDefaults(user, next.defaults); + + const referenced = new Set(next.referencedImageIds); + for (const row of images) { + if (referenced.has(row.id) && row.attachedAt === null) { row.attachedAt = now; } } - const stale = mine.filter( + const stale = images.filter( (row) => - !referenced.includes(row.id) && + !referenced.has(row.id) && (row.attachedAt !== null || row.createdAt <= keepUploadedAfter), ); this.images = this.images.filter((row) => !stale.includes(row)); return Promise.resolve({ - signature, - images: mine.filter((row) => !stale.includes(row)), + signatures: this.mine(user).map((row) => ({ ...row })), + images: images.filter((row) => !stale.includes(row)), removed: stale, }); } + + /** 만든 차례 (서비스가 사람마다 만든 시각을 늘 늘려 준다) */ + private mine(user: string): MailSignatureEntity[] { + return this.signatures + .filter((row) => row.userEmail === user) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + } + + private applyDefaults(user: string, defaults: MailSignatureDefaults): void { + this.signatures = this.signatures.map((row) => + row.userEmail === user + ? { + ...row, + isNewMessageDefault: row.id === defaults.newMessageId, + isReplyDefault: row.id === defaults.replyId, + } + : row, + ); + } } describe('MailSignatureService', () => { @@ -139,7 +197,7 @@ describe('MailSignatureService', () => { ): Promise { const id = overrides.id ?? - `0000000${repository.images.length}-0000-4000-8000-000000000000`; + `${String(repository.images.length).padStart(8, '0')}-0000-4000-8000-000000000000`; const row: MailSignatureImageEntity = { id, userEmail: ARA, @@ -157,6 +215,10 @@ describe('MailSignatureService', () => { return row; } + /** 서명 하나를 만든다 (이름 · 텍스트만) */ + const create = (name: string, extra: { bodyHtml?: string | null } = {}) => + service.createSignature(ARA, { name, body: `${name} 서명`, ...extra }); + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'signature-images-')); repository = new FakeRepository(); @@ -175,110 +237,269 @@ describe('MailSignatureService', () => { rmSync(dir, { recursive: true, force: true }); }); - it('저장한 적 없으면 기본값을 돌려준다', async () => { - await expect(service.getSignature(ARA)).resolves.toEqual({ - bodyHtml: null, - body: null, - isForNew: true, - isForReply: true, - images: [], - updatedAt: null, + describe('여러 서명 (16단계)', () => { + it('서명이 없으면 빈 목록 · 기본 둘 null — 첫 서명은 두 곳의 기본이 되고, 다음 서명은 기본을 바꾸지 않는다', async () => { + await expect(service.listSignatures(ARA)).resolves.toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: null, + }); + + const first = await create('사내'); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: first.id, + replyId: first.id, + }); + + const second = await create('대외'); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: first.id, + replyId: first.id, + }); + const list = await service.listSignatures(` ${ARA.toUpperCase()} `); + expect(list.items.map((item) => [item.id, item.name])).toEqual([ + [first.id, '사내'], + [second.id, '대외'], + ]); + expect(list.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 2, + }); + // 다른 사람의 서명은 따로다 — 그 사람의 첫 서명도 그 사람의 기본이 된다 + const doyun = await service.createSignature(DOYUN, { name: '사내' }); + await expect(service.getDefaults(DOYUN)).resolves.toEqual({ + newMessageId: doyun.id, + replyId: doyun.id, + }); }); - }); - describe('저장', () => { - it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => { + it('서식은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림 · 저장 시각을 함께 돌려준다', async () => { const mine = await storedImage(); - const html = `

김아라

`; + const html = `

김아라

`; - const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, { + const saved = await service.createSignature(ARA, { + name: '대외', bodyHtml: html, body: '김아라 · 아라시스템', - isForNew: true, - isForReply: false, }); expect(saved).toEqual({ + id: expect.stringMatching(/^[0-9a-f-]{36}$/) as string, + name: '대외', bodyHtml: `

김아라

`, body: '김아라 · 아라시스템', - isForNew: true, - isForReply: false, images: [ { id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 }, ], updatedAt: expect.any(String) as string, }); - // 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게 - expect(repository.signatures.has(ARA)).toBe(true); - expect(repository.saveCalls[0].referenced).toEqual([mine.id]); - await expect(service.getSignature(ARA)).resolves.toEqual(saved); + expect(repository.changeCalls[0].plan.referencedImageIds).toEqual([ + mine.id, + ]); + expect((await service.listSignatures(ARA)).items).toEqual([saved]); }); - it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => { - const before = Date.now(); + it(`${SIGNATURES_MAX}개까지 — 11번째는 400 VAL_001, 같은 이름(영문 대소문자 무시)도 VAL_001 — 아무것도 저장하지 않는다`, async () => { + await create('Office'); + expect(await failureOf(() => create('office'))).toEqual({ + code: 'VAL_001', + reason: 'signature-name-taken', + }); + // 영문 밖의 글자는 대소문자를 가리지 않고 그대로 견준다 + await expect(create('ÖFFICE')).resolves.toMatchObject({ name: 'ÖFFICE' }); + for (let index = 3; index <= SIGNATURES_MAX; index += 1) { + await create(`서명 ${index}`); + } + const before = repository.signatures.length; + expect(await failureOf(() => create('열한 번째'))).toEqual({ + code: 'VAL_001', + reason: 'too-many-signatures', + }); + expect(repository.signatures).toHaveLength(before); + }); - await service.saveSignature(ARA, { - bodyHtml: null, - body: null, - isForNew: true, - isForReply: true, + it('고치기는 이름 · 본문을 바꾸고 기본 표시는 그대로 — 다른 서명과 같은 이름은 VAL_001, 자기 이름(대소문자만 바꿈)은 된다', async () => { + const a = await create('사내'); + const b = await create('대외'); + + const updated = await service.updateSignature(ARA, a.id.toUpperCase(), { + name: '사내 (새)', + body: '바꾼 서명', + bodyHtml: '

바꾼 서명

', + }); + expect(updated).toMatchObject({ + id: a.id, + name: '사내 (새)', + body: '바꾼 서명', + bodyHtml: '

바꾼 서명

', + }); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: a.id, + replyId: a.id, }); - const { keepUploadedAfter } = repository.saveCalls[0]; + expect( + await failureOf(() => + service.updateSignature(ARA, b.id, { name: '사내 (새)' }), + ), + ).toEqual({ code: 'VAL_001', reason: 'signature-name-taken' }); + await expect( + service.updateSignature(ARA, b.id, { name: '대외' }), + ).resolves.toMatchObject({ name: '대외', body: null, bodyHtml: null }); + }); + + it('없는 서명 · 남의 서명은 RES_001 — 고치기 · 지우기 · 기본 고르기 모두, 아무것도 바꾸지 않는다', async () => { + const mine = await create('사내'); + const doyuns = await service.createSignature(DOYUN, { name: '대외' }); + + for (const id of [doyuns.id, NOT_MINE, 'not-a-uuid']) { + expect( + await failureOf(() => + service.updateSignature(ARA, id, { name: '바꿈' }), + ), + ).toEqual({ code: 'RES_001', reason: 'signature-not-found' }); + expect(await failureOf(() => service.deleteSignature(ARA, id))).toEqual( + { code: 'RES_001', reason: 'signature-not-found' }, + ); + expect( + await failureOf(() => + service.setDefaults(ARA, { newMessageId: mine.id, replyId: id }), + ), + ).toEqual({ code: 'RES_001', reason: 'signature-not-found' }); + } + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: mine.id, + replyId: mine.id, + }); + expect(repository.signatures).toHaveLength(2); + }); + + it('기본 고르기 — 두 곳을 따로 · 같은 서명 · 넣지 않음(null)으로, 한 사람에 하나씩', async () => { + const a = await create('사내'); + const b = await create('대외'); + + await expect( + service.setDefaults(ARA, { newMessageId: b.id, replyId: a.id }), + ).resolves.toEqual({ newMessageId: b.id, replyId: a.id }); + await expect( + service.setDefaults(ARA, { + newMessageId: null, + replyId: b.id.toUpperCase(), + }), + ).resolves.toEqual({ newMessageId: null, replyId: b.id }); + expect( + repository.signatures.filter((row) => row.isReplyDefault), + ).toHaveLength(1); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: b.id, + }); + }); + + it('지우면 그 서명을 기본으로 쓰던 곳은 넣지 않음 — 다른 서명이 저절로 기본이 되지 않는다', async () => { + const a = await create('사내'); + const b = await create('대외'); + await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id }); + + await service.deleteSignature(ARA, a.id); + + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: b.id, + }); + expect( + (await service.listSignatures(ARA)).items.map((item) => item.id), + ).toEqual([b.id]); + }); + + it('그림 정리는 모든 서명 HTML 의 합집합 — 다른 서명이 쓰는 그림은 남기고, 지운 서명에만 쓰던 그림은 표 · 파일에서 지운다', async () => { + const shared = await storedImage(); + const onlyA = await storedImage(); + // 막 올린 그림 — 사내를 저장할 때는 아직 어느 서명에도 없지만 남는다(10분 안) + const onlyB = await storedImage({ createdAt: new Date() }); + const img = (id: string) => ``; + const a = await create('사내', { + bodyHtml: `${img(shared.id)}${img(onlyA.id)}`, + }); + const b = await create('대외', { + bodyHtml: `${img(shared.id)}${img(onlyB.id)}`, + }); + expect(repository.images.map((row) => row.id)).toEqual([ + shared.id, + onlyA.id, + onlyB.id, + ]); + + // a 에서 shared 를 빼도 b 가 쓰므로 남는다 + await service.updateSignature(ARA, a.id, { + name: '사내', + bodyHtml: img(onlyA.id), + }); + expect(repository.images.map((row) => row.id)).toEqual([ + shared.id, + onlyA.id, + onlyB.id, + ]); + + // a 를 지우면 a 에만 있던 그림이 사라진다 + await service.deleteSignature(ARA, a.id); + expect(repository.images.map((row) => row.id)).toEqual([ + shared.id, + onlyB.id, + ]); + expect(existsSync(join(dir, onlyA.fileName))).toBe(false); + expect(existsSync(join(dir, shared.fileName))).toBe(true); + + // 남은 서명이 그림을 모두 빼면 둘 다 지운다 + await service.updateSignature(ARA, b.id, { name: '대외', body: '글만' }); + expect(repository.images).toEqual([]); + }); + + it('막 올린 그림(10분 안)은 어느 서명에도 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => { + const before = Date.now(); + const fresh = await storedImage({ createdAt: new Date() }); + + await create('사내'); + + const { keepUploadedAfter } = repository.changeCalls[0]; expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual( before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS, ); expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual( Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS, ); + expect(repository.images.map((row) => row.id)).toEqual([fresh.id]); }); it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => { const kept = await storedImage(); const gone = await storedImage(); - repository.deletedBeforeNextSave = gone.id; + repository.deletedBeforeNextChange = gone.id; - const saved = await service.saveSignature(ARA, { + const saved = await create('사내', { bodyHtml: `

서명

`, - body: null, - isForNew: true, - isForReply: true, }); expect(saved.bodyHtml).toBe( `

서명

`, ); expect(saved.images.map((image) => image.id)).toEqual([kept.id]); - expect(repository.saveCalls[0].referenced).toEqual([kept.id]); // 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다 - repository.deletedBeforeNextSave = kept.id; - const empty = await service.saveSignature(ARA, { + repository.deletedBeforeNextChange = kept.id; + const empty = await service.updateSignature(ARA, saved.id, { + name: '사내', bodyHtml: ``, body: '텍스트 서명', - isForNew: true, - isForReply: true, }); expect(empty.bodyHtml).toBeNull(); expect(empty.body).toBe('텍스트 서명'); }); - it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => { - const kept = await storedImage({ attachedAt: new Date() }); - const dropped = await storedImage({ attachedAt: new Date() }); - - await service.saveSignature(ARA, { - bodyHtml: ``, - body: null, - isForNew: true, - isForReply: true, - }); - - expect(repository.images.map((row) => row.id)).toEqual([kept.id]); - expect(existsSync(join(dir, kept.fileName))).toBe(true); - expect(existsSync(join(dir, dropped.fileName))).toBe(false); - }); - it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => { const dropped = await storedImage({ attachedAt: new Date() }); jest @@ -286,12 +507,7 @@ describe('MailSignatureService', () => { .mockRejectedValue(new Error('EACCES: permission denied')); await expect( - service.saveSignature(ARA, { - bodyHtml: '

그림 없음

', - body: null, - isForNew: true, - isForReply: true, - }), + create('사내', { bodyHtml: '

그림 없음

' }), ).resolves.toMatchObject({ bodyHtml: '

그림 없음

', images: [] }); expect(repository.images).not.toContainEqual(dropped); expect(warnSpy).toHaveBeenCalledWith( @@ -300,12 +516,12 @@ describe('MailSignatureService', () => { }); it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => { + const row = await create('사내'); for (const bodyHtml of ['', '', null, undefined]) { - const saved = await service.saveSignature(ARA, { + const saved = await service.updateSignature(ARA, row.id, { + name: '사내', bodyHtml, body: '', - isForNew: false, - isForReply: true, }); expect(saved.bodyHtml).toBeNull(); expect(saved.body).toBeNull(); @@ -322,27 +538,22 @@ describe('MailSignatureService', () => { images.push(await storedImage()); } const img = (id: string) => ``; - const save = (bodyHtml: string) => - service.saveSignature(ARA, { - bodyHtml, - body: null, - isForNew: true, - isForReply: true, - }); expect( await failureOf(() => - save(images.map((image) => img(image.id)).join('')), + create('사내', { + bodyHtml: images.map((image) => img(image.id)).join(''), + }), ), ).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' }); - expect(repository.saveCalls).toHaveLength(0); + expect(repository.changeCalls).toHaveLength(0); // 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다 const [a, b, c] = images; - const saved = await save( - [a, b, c, a].map((image) => img(image.id)).join('') + - img('11111111-1111-4111-8111-111111111111'), - ); + const saved = await create('사내', { + bodyHtml: + [a, b, c, a].map((image) => img(image.id)).join('') + img(NOT_MINE), + }); expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]); }); @@ -363,24 +574,207 @@ describe('MailSignatureService', () => { for (const [input, reason] of cases) { expect( await failureOf(() => - service.saveSignature(ARA, { - isForNew: true, - isForReply: true, - ...input, - }), + service.createSignature(ARA, { name: '사내', ...input }), ), ).toEqual({ code: 'VAL_001', reason }); } - expect(repository.saveCalls).toHaveLength(0); + expect(repository.changeCalls).toHaveLength(0); // 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다 - const saved = await service.saveSignature(ARA, { + const saved = await service.createSignature(ARA, { + name: '사내', body: '가'.repeat(2_000), bodyHtml: `

${'가'.repeat(10_000 - 7)}

`, + }); + expect(saved.bodyHtml).toHaveLength(10_000); + }); + }); + + describe('옛 경로 — 「새 메일」 기본 서명 (앱 첫 판 · 옛 웹 탭)', () => { + it('「새 메일」 기본 서명을 네 칸으로(isForReply 는 답장 기본이 같은 서명일 때만) — 없으면 답장 기본 서명, 둘 다 없으면 본문 없음 · 두 표시 false', async () => { + await expect(service.getLegacySignature(ARA)).resolves.toEqual({ + bodyHtml: null, + body: null, + isForNew: false, + isForReply: false, + images: [], + updatedAt: null, + }); + + const image = await storedImage(); + const a = await create('사내', { + bodyHtml: `

사내

`, + }); + const b = await create('대외'); + await expect(service.getLegacySignature(ARA)).resolves.toEqual({ + bodyHtml: a.bodyHtml, + body: '사내 서명', + isForNew: true, + isForReply: true, + images: a.images, + updatedAt: a.updatedAt, + }); + + await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id }); + await expect(service.getLegacySignature(ARA)).resolves.toMatchObject({ + body: '사내 서명', + isForNew: true, + isForReply: false, + }); + // 「새 메일」 기본이 없으면 답장 기본 서명을 isForNew false 로 — 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원이 + // 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4) + await service.setDefaults(ARA, { newMessageId: null, replyId: b.id }); + await expect(service.getLegacySignature(ARA)).resolves.toEqual({ + bodyHtml: null, + body: '대외 서명', + isForNew: false, + isForReply: true, + images: [], + updatedAt: b.updatedAt, + }); + // 둘 다 없으면 다시 본문 없음 + await service.setDefaults(ARA, { newMessageId: null, replyId: null }); + await expect(service.getLegacySignature(ARA)).resolves.toEqual({ + bodyHtml: null, + body: null, + isForNew: false, + isForReply: false, + images: [], + updatedAt: null, + }); + }); + + it('저장 — 서명이 없으면 "기본 서명"을 만들고, 두 표시를 기본 둘로 옮긴다', async () => { + const saved = await service.saveLegacySignature(ARA, { + bodyHtml: '

김아라

', + body: '김아라', + isForNew: true, + isForReply: false, + }); + + expect(saved).toMatchObject({ + bodyHtml: '

김아라

', + body: '김아라', + isForNew: true, + isForReply: false, + }); + const [row] = (await service.listSignatures(ARA)).items; + expect(row.name).toBe(LEGACY_SIGNATURE_NAME); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: row.id, + replyId: null, + }); + }); + + it('저장 — 「새 메일」 기본 서명의 본문만 바꾸고(이름 그대로), 답장 기본이 다른 서명이면 isForReply false 로는 그대로 둔다', async () => { + const a = await create('사내'); + const b = await create('대외'); + await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id }); + + await service.saveLegacySignature(ARA, { + body: '바꾼 글', + isForNew: true, + isForReply: false, + }); + const items = (await service.listSignatures(ARA)).items; + expect(items.map((item) => [item.name, item.body])).toEqual([ + ['사내', '바꾼 글'], + ['대외', '대외 서명'], + ]); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: a.id, + replyId: b.id, + }); + + // isForReply true → 답장 기본도 이 서명으로 + await service.saveLegacySignature(ARA, { + body: '바꾼 글', isForNew: true, isForReply: true, }); - expect(saved.bodyHtml).toHaveLength(10_000); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: a.id, + replyId: a.id, + }); + }); + + it('저장 — 「새 메일」 기본이 없으면 옛 GET 이 보인 답장 기본 서명을 고친다 (새로 만들지 않는다)', async () => { + await create('사내'); + const b = await create('대외'); + await service.setDefaults(ARA, { newMessageId: null, replyId: b.id }); + + const saved = await service.saveLegacySignature(ARA, { + body: '바꾼 글', + isForNew: false, + isForReply: true, + }); + + expect(saved).toMatchObject({ + body: '바꾼 글', + isForNew: false, + isForReply: true, + }); + const items = (await service.listSignatures(ARA)).items; + expect(items.map((item) => [item.name, item.body])).toEqual([ + ['사내', '사내 서명'], + ['대외', '바꾼 글'], + ]); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: b.id, + }); + }); + + it('저장 — 「새 메일」을 끈 채로 거듭 저장해도 "기본 서명"이 늘지 않는다 (답장 기본으로 · 두 표시를 끄면 이름으로 다시 찾는다)', async () => { + for (let index = 0; index < 3; index += 1) { + await service.saveLegacySignature(ARA, { + body: `글 ${index}`, + isForNew: false, + isForReply: true, + }); + } + const items = (await service.listSignatures(ARA)).items; + expect(items.map((item) => [item.name, item.body])).toEqual([ + [LEGACY_SIGNATURE_NAME, '글 2'], + ]); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: items[0].id, + }); + + // 두 표시를 모두 끄면 기본 서명이 없어진다 — 그 뒤 저장은 이름으로 같은 서명을 다시 찾는다 + for (let index = 3; index < 5; index += 1) { + await service.saveLegacySignature(ARA, { + body: `글 ${index}`, + isForNew: false, + isForReply: false, + }); + } + const after = (await service.listSignatures(ARA)).items; + expect(after.map((item) => [item.name, item.body])).toEqual([ + [LEGACY_SIGNATURE_NAME, '글 4'], + ]); + await expect(service.getDefaults(ARA)).resolves.toEqual({ + newMessageId: null, + replyId: null, + }); + }); + + it(`저장 — 서명을 새로 만들어야 하는데 이미 ${SIGNATURES_MAX}개면 400 VAL_001`, async () => { + for (let index = 1; index <= SIGNATURES_MAX; index += 1) { + await create(`서명 ${index}`); + } + await service.setDefaults(ARA, { newMessageId: null, replyId: null }); + + expect( + await failureOf(() => + service.saveLegacySignature(ARA, { + body: '글', + isForNew: true, + isForReply: true, + }), + ), + ).toEqual({ code: 'VAL_001', reason: 'too-many-signatures' }); }); }); @@ -467,7 +861,8 @@ describe('MailSignatureService', () => { ).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES }); }); - it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => { + it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(서명 여럿이 함께 쓰는 칸) — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => { + expect(SIGNATURE_IMAGES_STORED_MAX).toBe(20); const results = await Promise.allSettled( Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () => upload(pngBytes(10, 10)), @@ -525,7 +920,7 @@ describe('MailSignatureService', () => { }); for (const [user, id] of [ [DOYUN, mine.id], - [ARA, '11111111-1111-4111-8111-111111111111'], + [ARA, NOT_MINE], ]) { expect(await failureOf(() => service.openImage(user, id))).toEqual({ code: 'RES_001', diff --git a/backend/src/modules/mail-signature/mail-signature.service.ts b/backend/src/modules/mail-signature/mail-signature.service.ts index cea78de..9bf68ff 100644 --- a/backend/src/modules/mail-signature/mail-signature.service.ts +++ b/backend/src/modules/mail-signature/mail-signature.service.ts @@ -1,10 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'crypto'; +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; -import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget'; -import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants'; -import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html'; +import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html'; import { findSignatureImageIds, rewriteSignatureImages, @@ -14,6 +16,8 @@ import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { MailSignatureImageEntity } from './mail-signature-image.entity'; import { + LEGACY_SIGNATURE_NAME, + SIGNATURES_MAX, SIGNATURE_HTML_MAX_LENGTH, SIGNATURE_IMAGES_PER_SIGNATURE, SIGNATURE_IMAGES_STORED_MAX, @@ -22,9 +26,16 @@ import { SIGNATURE_TEXT_MAX_LENGTH, } from './mail-signature.constants'; import { MailSignatureEntity } from './mail-signature.entity'; -import { MailSignatureRepository } from './mail-signature.repository'; +import { + MailSignatureRepository, + type SignaturePlan, + type SignatureSnapshot, +} from './mail-signature.repository'; import type { + LegacyMailSignature, + LegacyMailSignatureInput, MailSignature, + MailSignatureDefaults, MailSignatureInput, SignatureImage, SignatureImageUpload, @@ -39,18 +50,36 @@ import { } from './signature-image-format'; import { SignatureImageStorage } from './signature-image-storage'; -const DEFAULT_SIGNATURE: Omit = { +/** 옛 경로 — 「새 메일」 · 「답장 · 전달」 기본 서명이 모두 없을 때 (앱 첫 판은 본문이 없으면 서명을 넣지 않는다) */ +const NO_LEGACY_SIGNATURE: LegacyMailSignature = { bodyHtml: null, body: null, - isForNew: true, - isForReply: true, + isForNew: false, + isForReply: false, + images: [], updatedAt: null, }; +/** 정화 · 길이를 지난 서명 본문 — 그림은 잠근 뒤 다시 맞춘다 */ +interface PreparedSignatureBody { + readonly bodyHtml: string | null; + readonly body: string | null; +} + function normalizeUser(email: string): string { return email.trim().toLowerCase(); } +/** 서명 id · 그림 id — 서버가 만든 UUID(소문자). 경로 파이프가 소문자로 바꾸지만 본문에서 온 id(기본 고르기)도 같게 */ +function normalizeId(id: string): string { + return id.trim().toLowerCase(); +} + +/** 이름 견주기 — ASCII 대소문자만 무시한다 (화면과 같다 — 한글 · 그 밖의 글자는 그대로 견준다) */ +function nameKey(name: string): string { + return name.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} + function toImage(row: MailSignatureImageEntity): SignatureImage { return { id: row.id, @@ -61,6 +90,67 @@ function toImage(row: MailSignatureImageEntity): SignatureImage { }; } +/** 서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (표에 없는 그림은 뺀다) */ +function imagesOf( + html: string | null, + images: readonly MailSignatureImageEntity[], +): SignatureImage[] { + if (!html) return []; + const byId = new Map(images.map((image) => [image.id, image])); + return findSignatureImageIds(html).flatMap((id) => { + const image = byId.get(id); + return image ? [toImage(image)] : []; + }); +} + +function toSignature( + row: MailSignatureEntity, + images: readonly MailSignatureImageEntity[], +): MailSignature { + return { + id: row.id, + name: row.name, + bodyHtml: row.bodyHtml, + body: row.bodyText, + images: imagesOf(row.bodyHtml, images), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function defaultsOf( + signatures: readonly MailSignatureEntity[], +): MailSignatureDefaults { + return { + newMessageId: signatures.find((row) => row.isNewMessageDefault)?.id ?? null, + replyId: signatures.find((row) => row.isReplyDefault)?.id ?? null, + }; +} + +/** + * 새 서명의 만든 시각 — 그 사람의 마지막 서명보다 늦게(같은 밀리초면 1ms 뒤로). 목록은 만든 차례라 + * 잠근 차례대로 늘 뒤에 붙는다 (같은 시각이면 id 차례가 되어 들쭉날쭉하지 않게) + */ +function createdAfter( + signatures: readonly MailSignatureEntity[], + now: Date, +): Date { + const last = Math.max(0, ...signatures.map((row) => row.createdAt.getTime())); + return new Date(Math.max(now.getTime(), last + 1)); +} + +/** 모든 서명 HTML 이 가리키는 그림 — 합집합 (지울 그림을 정하는 기준) */ +function referencedImageIdsOf( + signatures: readonly Pick[], +): string[] { + return [ + ...new Set( + signatures.flatMap((row) => + row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [], + ), + ), + ]; +} + /** 올린 이름의 확장자 (소문자, 점 포함) */ function extensionOf(name: string): string { const dot = name.lastIndexOf('.'); @@ -68,15 +158,19 @@ function extensionOf(name: string): string { } /** - * 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다. + * 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지), 서버 DB 에 둔다. * - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다 + * - 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시다 — 서명이 하나도 없던 사람의 첫 서명이 두 곳의 기본이 된다 + * - 서명 그림은 모든 서명이 함께 쓰는 칸(한 사람 20장)이다 — 저장 · 지우기 때 **어느 서명도 가리키지 않는** 그림을 지운다(막 올린 것은 남긴다) * - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다 - * - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001) + * - 서명 · 그림은 주인만 본다 — 남의 id 는 없는 것과 같다 (RES_001) + * - 옛 경로(`GET · PUT /api/mail-signature` — 앱 첫 판 · 16단계 전의 웹 탭)는 「새 메일」 기본 서명을 15단계까지의 모양으로 다룬다 + * 서명 · 이름 · 글은 로그에 남기지 않는다 (사유 · 수 · 가린 주소만) */ @Injectable() export class MailSignatureService { private readonly logger = new Logger(MailSignatureService.name); - /** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */ + /** 사용자별 그림 올리기 차례 — 보관 한도(20장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */ private readonly uploadQueues = new Map>(); constructor( @@ -84,76 +178,236 @@ export class MailSignatureService { private readonly storage: SignatureImageStorage, ) {} - async getSignature(userEmail: string): Promise { + /** 내 서명 — 만든 차례, 한 번에 모두 (hasNext 늘 false) */ + async listSignatures( + userEmail: string, + ): Promise> { const user = normalizeUser(userEmail); - const [row, images] = await Promise.all([ - this.repository.findSignature(user), + const [rows, images] = await Promise.all([ + this.repository.listSignatures(user), this.repository.listImages(user), ]); - if (!row) return { ...DEFAULT_SIGNATURE, images: [] }; - return this.toSignature(row, images); + return toPaginatedResult( + rows.map((row) => toSignature(row, images)), + { hasNext: false, nextCursor: null, total: rows.length }, + ); } /** - * 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다 - * (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS). - * 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001. + * 서명을 만든다 — 서명이 하나도 없던 사람이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다. + * 11번째 · 같은 이름(ASCII 대소문자 무시)이면 VAL_001 (아무것도 저장하지 않는다) */ - async saveSignature( + async createSignature( userEmail: string, input: MailSignatureInput, ): Promise { const user = normalizeUser(userEmail); - const body = input.body ? input.body : null; - if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) { - throw new BusinessException('VAL_001', { - context: { reason: 'signature-text-too-long', length: body.length }, - }); - } - - const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml); - // 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다 - const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : []; - if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) { - throw new BusinessException('VAL_001', { - context: { - reason: 'signature-too-many-images', - count: referenced.length, - }, - }); - } - - const now = new Date(); - const saved = await this.repository.saveSignature( - user, - (images) => { - // 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게) - const existing = new Set(images.map((image) => image.id)); - const html = - bodyHtml === null - ? null - : rewriteSignatureImages(bodyHtml, (id) => - existing.has(id) ? signatureImagePath(id) : null, - ); - const kept = html !== null && html.trim() !== '' ? html : null; - return { - signature: { - userEmail: user, - bodyHtml: kept, - bodyText: body, - isForNew: input.isForNew, - isForReply: input.isForReply, - updatedAt: now, + const prepared = await this.prepareBody(user, input); + const id = randomUUID(); + const result = await this.change(user, (snapshot, now) => { + if (snapshot.signatures.length >= SIGNATURES_MAX) { + throw new BusinessException('VAL_001', { + context: { + reason: 'too-many-signatures', + count: snapshot.signatures.length, }, - referencedImageIds: kept ? findSignatureImageIds(kept) : [], - }; - }, - now, - new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS), - ); - await this.removeFiles(saved.removed, user); + }); + } + this.assertNameFree(snapshot.signatures, input.name, null); + const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot); + const row = { + id, + userEmail: user, + name: input.name, + bodyHtml, + bodyText: prepared.body, + createdAt: createdAfter(snapshot.signatures, now), + updatedAt: now, + }; + return { + insert: row, + // 첫 서명은 두 곳의 기본 — 지금까지 서명이 처음에 두 곳에 들어가던 것과 같다 + ...(snapshot.signatures.length === 0 + ? { defaults: { newMessageId: id, replyId: id } } + : {}), + referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]), + }; + }); + return this.signatureIn(result, id); + } - return this.toSignature(saved.signature, saved.images); + /** + * 서명을 고친다(이름 · 본문 — 전체 교체). 기본 표시는 그대로. 없는 서명 · 남의 서명은 RES_001, 다른 서명과 같은 이름은 VAL_001. + * 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올린 그림(10분 안)은 남긴다 (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS) + */ + async updateSignature( + userEmail: string, + signatureId: string, + input: MailSignatureInput, + ): Promise { + const user = normalizeUser(userEmail); + const id = normalizeId(signatureId); + const prepared = await this.prepareBody(user, input); + const result = await this.change(user, (snapshot, now) => { + this.requireSignature(snapshot, id); + this.assertNameFree(snapshot.signatures, input.name, id); + const update = { + id, + name: input.name, + bodyHtml: this.keepExistingImages(prepared.bodyHtml, snapshot), + bodyText: prepared.body, + updatedAt: now, + }; + return { + update, + referencedImageIds: referencedImageIdsOf( + snapshot.signatures.map((row) => (row.id === id ? update : row)), + ), + }; + }); + return this.signatureIn(result, id); + } + + /** + * 서명을 지운다 — 이 서명을 기본으로 쓰던 곳은 "넣지 않음"이 된다(다른 서명이 저절로 기본이 되지 않는다). + * 이 서명에만 쓰던 그림은 함께 지운다. 없는 서명 · 남의 서명은 RES_001 + */ + async deleteSignature(userEmail: string, signatureId: string): Promise { + const user = normalizeUser(userEmail); + const id = normalizeId(signatureId); + await this.change(user, (snapshot) => { + this.requireSignature(snapshot, id); + return { + removeId: id, + referencedImageIds: referencedImageIdsOf( + snapshot.signatures.filter((row) => row.id !== id), + ), + }; + }); + } + + /** 「메일에 넣을 서명」 둘 — null 은 넣지 않음 */ + async getDefaults(userEmail: string): Promise { + return defaultsOf( + await this.repository.listSignatures(normalizeUser(userEmail)), + ); + } + + /** 「메일에 넣을 서명」 둘을 바꾼다 — 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 RES_001 (아무것도 바꾸지 않는다) */ + async setDefaults( + userEmail: string, + defaults: MailSignatureDefaults, + ): Promise { + const user = normalizeUser(userEmail); + const wanted: MailSignatureDefaults = { + newMessageId: + defaults.newMessageId === null + ? null + : normalizeId(defaults.newMessageId), + replyId: defaults.replyId === null ? null : normalizeId(defaults.replyId), + }; + const result = await this.change(user, (snapshot) => { + for (const id of [wanted.newMessageId, wanted.replyId]) { + if (id !== null) this.requireSignature(snapshot, id); + } + return { + defaults: wanted, + referencedImageIds: referencedImageIdsOf(snapshot.signatures), + }; + }); + return defaultsOf(result.signatures); + } + + /** + * 옛 경로 — 「새 메일」 기본 서명을 15단계까지의 모양으로 (앱 첫 판이 읽는다). 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 + * isForNew false · isForReply true 로, 둘 다 없으면 본문 없음 · 두 표시 false. + * 「새 메일」 기본이 있을 때 isForReply 는 답장 기본이 같은 서명일 때만 true — 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계) + */ + async getLegacySignature(userEmail: string): Promise { + const user = normalizeUser(userEmail); + const [rows, images] = await Promise.all([ + this.repository.listSignatures(user), + this.repository.listImages(user), + ]); + return toLegacySignature(rows, images); + } + + /** + * 옛 경로의 저장(16단계 전의 웹 탭) — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다. + * 기본 서명이 둘 다 없으면 "기본 서명"이라는 이름의 서명(ASCII 대소문자 무시 — 있으면 그것, 없으면 새로 만든다)을 쓴다 — + * 옛 탭이 두 표시를 끈 채 저장을 거듭해도 서명이 늘지 않게. + * isForNew → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), isForReply → 「답장 · 전달」 기본을 이 서명으로 + * (false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로 둔다) + */ + async saveLegacySignature( + userEmail: string, + input: LegacyMailSignatureInput, + ): Promise { + const user = normalizeUser(userEmail); + const prepared = await this.prepareBody(user, input); + const newId = randomUUID(); + const result = await this.change(user, (snapshot, now) => { + const current = defaultsOf(snapshot.signatures); + const target = + legacyTargetOf(snapshot.signatures) ?? + snapshot.signatures.find( + (row) => nameKey(row.name) === nameKey(LEGACY_SIGNATURE_NAME), + ) ?? + null; + const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot); + const id = target?.id ?? newId; + const defaults: MailSignatureDefaults = { + newMessageId: input.isForNew + ? id + : current.newMessageId === id + ? null + : current.newMessageId, + replyId: input.isForReply + ? id + : current.replyId === id + ? null + : current.replyId, + }; + if (target) { + const update = { + id, + name: target.name, + bodyHtml, + bodyText: prepared.body, + updatedAt: now, + }; + return { + update, + defaults, + referencedImageIds: referencedImageIdsOf( + snapshot.signatures.map((row) => (row.id === id ? update : row)), + ), + }; + } + if (snapshot.signatures.length >= SIGNATURES_MAX) { + throw new BusinessException('VAL_001', { + context: { + reason: 'too-many-signatures', + count: snapshot.signatures.length, + }, + }); + } + const row = { + id, + userEmail: user, + name: LEGACY_SIGNATURE_NAME, + bodyHtml, + bodyText: prepared.body, + createdAt: createdAfter(snapshot.signatures, now), + updatedAt: now, + }; + return { + insert: row, + defaults, + referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]), + }; + }); + return toLegacySignature(result.signatures, result.images); } /** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */ @@ -284,59 +538,125 @@ export class MailSignatureService { return loaded; } - /** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */ - private async sanitizeSignatureHtml( + /** + * 서명 본문 — 텍스트 2,000자, 서식은 정화 전 비용 · 정화 뒤 10,000자(JS 문자열 길이), 서로 다른 그림 3장(정화가 남의 그림은 지웠다 — + * 같은 그림 두 번은 한 장). 넘으면 VAL_001 + */ + private async prepareBody( user: string, - html: string | null | undefined, - ): Promise { - if (!html) return null; - // 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게) - if (html.length > MAX_BODY_HTML_LENGTH) { + input: { readonly bodyHtml?: string | null; readonly body?: string | null }, + ): Promise { + const body = input.body ? input.body : null; + if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) { throw new BusinessException('VAL_001', { - context: { reason: 'signature-html-too-long', length: html.length }, - }); - } - const budget = checkHtmlParseBudget(html); - if (!budget.ok) { - throw new BusinessException('VAL_001', { - context: { - reason: 'signature-html-too-complex', - detail: budget.reason, - }, + context: { reason: 'signature-text-too-long', length: body.length }, }); } - const sanitized = sanitizeOutgoingHtml(html, { - signatureImageIds: await this.imageIdsOf(user), + const signatureImageIds = input.bodyHtml + ? await this.imageIdsOf(user) + : undefined; + const sanitized = sanitizeStoredHtml(input.bodyHtml, { + maxLength: SIGNATURE_HTML_MAX_LENGTH, + signatureImageIds, }); - if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) { + if (!sanitized.ok) { throw new BusinessException('VAL_001', { context: { - reason: 'signature-html-too-long', + reason: + sanitized.problem === 'too-complex' + ? 'signature-html-too-complex' + : 'signature-html-too-long', length: sanitized.length, }, }); } - return sanitized === '' ? null : sanitized; + const referenced = sanitized.html + ? findSignatureImageIds(sanitized.html) + : []; + if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) { + throw new BusinessException('VAL_001', { + context: { + reason: 'signature-too-many-images', + count: referenced.length, + }, + }); + } + return { bodyHtml: sanitized.html, body }; } - private toSignature( - row: MailSignatureEntity, - images: readonly MailSignatureImageEntity[], + /** + * 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장 · 정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게). + * 남는 것이 없으면 null + */ + private keepExistingImages( + html: string | null, + snapshot: SignatureSnapshot, + ): string | null { + if (html === null) return null; + const existing = new Set(snapshot.images.map((image) => image.id)); + const kept = rewriteSignatureImages(html, (id) => + existing.has(id) ? signatureImagePath(id) : null, + ); + return kept.trim() !== '' ? kept : null; + } + + /** 같은 이름(ASCII 대소문자 무시)의 다른 서명이 있으면 VAL_001 */ + private assertNameFree( + signatures: readonly MailSignatureEntity[], + name: string, + exceptId: string | null, + ): void { + const key = nameKey(name); + if ( + signatures.some((row) => row.id !== exceptId && nameKey(row.name) === key) + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'signature-name-taken' }, + }); + } + } + + /** 그 사람의 서명이어야 한다 — 없으면(남의 것 · 형식이 틀린 id 포함) RES_001 */ + private requireSignature(snapshot: SignatureSnapshot, id: string): void { + if (!snapshot.signatures.some((row) => row.id === id)) { + throw new BusinessException('RES_001', { + context: { reason: 'signature-not-found' }, + }); + } + } + + /** 잠근 뒤 바꾸고, 지운 그림의 파일을 지운다 */ + private async change( + user: string, + plan: (snapshot: SignatureSnapshot, now: Date) => SignaturePlan, + ) { + const now = new Date(); + const result = await this.repository.change( + user, + (snapshot) => plan(snapshot, now), + now, + new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS), + ); + await this.removeFiles(result.removed, user); + return result; + } + + /** 바꾼 뒤의 서명 하나 — 방금 넣거나 고친 줄은 늘 있다 */ + private signatureIn( + result: { + readonly signatures: readonly MailSignatureEntity[]; + readonly images: readonly MailSignatureImageEntity[]; + }, + id: string, ): MailSignature { - const byId = new Map(images.map((image) => [image.id, image])); - const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : []; - return { - bodyHtml: row.bodyHtml, - body: row.bodyText, - isForNew: row.isForNew, - isForReply: row.isForReply, - images: used.flatMap((id) => { - const image = byId.get(id); - return image ? [toImage(image)] : []; - }), - updatedAt: row.updatedAt.toISOString(), - }; + const row = result.signatures.find((candidate) => candidate.id === id); + if (!row) { + throw new BusinessException('SYS_001', { + context: { reason: 'signature-missing-after-save' }, + }); + } + return toSignature(row, result.images); } /** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */ @@ -370,3 +690,38 @@ export class MailSignatureService { return run; } } + +/** + * 옛 경로가 다루는 서명 — 「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 null. + * 답장 기본까지 보는 까닭: 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원(옮기면 답장 기본만 있다)이 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4). + * 옛 PUT 도 이 서명을 고친다 — 옛 탭이 읽은 서명을 그대로 저장하게 + */ +function legacyTargetOf( + rows: readonly MailSignatureEntity[], +): MailSignatureEntity | null { + return ( + rows.find((candidate) => candidate.isNewMessageDefault) ?? + rows.find((candidate) => candidate.isReplyDefault) ?? + null + ); +} + +/** + * 옛 경로의 모양 — 「새 메일」 기본 서명(isForNew true · isForReply 는 답장 기본도 그 서명일 때), 없으면 「답장 · 전달」 기본 서명 + * (isForNew false · isForReply true), 둘 다 없으면 본문 없음 · 두 표시 false + */ +function toLegacySignature( + rows: readonly MailSignatureEntity[], + images: readonly MailSignatureImageEntity[], +): LegacyMailSignature { + const row = legacyTargetOf(rows); + if (!row) return NO_LEGACY_SIGNATURE; + return { + bodyHtml: row.bodyHtml, + body: row.bodyText, + isForNew: row.isNewMessageDefault, + isForReply: row.isReplyDefault, + images: imagesOf(row.bodyHtml, images), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/backend/src/modules/mail-signature/mail-signature.types.ts b/backend/src/modules/mail-signature/mail-signature.types.ts index c2fe761..1a5cfaf 100644 --- a/backend/src/modules/mail-signature/mail-signature.types.ts +++ b/backend/src/modules/mail-signature/mail-signature.types.ts @@ -1,7 +1,7 @@ import type { SignatureImageType } from './signature-image-format'; /** - * 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다. + * 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다 (16단계부터 한 사람에 여러 개). */ export interface SignatureImage { @@ -14,21 +14,51 @@ export interface SignatureImage { readonly height: number; } +/** 서명 하나 — `GET · POST /api/mail-signatures` · `PUT /api/mail-signatures/{id}` (16단계) */ export interface MailSignature { - /** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */ + readonly id: string; + /** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시) */ + readonly name: string; + /** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하. 없으면 null */ + readonly bodyHtml: string | null; + /** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하. 없으면 null */ + readonly body: string | null; + /** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */ + readonly images: SignatureImage[]; + /** 마지막으로 저장한 시각 (ISO 8601 UTC) */ + readonly updatedAt: string; +} + +/** 서명 만들기 · 고치기 본문 (전체 교체) */ +export interface MailSignatureInput { + /** 앞뒤 공백을 뺀 값 (요청 검증이 지운다) */ + readonly name: string; + readonly bodyHtml?: string | null; + readonly body?: string | null; +} + +/** 「메일에 넣을 서명」 둘 — `GET · PUT /api/mail-signature-defaults` (16단계). null = 넣지 않음 */ +export interface MailSignatureDefaults { + readonly newMessageId: string | null; + readonly replyId: string | null; +} + +/** + * 옛 경로의 서명 — `GET /api/mail-signature` (앱 첫 판 · 16단계 전의 웹 탭). 「새 메일」 기본 서명을 15단계까지의 네 칸으로 내준다 + * (없으면 「답장 · 전달」 기본 서명을 isForNew false 로). isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 이 서명 + */ +export interface LegacyMailSignature { readonly bodyHtml: string | null; - /** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */ readonly body: string | null; readonly isForNew: boolean; readonly isForReply: boolean; - /** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */ readonly images: SignatureImage[]; - /** 저장한 적 없으면 null */ + /** 기본 서명이 둘 다 없으면 null */ readonly updatedAt: string | null; } -/** PUT /api/mail-signature 본문 */ -export interface MailSignatureInput { +/** 옛 경로의 저장 본문 — `PUT /api/mail-signature` */ +export interface LegacyMailSignatureInput { readonly bodyHtml?: string | null; readonly body?: string | null; readonly isForNew: boolean; diff --git a/backend/src/modules/mail-signature/mail-signatures.controller.ts b/backend/src/modules/mail-signature/mail-signatures.controller.ts new file mode 100644 index 0000000..fb9d4dc --- /dev/null +++ b/backend/src/modules/mail-signature/mail-signatures.controller.ts @@ -0,0 +1,184 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiSessionErrors, + ApiStandardResponse, +} 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 { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + MailSignatureDefaultsDto, + MailSignatureDto, + MailSignaturePageDto, + SaveMailSignatureDto, +} from './dto/mail-signature.dto'; +import { + SIGNATURES_MAX, + SIGNATURE_HTML_MAX_LENGTH, + SIGNATURE_IMAGES_PER_SIGNATURE, + SIGNATURE_NAME_MAX_LENGTH, + SIGNATURE_TEXT_MAX_LENGTH, +} from './mail-signature.constants'; +import { MailSignatureService } from './mail-signature.service'; +import { MailSignatureIdPipe } from './signature-image-id.pipe'; + +/** 서명 저장(만들기 · 고치기)의 400 VAL_001 사유 */ +const SAVE_REJECTIONS = + `형식 오류 · 이름(앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자 · 제어 문자 없음) · 같은 이름의 다른 서명(영문 대소문자 무시) · ` + + `텍스트 서명 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · 정화한 서식 서명 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · ` + + `서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 모르는 필드`; + +const SIGNATURE_ID_PARAM = { + name: 'id', + description: '서명 id (서버가 만든 UUID) — 형식이 틀리면 404 RES_001', + example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d', +}; + +// 여러 서명 (계약 2절 "서명" — 16단계) — 로그인한 직원 자신의 서명만 다룬다. 서명 그림 경로는 그대로 /api/mail-signature/images (mail-signature.controller.ts) +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@Controller('mail-signatures') +export class MailSignaturesController { + constructor(private readonly signatures: MailSignatureService) {} + + @Get() + @ApiOperation({ + summary: '내 서명 목록', + description: `만든 차례 · ${SIGNATURES_MAX}개까지 한 번에 모두 (hasNext=false). 「메일에 넣을 서명」은 /api/mail-signature-defaults`, + }) + @ApiStandardResponse(MailSignaturePageDto, { description: '서명 목록' }) + list(@CurrentUser() user: AuthenticatedUser) { + return this.signatures.listSignatures(user.email); + } + + @Post() + @ApiOperation({ + summary: '서명 만들기', + description: + `${SIGNATURES_MAX}개까지. 서명이 하나도 없던 직원이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(그 밖에는 기본을 바꾸지 않는다). ` + + 'bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.', + }) + @ApiStandardResponse(MailSignatureDto, { + status: HttpStatus.CREATED, + description: '만든 서명', + }) + @ApiErrorResponse( + 'VAL_001', + `${SAVE_REJECTIONS} · 이미 ${SIGNATURES_MAX}개 — 저장하지 않았다`, + ) + create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: SaveMailSignatureDto, + ) { + return this.signatures.createSignature(user.email, dto); + } + + @Put(':id') + @ApiOperation({ + summary: '서명 고치기 (이름 · 본문 — 전체 교체)', + description: + '「메일에 넣을 서명」 표시는 그대로다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.', + }) + @ApiParam(SIGNATURE_ID_PARAM) + @ApiStandardResponse(MailSignatureDto, { description: '고친 서명' }) + @ApiErrorResponse('VAL_001', `${SAVE_REJECTIONS} — 저장하지 않았다`) + @ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id') + update( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailSignatureIdPipe) id: string, + @Body() dto: SaveMailSignatureDto, + ) { + return this.signatures.updateSignature(user.email, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @ApiOperation({ + summary: '서명 지우기', + description: + '이 서명을 기본으로 쓰던 곳(새 메일 · 답장과 전달)은 넣지 않음이 된다 — 다른 서명이 저절로 기본이 되지 않는다. ' + + '이 서명에만 쓰던 그림은 함께 지운다. 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 그대로다.', + }) + @ApiParam(SIGNATURE_ID_PARAM) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: '지웠다 — 본문 없음', + }) + @ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id') + async remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailSignatureIdPipe) id: string, + ): Promise { + await this.signatures.deleteSignature(user.email, id); + } +} + +// 「메일에 넣을 서명」 둘 (계약 2절 "서명" — 16단계) — 새 메일 · 답장과 전달에 저절로 넣는 서명. 15단계까지의 두 체크(isForNew · isForReply)를 대신한다 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@Controller('mail-signature-defaults') +export class MailSignatureDefaultsController { + constructor(private readonly signatures: MailSignatureService) {} + + @Get() + @ApiOperation({ + summary: '메일에 넣을 서명', + description: + '새 메일 · 답장과 전달에 넣는 서명의 id — null 이면 넣지 않음. 서명이 하나도 없으면 둘 다 null', + }) + @ApiStandardResponse(MailSignatureDefaultsDto, { + description: '메일에 넣을 서명', + }) + get(@CurrentUser() user: AuthenticatedUser) { + return this.signatures.getDefaults(user.email); + } + + @Put() + @ApiOperation({ + summary: '메일에 넣을 서명 바꾸기 (전체 교체)', + description: + '두 키를 모두 보낸다(null = 넣지 않음). 두 곳이 같은 서명이어도 된다. 바꾼 뒤의 값을 돌려준다', + }) + @ApiStandardResponse(MailSignatureDefaultsDto, { + description: '바꾼 뒤의 값', + }) + @ApiErrorResponse('VAL_001', '형식 오류 · 빠진 키 · 모르는 필드') + @ApiErrorResponse( + 'RES_001', + '내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림) — 아무것도 바꾸지 않았다', + ) + update( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailSignatureDefaultsDto, + ) { + return this.signatures.setDefaults(user.email, dto); + } +} diff --git a/backend/src/modules/mail-signature/signature-image-id.pipe.ts b/backend/src/modules/mail-signature/signature-image-id.pipe.ts index 908e99d..c5d0d51 100644 --- a/backend/src/modules/mail-signature/signature-image-id.pipe.ts +++ b/backend/src/modules/mail-signature/signature-image-id.pipe.ts @@ -2,10 +2,18 @@ import { Injectable, PipeTransform } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; -/** 서명 그림 id — 서버가 만든 UUID */ -const SIGNATURE_IMAGE_ID = +/** 서명 · 서명 그림 id — 서버가 만든 UUID */ +const SERVER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** 경로의 UUID 를 소문자로 — 형식이 틀리면 "그런 것이 없다"와 같으므로 RES_001 (그 사유로) */ +function serverUuid(value: unknown, reason: string): string { + if (typeof value === 'string' && SERVER_UUID.test(value)) { + return value.toLowerCase(); + } + throw new BusinessException('RES_001', { context: { reason } }); +} + /** * 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 — * 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게). @@ -13,11 +21,14 @@ const SIGNATURE_IMAGE_ID = @Injectable() export class SignatureImageIdPipe implements PipeTransform { transform(value: unknown): string { - if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) { - return value.toLowerCase(); - } - throw new BusinessException('RES_001', { - context: { reason: 'malformed-signature-image-id' }, - }); + return serverUuid(value, 'malformed-signature-image-id'); + } +} + +/** 경로의 서명 id 형식 확인 (16단계 — `/api/mail-signatures/{id}`) — 그림 id 와 같은 규칙, 틀리면 RES_001 */ +@Injectable() +export class MailSignatureIdPipe implements PipeTransform { + transform(value: unknown): string { + return serverUuid(value, 'malformed-signature-id'); } } diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts index cac75f9..7083faa 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts @@ -1,5 +1,5 @@ import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; +import { validate, type ValidationError } from 'class-validator'; import { MAX_VACATION_SUBJECT_LENGTH, @@ -18,6 +18,22 @@ async function constraintsOf(body: object): Promise { return errors.flatMap((error) => Object.keys(error.constraints ?? {})); } +/** 안쪽 객체(external — 16단계)까지 — `칸.규칙` 모양으로 모두 */ +async function allConstraintsOf(body: object): Promise { + const errors = await validate(plainToInstance(MailVacationDto, body), { + whitelist: true, + forbidNonWhitelisted: true, + }); + const flatten = (list: ValidationError[], prefix: string): string[] => + list.flatMap((error) => [ + ...Object.keys(error.constraints ?? {}).map( + (name) => `${prefix}${error.property}.${name}`, + ), + ...flatten(error.children ?? [], `${prefix}${error.property}.`), + ]); + return flatten(errors, ''); +} + describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => { const enabled = { isEnabled: true, @@ -72,7 +88,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => { ).toEqual([]); }); - it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => { + it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 16단계부터 서식 본문(htmlBody)도 받는다`, async () => { for (const isEnabled of [false, true]) { expect( await constraintsOf({ @@ -102,7 +118,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => { ); expect( await constraintsOf({ ...enabled, htmlBody: '

휴가

' }), - ).toEqual(['whitelistValidation']); + ).toEqual([]); }); it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => { @@ -212,6 +228,134 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => { }); }); +describe('부재중 서식 본문 · 회사 밖 응답 (16단계)', () => { + const enabled = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + const external = { + mode: 'separate', + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: '

I am away until Nov 7.

', + }; + + it('htmlBody · external 은 보내지 않아도 된다(옛 요청) — 보내면 htmlBody 는 글 · null, external 은 네 키(글 칸은 null 가능) · null', async () => { + expect(await allConstraintsOf(enabled)).toEqual([]); + expect( + await allConstraintsOf({ + ...enabled, + htmlBody: '

휴가

', + external, + }), + ).toEqual([]); + expect( + await allConstraintsOf({ ...enabled, htmlBody: null, external: null }), + ).toEqual([]); + for (const mode of ['same', 'none', 'separate']) { + expect( + await allConstraintsOf({ + ...enabled, + external: { mode, subject: null, textBody: null, htmlBody: null }, + }), + ).toEqual([]); + } + }); + + it('형식이 틀리면 VAL_001 — 모르는 고르기 · 빠진 키 · 글이 아닌 값 · 모르는 키 · 객체가 아님', async () => { + expect(await allConstraintsOf({ ...enabled, htmlBody: 1 })).toEqual([ + 'htmlBody.isString', + ]); + expect( + await allConstraintsOf({ + ...enabled, + external: { ...external, mode: 'internal' }, + }), + ).toEqual(['external.mode.isIn']); + // 빠진 키 — 전체 교체라 null 로 보낸다(이 검사는 stopAtFirstError 를 빼 걸린 규칙이 모두 보인다) + expect( + await allConstraintsOf({ ...enabled, external: { mode: 'same' } }), + ).toEqual( + expect.arrayContaining([ + 'external.subject.isString', + 'external.textBody.isString', + 'external.htmlBody.isString', + ]), + ); + expect( + await allConstraintsOf({ + ...enabled, + external: { ...external, subject: 1, htmlBody: [] }, + }), + ).toEqual( + expect.arrayContaining([ + 'external.subject.isString', + 'external.htmlBody.isString', + ]), + ); + expect( + await allConstraintsOf({ + ...enabled, + external: { ...external, isActive: true }, + }), + ).toEqual(['external.isActive.whitelistValidation']); + for (const value of [[], 'same', 1]) { + expect(await allConstraintsOf({ ...enabled, external: value })).toEqual( + expect.arrayContaining(['external.isObject']), + ); + } + }); + + it(`회사 밖 제목 · 내용은 사내 칸과 같은 형식 한도를 늘 본다 — ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 · 제어 문자 (켤 때의 150 · 600자는 서비스가)`, async () => { + for (const isEnabled of [false, true]) { + expect( + await allConstraintsOf({ + ...enabled, + isEnabled, + external: { + ...external, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF), + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF), + }, + }), + ).toEqual([]); + } + expect( + await allConstraintsOf({ + ...enabled, + external: { + ...external, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1), + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1), + }, + }), + ).toEqual([ + 'external.subject.maxCodeUnits', + 'external.textBody.maxCodeUnits', + ]); + expect( + await allConstraintsOf({ + ...enabled, + external: { ...external, subject: 'a\nb', textBody: 'a\u0000' }, + }), + ).toEqual(['external.subject.matches', 'external.textBody.matches']); + // 켤 때의 한도(150 · 600)를 넘는 글도 형식으로는 맞다 — 켤 때 따로 쓰기면 서비스가 VAL_001 + expect( + await allConstraintsOf({ + ...enabled, + external: { + ...external, + subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1), + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1), + }, + }), + ).toEqual([]); + }); +}); + describe('isUtcDateTime', () => { it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => { expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true); diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.ts b/backend/src/modules/mail/dto/mail-vacation.dto.ts index e220bca..58ce655 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.ts @@ -1,15 +1,22 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsBoolean, + IsIn, + IsObject, + IsOptional, IsString, Matches, ValidateBy, ValidateIf, + ValidateNested, type ValidationOptions, } from 'class-validator'; import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; import { + MAIL_VACATION_EXTERNAL_MODES, + MAX_VACATION_HTML_LENGTH, MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, MAX_VACATION_TEXT_LENGTH, @@ -18,7 +25,8 @@ import { SINGLE_LINE_TEXT_PATTERN, } from '@/modules/mail/mail.constants'; import type { - MailVacation, + MailVacationExternal, + MailVacationExternalMode, MailVacationState, } from '@/modules/mail/mail.types'; @@ -46,16 +54,84 @@ export function IsUtcDateTime(options: ValidationOptions) { ); } -/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */ +/** null 은 "날짜 없음" · "쓴 적 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 키를 모두 보낸다) */ const unlessNull = (_object: object, value: unknown) => value !== null; +const HTML_BODY_DESCRIPTION = + `서식 본문 — 서식 본문과 같은 허용 목록으로 정화해 저장한다(그림은 넣을 수 없다). 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지(켤 때만 본다 — 넘으면 400 VAL_001). ` + + 'null · 빈 글이면 글만. 받는 사람에게는 글(textBody) + 서식 두 벌로 간다'; + +/** + * 회사 밖 응답 (16단계) — 네 키를 모두 보낸다(글 칸은 null 가능). 따로 쓰기(separate)이고 켤 때는 제목 · 내용이 1자 이상 · + * 제목 150자 · 내용 600자 · 서식 10,000자(서비스가 본다). 다른 고르기여도 써 둔 글은 그대로 저장한다 + */ +export class MailVacationExternalDto implements MailVacationExternal { + @ApiProperty({ + description: + '회사 밖(봉투 보낸 사람의 도메인이 회사 메일 도메인이 아님)에서 온 메일에 — same: 사내와 같은 응답(처음 값) · none: 보내지 않기 · separate: 아래 제목 · 내용으로', + enum: MAIL_VACATION_EXTERNAL_MODES, + example: 'separate', + }) + @IsIn(MAIL_VACATION_EXTERNAL_MODES, { + message: '회사 밖 응답을 다시 선택해 주세요.', + }) + mode!: MailVacationExternalMode; + + @ApiProperty({ + description: `회사 밖에 보낼 제목 — 켜고 따로 쓰기면 1~${MAX_VACATION_SUBJECT_LENGTH}자, 그 밖에는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지. 빈 글은 null 로 저장한다. 제어 문자는 쓸 수 없다`, + nullable: true, + type: String, + maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, + example: 'Out of office', + }) + @ValidateIf(unlessNull) + @IsString({ message: '회사 밖에 보낼 제목 형식이 올바르지 않습니다.' }) + @MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, { + message: `회사 밖에 보낼 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`, + }) + @Matches(SINGLE_LINE_TEXT_PATTERN, { + message: '회사 밖에 보낼 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.', + }) + subject!: string | null; + + @ApiProperty({ + description: `회사 밖에 보낼 내용(보이는 글) — 켜고 따로 쓰기면 1~${MAX_VACATION_TEXT_LENGTH}자, 그 밖에는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지. 빈 글은 null 로 저장한다`, + nullable: true, + type: String, + maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF, + example: 'I am out of the office until November 7.', + }) + @ValidateIf(unlessNull) + @IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' }) + @MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, { + message: `회사 밖에 보낼 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`, + }) + @Matches(MULTI_LINE_TEXT_PATTERN, { + message: + '회사 밖에 보낼 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.', + }) + textBody!: string | null; + + @ApiProperty({ + description: `회사 밖에 보낼 ${HTML_BODY_DESCRIPTION}`, + nullable: true, + type: String, + example: + '

I am out of the office until November 7.

', + }) + @ValidateIf(unlessNull) + @IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' }) + htmlBody!: string | null; +} + /** * 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계). - * 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). + * 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자 · 서식 10,000자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). * 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져 - * 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다. + * 켤 때만의 한도를 함께 걸 수 없다. + * 16단계의 htmlBody · external 은 보내지 않아도 된다 — 없으면 글만 · 사내와 같은 응답(회사 밖 글 칸 null)으로 저장한다(옛 화면이 보는 그대로) */ -export class MailVacationDto implements MailVacation { +export class MailVacationDto { @ApiProperty({ description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`, example: true, @@ -102,8 +178,8 @@ export class MailVacationDto implements MailVacation { @ApiProperty({ description: - `자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` + - `꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`, + `자동 응답 내용(보이는 글 — 서식을 뺀 글). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` + + `꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서식 본문(htmlBody)이 있으면 받는 쪽의 글 판이다. 서명은 붙지 않는다`, maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF, example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.', }) @@ -115,16 +191,52 @@ export class MailVacationDto implements MailVacation { message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.', }) textBody!: string; + + @ApiPropertyOptional({ + description: `${HTML_BODY_DESCRIPTION} (16단계 — 보내지 않으면 null)`, + nullable: true, + type: String, + example: + '

11월 7일까지 휴가입니다.

', + }) + @IsOptional() + @IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' }) + htmlBody?: string | null; + + @ApiPropertyOptional({ + description: + '회사 밖에서 온 메일에 하는 응답 (16단계) — 보내지 않거나 null 이면 사내와 같은 응답(글 칸 null)으로 저장한다', + type: MailVacationExternalDto, + }) + @IsOptional() + @IsObject({ message: '회사 밖 응답 형식이 올바르지 않습니다.' }) + @ValidateNested() + @Type(() => MailVacationExternalDto) + external?: MailVacationExternalDto | null; } /** * GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지. - * PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다 + * 16단계의 htmlBody · external 은 응답에 늘 있다. PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). + * 응답 문서용이라 검사 데코레이터를 더하지 않는다 */ export class MailVacationStateDto - extends MailVacationDto + extends OmitType(MailVacationDto, ['htmlBody', 'external'] as const) implements MailVacationState { + @ApiProperty({ + description: `${HTML_BODY_DESCRIPTION} — 늘 있다(없으면 null)`, + nullable: true, + type: String, + }) + htmlBody!: string | null; + + @ApiProperty({ + description: '회사 밖에서 온 메일에 하는 응답 — 늘 있다', + type: MailVacationExternalDto, + }) + external!: MailVacationExternalDto; + @ApiProperty({ description: 'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' + diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts index d61a2c6..f247cd3 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts @@ -775,4 +775,60 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => { } }); }); + + /** + * 쓰기 화면의 서명 자리(16단계 — 계약 2절 "앱이 만드는 모양") — 빈 문단 하나 뒤의 **속성 없는 div 하나**가 서명 경계다. + * 웹 편집기도 같은 경계로 서명을 넣고 임시 저장 · 이어 쓰기에서 다시 찾으므로, 보내는 정화 · 임시 저장본 다시 읽기 · 받은 메일 정화를 지나도 남아야 한다 + */ + describe('서명 자리 — 빈 문단 뒤 속성 없는 div (16단계)', () => { + const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + const signatureBlock = + '

김아라 · 인프라팀

' + + `
`; + const composed = + `
` + + '

안녕하세요.


' + + signatureBlock + + '
' + + '

원문

'; + /** 정화한 서명 자리 — 빈 문단 바로 뒤의 속성 없는 div, 안은 서명 */ + const keptBoundary = + '


김아라 · 인프라팀

' + + `
`; + + it('보낼 때 · 임시 저장할 때(인용 경계 표시를 남기는 정화)와 저장본을 다시 열 때(표시 없는 정화) 모두 경계 div 가 속성 없이 남는다', () => { + const ids = new Set([IMAGE_ID]); + for (const options of [ + { signatureImageIds: ids, keepQuoteStart: true }, + { signatureImageIds: ids }, + ]) { + const html = clean(composed, options); + expect(html).toContain(keptBoundary); + // 정화한 것을 다시 정화해도 같다 — 이어 쓰기를 거듭해도 경계가 그대로다 + expect(clean(html, { signatureImageIds: ids })).toBe( + clean(composed, { signatureImageIds: ids }), + ); + } + }); + + it('받은 메일 정화(보낸편지함 · 받는 사람이 다시 읽을 때)도 속성 없는 div 를 그대로 둔다', () => { + const sent = clean(composed, { signatureImageIds: new Set([IMAGE_ID]) }); + const { html } = sanitizeMailHtml(sent); + expect(html).toContain( + '


김아라 · 인프라팀

', + ); + }); + + it('속성이 모두 지워진 div 도 속성 없는 div 가 된다 — 경계는 자리(빈 문단 바로 뒤)로 알아본다 (화면 · 앱이 함께 보는 알려진 모양)', () => { + for (const div of [ + '
', + '
', + '
', + ]) { + expect( + clean(`


${div}

서명

`), + ).toBe('


서명

'); + } + }); + }); }); diff --git a/backend/src/modules/mail/html/sanitize-stored-html.spec.ts b/backend/src/modules/mail/html/sanitize-stored-html.spec.ts new file mode 100644 index 0000000..9d0c292 --- /dev/null +++ b/backend/src/modules/mail/html/sanitize-stored-html.spec.ts @@ -0,0 +1,65 @@ +import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants'; +import { sanitizeStoredHtml } from './sanitize-stored-html'; + +describe('sanitizeStoredHtml — 저장해 두는 짧은 서식 글(서명 · 부재중 응답)의 정화', () => { + const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + const image = ``; + + it('보내는 정화를 거친다 — 스크립트 · 이벤트 속성은 지우고, 그림은 넘겨준 서명 그림만 남긴다', () => { + expect( + sanitizeStoredHtml( + `

휴가

${image}`, + { maxLength: null }, + ), + ).toEqual({ ok: true, html: '

휴가

' }); + expect( + sanitizeStoredHtml(`

서명

${image}`, { + maxLength: null, + signatureImageIds: new Set([IMAGE_ID]), + }), + ).toEqual({ + ok: true, + html: `

서명

`, + }); + }); + + it('없거나 비었거나 정화해서 남는 것이 없으면 null', () => { + for (const html of [null, undefined, '', '', ' ']) { + expect(sanitizeStoredHtml(html, { maxLength: 10 })).toEqual({ + ok: true, + html: null, + }); + } + }); + + it('정화 뒤 길이는 maxLength 일 때만 본다 — 정화 전 길이 · 파서 비용은 늘 본다', () => { + const long = `

${'가'.repeat(10_000)}

`; + expect(sanitizeStoredHtml(long, { maxLength: 10_000 })).toEqual({ + ok: false, + problem: 'too-long', + length: 10_007, + }); + expect(sanitizeStoredHtml(long, { maxLength: null })).toEqual({ + ok: true, + html: long, + }); + expect( + sanitizeStoredHtml(`

${'가'.repeat(10_000 - 7)}

`, { + maxLength: 10_000, + }), + ).toMatchObject({ ok: true }); + + expect( + sanitizeStoredHtml('x'.repeat(MAX_BODY_HTML_LENGTH + 1), { + maxLength: null, + }), + ).toEqual({ + ok: false, + problem: 'too-long', + length: MAX_BODY_HTML_LENGTH + 1, + }); + expect( + sanitizeStoredHtml(''.repeat(166_666), { maxLength: null }), + ).toMatchObject({ ok: false, problem: 'too-complex' }); + }); +}); diff --git a/backend/src/modules/mail/html/sanitize-stored-html.ts b/backend/src/modules/mail/html/sanitize-stored-html.ts new file mode 100644 index 0000000..e859f2b --- /dev/null +++ b/backend/src/modules/mail/html/sanitize-stored-html.ts @@ -0,0 +1,54 @@ +import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants'; +import { checkHtmlParseBudget } from './html-parse-budget'; +import { sanitizeOutgoingHtml } from './sanitize-outgoing-html'; + +/** + * 저장해 두고 다시 쓰는 짧은 서식 글의 정화 (순수 함수) — 서명(계약 2절 "서명")과 부재중 응답의 서식 본문(16단계 — "부재중 자동 응답"). + * 쓰기 화면의 서식 본문과 같은 규칙이다: + * 1. 정화 전 — 길이(MAX_BODY_HTML_LENGTH — 500,000자)와 파서 비용(html-parse-budget.ts — 정화가 이벤트 루프를 오래 붙잡지 않게) + * 2. 보내는 정화(sanitize-outgoing-html.ts) — 그림은 `signatureImageIds` 에 든 그 사람의 서명 그림만 남는다(없으면 그림 없음 — 부재중) + * 3. 정화 뒤 — `maxLength`(JS 문자열 길이)를 넘으면 실패. null 이면 보지 않는다(부재중을 꺼 둔 채 저장할 때) + * 비었거나 정화해서 남는 것이 없으면 null 이다. 실패는 부르는 쪽이 400 VAL_001 로 던진다(사유만 — 글은 로그에 남기지 않는다) + */ + +/** too-long: 정화 전 · 뒤의 길이 한도 초과, too-complex: 파서 비용 한도 초과(깊은 중첩 등) */ +export type StoredHtmlProblem = 'too-long' | 'too-complex'; + +export type StoredHtmlResult = + | { readonly ok: true; readonly html: string | null } + | { + readonly ok: false; + readonly problem: StoredHtmlProblem; + /** 걸린 글의 길이 (JS 문자열 길이) — 로그용 */ + readonly length: number; + }; + +export interface StoredHtmlOptions { + /** 정화한 뒤의 최대 길이 — null 이면 보지 않는다 */ + readonly maxLength: number | null; + /** 남겨도 되는 서명 그림 id — 없으면 그림을 하나도 남기지 않는다 */ + readonly signatureImageIds?: ReadonlySet; +} + +export function sanitizeStoredHtml( + html: string | null | undefined, + options: StoredHtmlOptions, +): StoredHtmlResult { + if (!html) return { ok: true, html: null }; + if (html.length > MAX_BODY_HTML_LENGTH) { + return { ok: false, problem: 'too-long', length: html.length }; + } + if (!checkHtmlParseBudget(html).ok) { + return { ok: false, problem: 'too-complex', length: html.length }; + } + const sanitized = sanitizeOutgoingHtml( + html, + options.signatureImageIds + ? { signatureImageIds: options.signatureImageIds } + : {}, + ); + if (options.maxLength !== null && sanitized.length > options.maxLength) { + return { ok: false, problem: 'too-long', length: sanitized.length }; + } + return { ok: true, html: sanitized === '' ? null : sanitized }; +} diff --git a/backend/src/modules/mail/jmap/filter-source.spec.ts b/backend/src/modules/mail/jmap/filter-source.spec.ts index 8a78161..c8a6361 100644 --- a/backend/src/modules/mail/jmap/filter-source.spec.ts +++ b/backend/src/modules/mail/jmap/filter-source.spec.ts @@ -11,11 +11,14 @@ import type { import { FILTER_SOURCE_FORMAT, FILTER_SOURCE_FORMAT_WITH_BLOCKS, + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, FILTER_SOURCE_MARKER, LATEST_FILTER_SOURCE_FORMAT, + SAME_AS_INTERNAL_REPLY, filterRulesVersion, filterSourceLine, readFilterSource, + usesRichVacation, type FilterScriptSource, } from './filter-source'; @@ -46,6 +49,8 @@ const vacation: MailVacation = { toDate: null, subject: '부재중 ${x} "따옴표"', textBody: '첫 줄\r\n둘째 줄\n\t탭', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, }; const source: FilterScriptSource = { @@ -186,7 +191,9 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본 }); it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => { - expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS); + expect(LATEST_FILTER_SOURCE_FORMAT).toBe( + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + ); expect( readFilterSource( scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })), @@ -422,6 +429,153 @@ describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => { }); }); +describe('부재중의 서식 본문 · 회사 밖 응답(판 3 — 16단계)', () => { + const rich: MailVacation = { + ...vacation, + htmlBody: + '

11월 7일까지 휴가입니다.

', + external: { + mode: 'separate', + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: '

I am away until Nov 7.

', + }, + }; + const richSource: FilterScriptSource = { ...source, vacation: rich }; + const formatOf = (value: FilterScriptSource) => + ( + JSON.parse( + filterSourceLine(value).slice(FILTER_SOURCE_MARKER.length), + ) as Record + ).v; + const storedV3 = (overrides: Record = {}) => + stored({ + v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + vacation: rich, + blocked: [], + ...overrides, + }); + + it('새 칸을 쓴 직원만 판 3 — 서식 본문 · 사내와 다른 회사 밖 응답 · 써 둔 회사 밖 글 가운데 하나라도. 아니면 판 1 · 2 그대로', () => { + expect(LATEST_FILTER_SOURCE_FORMAT).toBe( + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + ); + expect(formatOf(source)).toBe(FILTER_SOURCE_FORMAT); + expect(usesRichVacation(vacation)).toBe(false); + + for (const changed of [ + { ...vacation, htmlBody: '

휴가

' }, + { ...vacation, external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' } }, + { + ...vacation, + external: { ...SAME_AS_INTERNAL_REPLY, mode: 'separate' }, + }, + // 사내와 같은 응답이어도 써 둔 회사 밖 글이 있으면 남겨야 한다 + { + ...vacation, + external: { ...SAME_AS_INTERNAL_REPLY, textBody: '써 둔 글' }, + }, + ] as MailVacation[]) { + expect(usesRichVacation(changed)).toBe(true); + expect(formatOf({ ...source, vacation: changed })).toBe( + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + ); + } + // 판 3 은 차단 목록 칸을 늘 담는다(비었어도) — 차단이 있으면 그대로 + const v3 = JSON.parse( + filterSourceLine(richSource).slice(FILTER_SOURCE_MARKER.length), + ) as Record; + expect(v3.blocked).toEqual([]); + expect(v3.vacation).toEqual(rich); + }); + + it('판 3 을 그대로 읽는다 — 차단 목록도. 판 1 · 2 는 글만 · 사내와 같은 응답으로', () => { + const blocked: BlockedSender[] = [ + { address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' }, + ]; + for (const value of [richSource, { ...richSource, blocked }]) { + expect(readFilterSource(scriptWith(filterSourceLine(value)))).toEqual({ + status: 'ok', + source: value, + }); + } + // 판 1 · 2 에 새 칸이 적혀 있어도 보지 않는다 — 그 판을 쓰는 서버는 그 칸을 모른다 + for (const v of [FILTER_SOURCE_FORMAT, FILTER_SOURCE_FORMAT_WITH_BLOCKS]) { + expect( + sourceOf(scriptWithJson(stored({ v, vacation: rich, blocked: [] }))) + ?.vacation, + ).toEqual({ + ...vacation, + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, + }); + } + }); + + it('판 3 의 서식 본문은 읽을 때 보내는 정화를 다시 거친다 — 다른 프로그램이 고친 원본의 스크립트 · 이벤트 속성 · 그림은 화면에 가지 않는다', () => { + const read = sourceOf( + scriptWithJson( + storedV3({ + vacation: { + ...rich, + htmlBody: + '

휴가

', + external: { + ...rich.external, + htmlBody: '
링크굵게', + }, + }, + }), + ), + ); + expect(read?.vacation.htmlBody).toBe('

휴가

'); + expect(read?.vacation.external.htmlBody).toBe( + '링크굵게', + ); + // 정화해서 비면 null — 글만 + expect( + sourceOf( + scriptWithJson( + storedV3({ vacation: { ...rich, htmlBody: '' } }), + ), + )?.vacation.htmlBody, + ).toBeNull(); + }); + + it.each<[string, Record]>([ + ['회사 밖 응답 없음', { ...rich, external: undefined }], + ['회사 밖 응답이 객체가 아님', { ...rich, external: 'same' }], + [ + '모르는 고르기', + { ...rich, external: { ...rich.external, mode: 'internal' } }, + ], + [ + '회사 밖 제목이 글이 아님', + { ...rich, external: { ...rich.external, subject: 1 } }, + ], + [ + '회사 밖 글 칸 없음', + { ...rich, external: { mode: 'same', subject: null, htmlBody: null } }, + ], + ['서식 본문이 글이 아님', { ...rich, htmlBody: 1 }], + ['서식 본문 칸 없음', { ...rich, htmlBody: undefined }], + [ + '정화할 수 없는 서식(깊은 중첩)', + { ...rich, htmlBody: ''.repeat(166_666) }, + ], + ])('판 3 의 부재중이 계약에 맞지 않으면 깨진 원본 — %s', (_label, value) => { + expect( + readFilterSource(scriptWithJson(storedV3({ vacation: value }))), + ).toEqual(BROKEN); + }); + + it('판 3 의 차단 목록은 판 2 와 같은 규칙 — 칸이 없으면 깨진 원본', () => { + expect( + readFilterSource(scriptWithJson(storedV3({ blocked: undefined }))), + ).toEqual(BROKEN); + }); +}); + describe('filterRulesVersion — 규칙의 지문(version)', () => { const rules = source.rules; diff --git a/backend/src/modules/mail/jmap/filter-source.ts b/backend/src/modules/mail/jmap/filter-source.ts index c7bcae1..2ce4410 100644 --- a/backend/src/modules/mail/jmap/filter-source.ts +++ b/backend/src/modules/mail/jmap/filter-source.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; +import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html'; import { parseBlockTarget } from '@/modules/mail/mail-blocks'; import { MAIL_FILTER_FIELDS, @@ -8,6 +9,7 @@ import { findFilterRulesProblem, } from '@/modules/mail/mail-filters'; import { + MAIL_VACATION_EXTERNAL_MODES, MAX_MAIL_BLOCKS, MAX_MAIL_FILTER_RULES, } from '@/modules/mail/mail.constants'; @@ -17,6 +19,7 @@ import type { MailFilterCondition, MailFilterRule, MailVacation, + MailVacationExternal, } from '@/modules/mail/mail.types'; import { toUtcIso } from './jmap.mapper'; @@ -43,12 +46,19 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: '; * - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다. * 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획: * 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다) + * - 3: 2 + 부재중의 서식 본문 `vacation.htmlBody` · 회사 밖 응답 `vacation.external` (16단계) — **새 칸을 쓴 직원만** 쓴다 + * (usesRichVacation — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우). 판 3 은 `blocked` 를 늘 담는다(비었어도). + * 판 3 을 아는 서버는 판 1 · 2 를 "글만 · 사내와 같은 응답"으로 읽는다. 판 3 을 모르는 서버로 되돌리면 새 칸을 쓴 직원만 + * 필터 · 부재중 · 차단 저장이 503 MAIL_001 로 막힌다 — 엔진의 응답(Sieve)은 그대로 돈다(계약 2절 "부재중 자동 응답"의 되돌리기 계획) */ export const FILTER_SOURCE_FORMAT = 1; /** 차단 목록이 든 원본의 판 (9단계) */ export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2; +/** 부재중의 서식 본문 · 회사 밖 응답이 든 원본의 판 (16단계) */ +export const FILTER_SOURCE_FORMAT_WITH_RICH_VACATION = 3; /** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */ -export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS; +export const LATEST_FILTER_SOURCE_FORMAT = + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION; /** * 스크립트 글에서 원본을 읽은 결과 @@ -69,6 +79,14 @@ export interface FilterScriptSource { readonly blocked: readonly BlockedSender[]; } +/** 회사 밖 응답의 처음 값 — 사내와 같은 응답, 따로 써 둔 글 없음 (16단계 — 판 1 · 2 의 원본 · 새 칸이 없는 옛 요청도 이것) */ +export const SAME_AS_INTERNAL_REPLY: MailVacationExternal = { + mode: 'same', + subject: null, + textBody: null, + htmlBody: null, +}; + /** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */ export const NEVER_CONFIGURED_VACATION: MailVacation = { isEnabled: false, @@ -76,8 +94,25 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = { toDate: null, subject: '', textBody: '', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, }; +/** + * 16단계의 새 칸을 쓰는가 — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우. 그때만 원본 판 3 으로 쓴다 + * (새 칸을 쓰지 않은 직원은 판 1 · 2 그대로 — 판 3 을 모르는 서버로 되돌려도 막히지 않는다) + */ +export function usesRichVacation(vacation: MailVacation): boolean { + const { external } = vacation; + return ( + vacation.htmlBody !== null || + external.mode !== 'same' || + external.subject !== null || + external.textBody !== null || + external.htmlBody !== null + ); +} + /** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */ export const EMPTY_FILTER_SOURCE: FilterScriptSource = { rules: [], @@ -102,7 +137,8 @@ function canonicalRule(rule: MailFilterRule): MailFilterRule { }; } -function canonicalVacation(vacation: MailVacation): MailVacation { +/** 판 1 · 2 의 부재중 — 다섯 칸 */ +function canonicalVacation(vacation: MailVacation) { return { isEnabled: vacation.isEnabled, fromDate: vacation.fromDate, @@ -112,6 +148,16 @@ function canonicalVacation(vacation: MailVacation): MailVacation { }; } +/** 판 3 의 부재중 — 다섯 칸 + 서식 본문 · 회사 밖 응답 */ +function canonicalRichVacation(vacation: MailVacation): MailVacation { + const { mode, subject, textBody, htmlBody } = vacation.external; + return { + ...canonicalVacation(vacation), + htmlBody: vacation.htmlBody, + external: { mode, subject, textBody, htmlBody }, + }; +} + /** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */ export function canonicalRules( rules: readonly MailFilterRule[], @@ -130,24 +176,28 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string { } /** - * 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다 + * 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 가장 낮은 판으로 쓴다 — 부재중의 새 칸(16단계)을 쓰면 판 3, + * 아니고 차단한 주소가 있으면 판 2, 둘 다 아니면 판 1(blocked 없이) — 낮은 판만 아는 서버도 읽는다 */ export function filterSourceLine(source: FilterScriptSource): string { - const base = { - rules: canonicalRules(source.rules), - vacation: canonicalVacation(source.vacation), - }; + const rules = canonicalRules(source.rules); + const blocked = source.blocked.map(({ address, blockedAt }) => ({ + address, + blockedAt, + })); + if (usesRichVacation(source.vacation)) { + return `${FILTER_SOURCE_MARKER}${JSON.stringify({ + v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + rules, + vacation: canonicalRichVacation(source.vacation), + blocked, + })}`; + } + const base = { rules, vacation: canonicalVacation(source.vacation) }; return `${FILTER_SOURCE_MARKER}${JSON.stringify( - source.blocked.length === 0 + blocked.length === 0 ? { v: FILTER_SOURCE_FORMAT, ...base } - : { - v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, - ...base, - blocked: source.blocked.map(({ address, blockedAt }) => ({ - address, - blockedAt, - })), - }, + : { v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, ...base, blocked }, )}`; } @@ -252,8 +302,9 @@ function readDate(raw: unknown): string | null | undefined { } /** - * 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고 - * (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다 + * 부재중(판 1 · 2) — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고 + * (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다. + * 16단계의 칸은 처음 값이다 — 글만 · 사내와 같은 응답 */ function toVacation(raw: unknown): MailVacation | null { if ( @@ -273,9 +324,52 @@ function toVacation(raw: unknown): MailVacation | null { toDate, subject: raw.subject, textBody: raw.textBody, + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, }; } +/** + * 원본의 서식 본문 — null 이면 null, 글이면 보내는 정화(그림 없음)를 **다시** 거친 값(비면 null), 정화할 수 없는 글(길이 · 파서 비용)이면 undefined(깨짐). + * 다시 정화하는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본의 HTML 이 화면 · 받는 사람에게 그대로 가지 않게 — 우리가 쓴 값은 이미 정화한 값이라 그대로다 + */ +function readVacationHtml(raw: unknown): string | null | undefined { + if (raw === null) return null; + if (typeof raw !== 'string') return undefined; + const sanitized = sanitizeStoredHtml(raw, { maxLength: null }); + return sanitized.ok ? sanitized.html : undefined; +} + +/** 회사 밖 응답(판 3) — 고르기는 셋 가운데 하나, 글 칸은 글 또는 null. 어긋나면 null(깨짐) */ +function toVacationExternal(raw: unknown): MailVacationExternal | null { + if ( + !isRecord(raw) || + !isOneOf(raw.mode, MAIL_VACATION_EXTERNAL_MODES) || + !isNullableString(raw.subject) || + !isNullableString(raw.textBody) + ) { + return null; + } + const htmlBody = readVacationHtml(raw.htmlBody); + if (htmlBody === undefined) return null; + return { + mode: raw.mode, + subject: raw.subject, + textBody: raw.textBody, + htmlBody, + }; +} + +/** 부재중(판 3) — 판 1 · 2 의 다섯 칸 + 서식 본문 · 회사 밖 응답. 어긋나면 null(깨짐) */ +function toRichVacation(raw: unknown): MailVacation | null { + const base = toVacation(raw); + if (base === null || !isRecord(raw)) return null; + const htmlBody = readVacationHtml(raw.htmlBody); + const external = toVacationExternal(raw.external); + if (htmlBody === undefined || external === null) return null; + return { ...base, htmlBody, external }; +} + /** * 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각. * 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다 @@ -313,8 +407,9 @@ const BROKEN: FilterSourceReading = { status: 'broken' }; /** * 스크립트 글에서 원본을 읽는다 (FilterSourceReading). * 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다. - * 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken. - * 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다 + * 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 · 3 은 차단 목록도, + * 판 3 은 부재중의 서식 본문(다시 정화) · 회사 밖 응답도) — 어긋나면 broken. + * 판 1 은 차단 없음, 판 1 · 2 는 부재중이 글만 · 사내와 같은 응답이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다 */ export function readFilterSource(script: string): FilterSourceReading { const line = findSourceLine(script); @@ -337,7 +432,8 @@ export function readFilterSource(script: string): FilterSourceReading { } if ( (format !== FILTER_SOURCE_FORMAT && - format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) || + format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS && + format !== FILTER_SOURCE_FORMAT_WITH_RICH_VACATION) || !Array.isArray(parsed.rules) || parsed.rules.length > MAX_MAIL_FILTER_RULES ) { @@ -353,12 +449,13 @@ export function readFilterSource(script: string): FilterSourceReading { if (findFilterRulesProblem(rules)) return BROKEN; const blocked = - format === FILTER_SOURCE_FORMAT_WITH_BLOCKS - ? toBlocked(parsed.blocked) - : []; + format === FILTER_SOURCE_FORMAT ? [] : toBlocked(parsed.blocked); if (blocked === null) return BROKEN; - const vacation = toVacation(parsed.vacation); + const vacation = + format === FILTER_SOURCE_FORMAT_WITH_RICH_VACATION + ? toRichVacation(parsed.vacation) + : toVacation(parsed.vacation); return vacation ? { status: 'ok', source: { rules, vacation, blocked } } : BROKEN; diff --git a/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts index 1f46445..a76393c 100644 --- a/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts @@ -190,6 +190,81 @@ describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword — expect(failure?.code).toBe('MAIL_001'); expect(JSON.stringify(failure?.context)).not.toContain('secret details'); }); + + it('휴대폰 바꾸기(16단계) — 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청, updated → replaced · forbidden → code-rejected · 결과를 모르면 unknown', async () => { + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { updated: { singleton: null } }, + 's', + ]), + ); + await expect( + provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH), + ).resolves.toBe('replaced'); + expect(lastCalls(callAccountChange)).toEqual([ + [ + 'x:AccountPassword/set', + { + accountId: 'x1', + update: { + singleton: { + currentSecret: 'pw', + otpAuth: { otpCode: '123456', otpUrl: OTPAUTH }, + }, + }, + }, + 's', + ], + ]); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { + notUpdated: { + singleton: { + type: 'forbidden', + description: 'Current secret is incorrect.', + }, + }, + }, + 's', + ]), + ); + await expect( + provider.replaceTwoFactor(ARA, 'pw', '000000', OTPAUTH), + ).resolves.toBe('code-rejected'); + + const warn = jest.spyOn(Logger.prototype, 'warn'); + callAccountChange.mockResolvedValueOnce({ + kind: 'unknown', + detail: 'timeout', + }); + await expect( + provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH), + ).resolves.toBe('unknown'); + const logged = warn.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('가를 수 없어 MAIL_001'); + expect(logged).not.toMatch(/123456|otpauth|pw\b/); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { notUpdated: { singleton: { type: 'serverFail' } } }, + 's', + ]), + ); + expect( + (await failureOf(provider.replaceTwoFactor(ARA, 'pw', '1', OTPAUTH))) + ?.context, + ).toMatchObject({ + reason: 'two-factor-replace-refused', + jmapType: 'serverFail', + }); + }); }); describe('앱 비밀번호', () => { diff --git a/backend/src/modules/mail/jmap/jmap-account-security.provider.ts b/backend/src/modules/mail/jmap/jmap-account-security.provider.ts index 05592e6..224e050 100644 --- a/backend/src/modules/mail/jmap/jmap-account-security.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-account-security.provider.ts @@ -8,6 +8,7 @@ import type { CreatedAppPassword, TwoFactorDisableResult, TwoFactorEnableResult, + TwoFactorReplaceResult, } from '@/modules/account-security/account-security.provider'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; @@ -59,6 +60,8 @@ function toIso(value: unknown): string | null { * - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나 * 이미 켜져 있으면(지금 코드를 요구) forbidden * - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect." + * - 휴대폰 바꾸기(16단계): 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청 → updated(옛 비밀은 바로 죽는다). + * 코드 없음 · 틀린 코드 · 틀린 비밀번호 모두 forbidden (2026-11-10 개발 엔진 확인) * - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다 * 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만) */ @@ -139,6 +142,33 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider { return outcome === 'forbidden' ? 'code-rejected' : 'unknown'; } + async replaceTwoFactor( + user: string, + currentPassword: string, + otpCode: string, + otpauthUrl: string, + ): Promise { + // 켠 채 지금 코드와 새 주소를 한 요청으로 — 엔진이 지금 코드를 확인하고 새 비밀로 바꾼다(옛 비밀은 바로 죽는다 — 2026-11-10 확인) + const reply = await this.jmap.callAccountChange(user, (accountId) => [ + [ + PASSWORD_SET, + { + accountId, + update: { + [PASSWORD_OBJECT_ID]: { + currentSecret: currentPassword, + otpAuth: { otpCode, otpUrl: otpauthUrl }, + }, + }, + }, + 's', + ], + ]); + const outcome = this.passwordUpdate(user, reply, 'two-factor-replace'); + if (outcome === 'updated') return 'replaced'; + return outcome === 'forbidden' ? 'code-rejected' : 'unknown'; + } + async listAppPasswords(user: string): Promise { const responses = await this.jmap.callAccount(user, (accountId) => [ [ @@ -251,7 +281,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider { ): 'updated' | 'forbidden' | 'unknown' { if (reply.kind === 'unknown') { this.logger.warn( - `[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`, + `[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`, ); return 'unknown'; } @@ -275,7 +305,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider { if (failure?.type === 'forbidden') return 'forbidden'; if (!failure) { this.logger.warn( - `[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`, + `[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}`, ); return 'unknown'; } @@ -285,6 +315,13 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider { }); } + /** 결과를 모를 때 부르는 쪽이 하는 일 — 켜기 · 끄기는 다시 읽어 맞추고, 휴대폰 바꾸기는 가릴 수 없어 MAIL_001 로 답한다 */ + private unknownFollowUp(call: string): string { + return call === 'two-factor-replace' + ? '엔진이 otpUrl 을 가려 다시 읽어도 가를 수 없어 MAIL_001 로 답합니다' + : '엔진을 다시 읽어 맞춥니다'; + } + /** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */ private createRefusal(error: JmapError): BusinessException { if (error.type === 'overQuota') { diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts index bc8dd9f..4c21004 100644 --- a/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.spec.ts @@ -674,6 +674,147 @@ describe('JmapContactsProvider (엔진 주소록)', () => { expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/); }); + it('가져오기(allowPartialCreate) — 받지 않은 만들기는 던지지 않고 refusedKeys 로, 받은 것만 createdIds · 카드 모양 (warn 로그는 개수 · 종류만)', async () => { + engine.setReply = (args) => { + const create = args.create as Record>; + expect(Object.keys(create)).toEqual(['import-0', 'import-1']); + return [ + 'ContactCard/set', + { + newState: 's11', + created: { 'import-0': { id: 'n7' } }, + notCreated: { + 'import-1': { + type: 'invalidProperties', + description: 'Invalid 박지훈 jihoon@x', + }, + }, + updated: { g: null }, + }, + 'w', + ]; + }; + + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { + create: [ + { + kind: 'contact', + key: 'import-0', + uid: 'urn:uuid:a', + fields: { ...newPerson, name: '받음' }, + }, + { + kind: 'contact', + key: 'import-1', + uid: 'urn:uuid:b', + fields: { ...newPerson, name: '박지훈' }, + }, + ], + update: [ + { + kind: 'group', + id: 'g', + memberUids: ['urn:uuid:a', 'urn:uuid:b'], + }, + ], + destroy: [], + }, + { allowPartialCreate: true }, + ); + + if (outcome.kind !== 'written') throw new Error('written 이 아니다'); + expect(outcome.createdIds).toEqual(new Map([['import-0', 'n7']])); + expect(outcome.refusedKeys).toEqual(new Set(['import-1'])); + expect([...outcome.cards.keys()]).toEqual(['n7']); + expect(outcome.version).toBe(version('book', 's11')); + const logged = warnSpy.mock.calls + .map(([message]) => String(message)) + .join('\n'); + expect(logged).toContain('받지 않음 1/2'); + expect(logged).toContain('jmapType=invalidProperties'); + expect(logged).not.toMatch(/박지훈|jihoon|Invalid|ara\.kim@/); + }); + + it('가져오기라도 고치기 · 지우기의 거절은 지금처럼 던진다 · 모두 받으면 refusedKeys 는 비어 있다', async () => { + engine.setReply = () => [ + 'ContactCard/set', + { + newState: 's11', + created: { 'import-0': { id: 'n7' } }, + notUpdated: { g: { type: 'invalidProperties' } }, + }, + 'w', + ]; + const changes: AddressBookChanges = { + create: [ + { + kind: 'contact', + key: 'import-0', + uid: 'urn:uuid:a', + fields: newPerson, + }, + ], + update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }], + destroy: [], + }; + const error = await failureOf( + provider.writeAddressBook(ARA, version('book', 's10'), changes, { + allowPartialCreate: true, + }), + ); + expect(error.code).toBe('VAL_001'); + + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...changes, update: [] }, + { allowPartialCreate: true }, + ); + if (outcome.kind !== 'written') throw new Error('written 이 아니다'); + expect(outcome.refusedKeys).toEqual(new Set()); + expect(outcome.createdIds).toEqual(new Map([['import-0', 'n1']])); + }); + + it('한꺼번에 지우기(withoutOriginals) — 지울 카드는 id 만 읽어 있는지 보고, 원본은 주지 않는다', async () => { + engine.add({ + id: 'c1', + uid: 'urn:uuid:c1', + name: { full: '하나' }, + photos: { p: { href: 'https://x.example/p.jpg' } }, + } as JmapContactCard); + + const outcome = await provider.writeAddressBook( + ARA, + version('book', 's10'), + { ...noChanges, destroy: ['c1'] }, + { withoutOriginals: true }, + ); + + const [read] = engine.requests; + expect(read?.calls).toEqual([ + [ + 'ContactCard/get', + { accountId: 'acc', ids: ['c1'], properties: ['id'] }, + 'd', + ], + ]); + if (outcome.kind !== 'written') throw new Error('written 이 아니다'); + expect(outcome.removed.size).toBe(0); + expect(engine.cardOf('c1')).toBeUndefined(); + + // 그 판에 없는 카드면 conflict (보내지 않는다) + const missing = await provider.writeAddressBook( + ARA, + version('book', 's11'), + { ...noChanges, destroy: ['c1'] }, + { withoutOriginals: true }, + ); + expect(missing).toEqual({ kind: 'conflict' }); + }); + it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => { const original = { source: JSON.stringify({ diff --git a/backend/src/modules/mail/jmap/jmap-contacts.provider.ts b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts index 782d507..82c57bc 100644 --- a/backend/src/modules/mail/jmap/jmap-contacts.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-contacts.provider.ts @@ -7,6 +7,7 @@ import type { AddressBookContactCard, AddressBookGroupCard, AddressBookSnapshot, + AddressBookWriteOptions, AddressBookWriteOutcome, ContactsProvider, RemovedCardOriginal, @@ -49,6 +50,9 @@ const READ_PAGE_SIZE = 500; */ export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000; +/** 원본이 필요 없는 지우기(한꺼번에 지우기 — 16단계)에서 지울 카드가 있는지만 볼 때 읽는 칸 (id 는 늘 온다) */ +const EXISTENCE_PROPERTIES = ['id']; + /** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */ const CARD_PROPERTIES = [ 'uid', @@ -118,6 +122,8 @@ function pickAddressBook(books: readonly JmapAddressBook[]): string | null { * 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy. * 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/` 패치를 받지 않는다 — 그래서 uid 는 만들 때 * 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인) + * - 가져오기(16단계 — allowPartialCreate): 받지 않은 만들기는 던지지 않고 refusedKeys 로 알린다(받은 것은 들어갔다). 한 요청에 300명 90ms(2026-11-10 확인), + * 엔진의 한 요청 상한(maxObjectsInSet 500)은 서비스가 맞춘다. 한꺼번에 지우기(withoutOriginals)는 지울 카드를 id 만 읽는다 * - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만) */ @Injectable() @@ -211,6 +217,7 @@ export class JmapContactsProvider implements ContactsProvider { user: string, version: string, changes: AddressBookChanges, + options: AddressBookWriteOptions = {}, ): Promise { const { addressBookId, state } = decodeVersion(version); if (changes.create.length > 0 && addressBookId === null) { @@ -230,6 +237,7 @@ export class JmapContactsProvider implements ContactsProvider { change.kind === 'contact' ? [change.id] : [], ), changes.destroy, + options.withoutOriginals === true, ); if (current === null) return { kind: 'conflict' }; const removed = current.originals; @@ -287,8 +295,14 @@ export class JmapContactsProvider implements ContactsProvider { this.warnBusy(user, 'set-error'); return { kind: 'busy', removed }; } + // 가져오기(16단계) — 받지 않은 만들기는 던지지 않고 적는다 (고치기 · 지우기의 거절은 아래에서 지금처럼 던진다) + const refusedKeys = options.allowPartialCreate + ? this.refusedCreates(user, set, changes) + : new Set(); const refusal = - firstSetError(set.notCreated) ?? + (options.allowPartialCreate + ? undefined + : firstSetError(set.notCreated)) ?? firstSetError(set.notUpdated) ?? firstSetError(set.notDestroyed); if (refusal) { @@ -318,6 +332,7 @@ export class JmapContactsProvider implements ContactsProvider { const createdIds = new Map(); for (const card of changes.create) { + if (refusedKeys.has(card.key)) continue; const id = set.created?.[card.key]?.id; if (typeof id !== 'string' || id === '') { throw new BusinessException('MAIL_001', { @@ -337,11 +352,35 @@ export class JmapContactsProvider implements ContactsProvider { createdIds, cards, removed, + ...(options.allowPartialCreate ? { refusedKeys } : {}), }; } + /** + * 엔진이 받지 않은 만들기의 key (가져오기 — 16단계). 우리가 보낸 key 만 센다. 있으면 warn 로그 — 개수 · 엔진 오류 종류만 + * (카드 내용 · 엔진 설명 문장은 남기지 않는다) + */ + private refusedCreates( + user: string, + set: JmapSetResult, + changes: AddressBookChanges, + ): Set { + const sent = new Set(changes.create.map((card) => card.key)); + const refused = new Set( + Object.keys(set.notCreated ?? {}).filter((key) => sent.has(key)), + ); + const first = firstSetError(set.notCreated); + if (refused.size > 0) { + this.logger.warn( + `[${mapJmapErrorType(first?.type)}]${traceTag()} 엔진이 가져오기의 일부 연락처를 받지 않았습니다 — 받은 것만 넣었습니다: user=${maskEmail(user)}, jmapType=${first?.type ?? 'unknown'}, 받지 않음 ${refused.size}/${changes.create.length}`, + ); + } + return refused; + } + /** * 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나. + * withoutOriginals 면 지울 카드는 있는지만 본다(id 만 — 사진이 든 카드 500장을 통째로 읽지 않게, 원본은 주지 않는다). * 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다) */ private async readCurrentCards( @@ -349,6 +388,7 @@ export class JmapContactsProvider implements ContactsProvider { state: string, updateIds: readonly string[], destroyIds: readonly string[], + withoutOriginals: boolean, ): Promise<{ cards: ReadonlyMap>; originals: ReadonlyMap; @@ -370,8 +410,12 @@ export class JmapContactsProvider implements ContactsProvider { ? [ [ 'ContactCard/get', - // 되돌리기 원본 — 다른 프로그램의 칸까지 모두 - { accountId, ids: [...destroyIds], properties: null }, + // 되돌리기 원본 — 다른 프로그램의 칸까지 모두 (원본이 필요 없으면 있는지만) + { + accountId, + ids: [...destroyIds], + properties: withoutOriginals ? EXISTENCE_PROPERTIES : null, + }, 'd', ] as JmapInvocation, ] @@ -396,9 +440,11 @@ export class JmapContactsProvider implements ContactsProvider { if (updating === null || destroying === null) return null; return { cards: updating, - originals: new Map( - [...destroying].map(([id, card]) => [id, cardOriginal(card)]), - ), + originals: withoutOriginals + ? new Map() + : new Map( + [...destroying].map(([id, card]) => [id, cardOriginal(card)]), + ), }; } diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts index a0f48bf..2352177 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.spec.ts @@ -7,7 +7,10 @@ import type { MailVacation, } from '@/modules/mail/mail.types'; import { + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, + FILTER_SOURCE_MARKER, NEVER_CONFIGURED_VACATION, + SAME_AS_INTERNAL_REPLY, filterRulesVersion, readFilterSource, type FilterScriptSource, @@ -57,6 +60,8 @@ const vacationOn: MailVacation = { toDate: '2026-11-07T15:00:00.000Z', subject: '부재중입니다', textBody: '11월 7일까지 휴가입니다.', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, }; interface FakeScript { @@ -139,8 +144,12 @@ class FakeSieveEngine { ), }; + /** 회사 메일 도메인 설정 (MAIL_SETTINGS 의 domain) — 부재중의 회사 밖 응답을 가를 때 */ + readonly settings: { domain: string | null } = { domain: 'arasystem.test' }; + readonly store = new JmapFilterScriptStore( this.client as unknown as JmapClient, + this.settings, ); /** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */ @@ -341,12 +350,15 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 version: filterRulesVersion([]), isActive: true, }); + // 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값) await expect(engine.store.getVacation(ARA)).resolves.toEqual({ isEnabled: true, fromDate: '2026-11-02T15:00:00.000Z', toDate: null, subject: '4단계 부재중', textBody: '휴가입니다.', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, isActive: true, }); expect(engine.recorded[0]).toEqual({ @@ -392,6 +404,8 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 toDate: '2026-11-07T15:00:00.000Z', subject: '', textBody: '', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, }; await expect(engine.store.getVacation(ARA)).resolves.toEqual({ @@ -532,7 +546,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 context: { reason: 'filter-source-newer', format: 99, - supportedFormat: 2, + supportedFormat: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, }, }); } @@ -1060,6 +1074,55 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 expect(engine.ours()?.isActive).toBe(true); }); + it('회사 밖 따로 쓰기 · 서식 본문(16단계) — 회사 메일 도메인으로 봉투 보낸 사람을 가르는 글을 올리고, 원본은 판 3', async () => { + const engine = new FakeSieveEngine(); + engine.settings.domain = 'arasystem.it.kr'; + const rich: MailVacation = { + ...vacationOn, + htmlBody: '

11월 7일까지 휴가입니다.

', + external: { + mode: 'separate', + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: null, + }, + }; + + await expect(engine.store.setVacation(ARA, rich)).resolves.toEqual({ + ...rich, + isActive: true, + }); + const text = engine.blobs.get(engine.ours()!.blobId) ?? ''; + expect(text).toContain( + 'if envelope :domain :is "from" "arasystem.it.kr" {', + ); + expect(text).toContain('multipart/alternative'); + expect(text).toContain(':handle "ara-external-'); + expect(text.split('\r\n')[1]).toContain( + `${FILTER_SOURCE_MARKER}{"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION},`, + ); + expect(engine.ourSource()?.vacation).toEqual(rich); + await expect(engine.store.getVacation(ARA)).resolves.toEqual({ + ...rich, + isActive: true, + }); + }); + + it('회사 메일 도메인 설정이 없으면 직원 주소의 도메인으로 가른다 (로그인한 직원은 늘 그 도메인이다)', async () => { + const engine = new FakeSieveEngine(); + engine.settings.domain = null; + + await engine.store.setVacation(ARA, { + ...vacationOn, + external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' }, + }); + const text = engine.blobs.get(engine.ours()!.blobId) ?? ''; + expect(text).toContain( + 'if envelope :domain :is "from" "arasystem.test" {', + ); + expect(text).not.toContain('else'); + }); + it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => { const engine = new FakeSieveEngine(); engine.addScript('vacation', 'require ["vacation"];\r\n', true); diff --git a/backend/src/modules/mail/jmap/jmap-filter-script.ts b/backend/src/modules/mail/jmap/jmap-filter-script.ts index 29a3c34..0fed9ea 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-script.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-script.ts @@ -1,7 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { BlockedSender, MailBlockAdded, @@ -121,7 +122,10 @@ interface ScriptSnapshot extends ScriptView { readonly newerFormat: number | null; } -/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */ +/** + * 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수, 부재중의 켬 · 서식 있음 · 회사 밖 고르기만 + * (규칙의 글 · 차단한 주소 · 부재중 글은 남기지 않는다) + */ function scriptSummary( script: BuiltFilterScript, source: FilterScriptSource, @@ -131,10 +135,17 @@ function scriptSummary( rules: source.rules.length, enabledRules: source.rules.filter((rule) => rule.isEnabled).length, vacation: source.vacation.isEnabled, + vacationHtml: source.vacation.htmlBody !== null, + vacationExternal: source.vacation.external.mode, blocked: source.blocked.length, }; } +/** 사용자 주소의 도메인 (소문자) — 회사 메일 도메인 설정이 없을 때만 쓴다(로그인한 직원은 늘 그 도메인이다) */ +function domainOf(user: string): string { + return user.slice(user.lastIndexOf('@') + 1).toLowerCase(); +} + function toFilterSet(view: ScriptView): MailFilterSet { return { rules: canonicalRules(view.source.rules), @@ -175,7 +186,12 @@ function toBlockList(view: ScriptView): MailBlockList { export class JmapFilterScriptStore { private readonly logger = new Logger(JmapFilterScriptStore.name); - constructor(private readonly jmap: JmapClient) {} + constructor( + private readonly jmap: JmapClient, + /** 회사 메일 도메인 — 부재중의 회사 밖 응답(16단계)을 봉투 보낸 사람의 도메인으로 가를 때 */ + @Inject(MAIL_SETTINGS) + private readonly settings: Pick, + ) {} /** 지금 규칙 · 지문 · 동작하는지 */ async getFilters(user: string): Promise { @@ -407,7 +423,10 @@ export class JmapFilterScriptStore { if (next.blocked.length > 0 && junkMailbox === null) { junkMailbox = await this.junkMailbox(user); } - const script = buildFilterScript(next, { junkMailbox }); + const script = buildFilterScript(next, { + junkMailbox, + internalDomain: this.settings.domain ?? domainOf(user), + }); if ( script.bytes > FILTER_SCRIPT_MAX_BYTES || script.longestStringBytes > SIEVE_STRING_MAX_BYTES @@ -429,7 +448,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 ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`, + `${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}(서식 ${next.vacation.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${next.vacation.external.mode}), 차단 ${next.blocked.length}개, ${attempt}번째`, ); return { source: next, isActive: saved.isActive }; } diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 55614ee..0142933 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -18,6 +18,7 @@ import { import type { MailSettings } from '@/modules/mail/mail.settings'; import type { DraftContent, + MailVacation, MailWatch, MailWatchListener, OutgoingMessage, @@ -198,7 +199,10 @@ function fakeClient( client as unknown as JmapClient, cache, overrides.settings ?? settings, - new JmapFilterScriptStore(client as unknown as JmapClient), + new JmapFilterScriptStore( + client as unknown as JmapClient, + overrides.settings ?? settings, + ), new JmapFilterRunner(client as unknown as JmapClient), new JmapConversations(client as unknown as JmapClient), ), @@ -8024,12 +8028,15 @@ describe('JmapMailProvider', () => { textBody: '11월 7일까지 휴가입니다.', htmlBody: null, }; - const vacation = { + // 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값) + const vacation: MailVacation = { isEnabled: true, fromDate: '2026-11-02T15:00:00.000Z', toDate: '2026-11-07T15:00:00.000Z', subject: '부재중입니다', textBody: '11월 7일까지 휴가입니다.', + htmlBody: null, + external: { mode: 'same', subject: null, textBody: null, htmlBody: null }, }; beforeEach(() => { jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts index 809ca84..a1ffde2 100644 --- a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts @@ -133,6 +133,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { toDate: null, subject: '', textBody: '', + htmlBody: null, + external: { mode: 'same', subject: null, textBody: null, htmlBody: null }, isActive: true, }; filterScripts = { @@ -899,12 +901,15 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { match: 'always', actions: { forwardTo: `doyun.lee@${DOMAIN}` }, }); + // 운영자의 안내 글은 글만 · 회사 안팎 모두에게 (16단계 칸은 처음 값) expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, { isEnabled: true, fromDate: null, toDate: null, subject: '부재 안내', textBody: '퇴사했습니다.', + htmlBody: null, + external: { mode: 'same', subject: null, textBody: null, htmlBody: null }, }); filterScripts.getFilters.mockResolvedValue({ @@ -912,12 +917,20 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { version: 'v2', isActive: true, }); + const external = { + mode: 'separate' as const, + subject: 'Left the company', + textBody: 'Please contact doyun.', + htmlBody: null, + }; filterScripts.getVacation.mockResolvedValue({ isEnabled: true, fromDate: null, toDate: null, subject: '부재 안내', textBody: '퇴사했습니다.', + htmlBody: '

퇴사했습니다.

', + external, isActive: true, }); await expect(engine.getLockRules(ARA)).resolves.toEqual({ @@ -929,10 +942,16 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { rules: [saved.rules[1]], version: 'v2', }); - expect(filterScripts.setVacation).toHaveBeenLastCalledWith( - ARA, - expect.objectContaining({ isEnabled: false, subject: '부재 안내' }), - ); + // 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 (isActive 는 넘기지 않는다) + expect(filterScripts.setVacation).toHaveBeenLastCalledWith(ARA, { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '부재 안내', + textBody: '퇴사했습니다.', + htmlBody: '

퇴사했습니다.

', + external, + }); }); it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => { diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.ts index 129b1f9..a70a51c 100644 --- a/backend/src/modules/mail/jmap/jmap-ops-engine.ts +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.ts @@ -529,12 +529,20 @@ export class JmapOpsEngine implements OpsEngine { }); } if (rules.autoReply !== null) { + // 운영자의 안내 글 — 글만, 회사 안팎 모두에게 (16단계의 서식 본문 · 회사 밖 응답은 쓰지 않는다) await this.filterScripts.setVacation(email, { isEnabled: true, fromDate: null, toDate: null, subject: rules.autoReply.subject, textBody: rules.autoReply.body, + htmlBody: null, + external: { + mode: 'same', + subject: null, + textBody: null, + htmlBody: null, + }, }); } } @@ -549,12 +557,15 @@ export class JmapOpsEngine implements OpsEngine { } const vacation = await this.filterScripts.getVacation(email); if (vacation.isEnabled) { + // 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 둔다 await this.filterScripts.setVacation(email, { isEnabled: false, fromDate: vacation.fromDate, toDate: vacation.toDate, subject: vacation.subject, textBody: vacation.textBody, + htmlBody: vacation.htmlBody, + external: vacation.external, }); } } diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 96db141..629e5fd 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -355,7 +355,8 @@ export function toUsage(quota: JmapQuota): MailUsage { * 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다 * (4단계에 저장한 값 이어받기 — jmap-filter-script.ts). * 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은 - * `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다. + * `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null, external: 사내와 같은 응답 }` 가 된다. + * 엔진의 서식 본문(htmlBody)은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값 — filter-source.ts 의 SAME_AS_INTERNAL_REPLY 와 같다) */ export function toVacation( response: JmapVacationResponse | undefined, @@ -366,6 +367,8 @@ export function toVacation( toDate: toUtcIso(response?.toDate), subject: response?.subject ?? '', textBody: response?.textBody ?? '', + htmlBody: null, + external: { mode: 'same', subject: null, textBody: null, htmlBody: null }, }; } diff --git a/backend/src/modules/mail/jmap/mime-text.spec.ts b/backend/src/modules/mail/jmap/mime-text.spec.ts index a56054d..1400e7a 100644 --- a/backend/src/modules/mail/jmap/mime-text.spec.ts +++ b/backend/src/modules/mail/jmap/mime-text.spec.ts @@ -1,5 +1,6 @@ import { startCpuTimer } from '@/../test/cpu-time'; import { + alternativeMimeEntity, base64TextBody, decodeHeaderText, encodeHeaderWords, @@ -97,6 +98,68 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => { }); }); +describe('alternativeMimeEntity — 부재중 서식 본문 (16단계, 글 + 서식 두 벌)', () => { + /** 개체의 부분 — 경계로 나눠 머리 · base64 본문을 푼다 */ + const partsOf = (entity: string) => { + const boundary = /boundary="([^"]+)"/.exec(entity)?.[1] ?? ''; + return entity + .split(`--${boundary}`) + .slice(1, -1) + .map((part) => { + const [head, body] = part.replace(/^\r\n/, '').split('\r\n\r\n'); + return { + head, + text: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString( + 'utf8', + ), + }; + }); + }; + + it('multipart/alternative — 글(text/plain) 다음 서식(text/html), 각 base64 · utf-8, 닫는 경계 뒤 줄바꿈 (엔진이 그대로 보낸 모양 — engine-facts E2)', () => { + const entity = alternativeMimeEntity( + '11월 7일까지 휴가입니다.\n급한 일은 이도윤 님께.', + '

11월 7일까지 휴가입니다.

', + ); + const lines = entity.split('\r\n'); + + expect(lines.slice(0, 3)).toEqual([ + 'Content-Type: multipart/alternative; boundary="ara-vacation-alternative"', + '', + '--ara-vacation-alternative', + ]); + expect(entity.endsWith('\r\n--ara-vacation-alternative--\r\n')).toBe(true); + expect(partsOf(entity)).toEqual([ + { + head: 'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64', + text: '11월 7일까지 휴가입니다.\r\n급한 일은 이도윤 님께.', + }, + { + head: 'Content-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64', + text: '

11월 7일까지 휴가입니다.

', + }, + ]); + }); + + it('ASCII 만 나오고 경계는 본문 줄과 겹치지 않는다 — 글 · 서식의 ${ · 역슬래시 · 따옴표 · 경계와 같은 글도 base64 안에만 있다', () => { + const entity = alternativeMimeEntity( + '${x} \\ "따옴표" --ara-vacation-alternative', + '

${x} --ara-vacation-alternative

', + ); + expect(entity).toMatch(/^[\x20-\x7e\r\n]*$/); + expect(entity).not.toContain('${'); + expect(entity).not.toContain('\\'); + // 경계 줄은 셋뿐이다(여는 둘 · 닫는 하나) + expect( + entity.split('\r\n').filter((line) => line.startsWith('--')), + ).toEqual([ + '--ara-vacation-alternative', + '--ara-vacation-alternative', + '--ara-vacation-alternative--', + ]); + }); +}); + /** * 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정). * 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값) diff --git a/backend/src/modules/mail/jmap/mime-text.ts b/backend/src/modules/mail/jmap/mime-text.ts index 0a1b164..4b45d1f 100644 --- a/backend/src/modules/mail/jmap/mime-text.ts +++ b/backend/src/modules/mail/jmap/mime-text.ts @@ -1,6 +1,6 @@ /** * 메일 머리글 · 본문의 글 모양 (순수 함수). - * - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문. + * - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문(서식이 있으면 글 + 서식 두 벌 — 16단계). * 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다 * (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인). * - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts) @@ -84,6 +84,36 @@ export function plainTextMimeEntity(text: string): string { ].join(CRLF); } +/** + * 글 + 서식 두 벌의 경계 (RFC 2046 §5.1.1) — base64 글자에 없는 `-` 가 들어 있어 본문 줄과 겹치지 않는다. + * 늘 같은 값이라 같은 부재중이면 같은 스크립트 글이 된다 (다시 올리지 않고 쓴다 — jmap-filter-script.ts) + */ +const ALTERNATIVE_BOUNDARY = 'ara-vacation-alternative'; + +/** + * 글 + 서식 두 벌의 MIME 개체 (16단계 — 부재중 서식 본문) — `multipart/alternative` 에 글(text/plain) · 서식(text/html) 차례로, 각 base64. + * 서식을 읽지 못하는 메일 프로그램은 글만 보인다. 엔진이 이 개체를 그대로 보낸다(2026-11-10 개발 엔진 — engine-facts E2). + * ASCII 로만 나오므로 Sieve 문자열에 넣어도 `${` · 따옴표 · 역슬래시가 생기지 않는다(경계의 따옴표는 Sieve 쪽이 이스케이프한다) + */ +export function alternativeMimeEntity(text: string, html: string): string { + return [ + `Content-Type: multipart/alternative; boundary="${ALTERNATIVE_BOUNDARY}"`, + '', + `--${ALTERNATIVE_BOUNDARY}`, + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Transfer-Encoding: base64', + '', + base64TextBody(text), + `--${ALTERNATIVE_BOUNDARY}`, + 'Content-Type: text/html; charset="utf-8"', + 'Content-Transfer-Encoding: base64', + '', + base64TextBody(html), + `--${ALTERNATIVE_BOUNDARY}--`, + '', + ].join(CRLF); +} + /** * encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다 * (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다 diff --git a/backend/src/modules/mail/jmap/sieve-script.spec.ts b/backend/src/modules/mail/jmap/sieve-script.spec.ts index b37822d..358f3b5 100644 --- a/backend/src/modules/mail/jmap/sieve-script.spec.ts +++ b/backend/src/modules/mail/jmap/sieve-script.spec.ts @@ -4,13 +4,16 @@ import type { MailVacation, } from '@/modules/mail/mail.types'; import { + FILTER_SOURCE_FORMAT_WITH_RICH_VACATION, FILTER_SOURCE_MARKER, NEVER_CONFIGURED_VACATION, + SAME_AS_INTERNAL_REPLY, filterSourceLine, readFilterSource, type FilterScriptSource, } from './filter-source'; -import { buildFilterScript } from './sieve-script'; +import { alternativeMimeEntity, plainTextMimeEntity } from './mime-text'; +import { SIEVE_STRING_MAX_BYTES, buildFilterScript } from './sieve-script'; const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); @@ -38,6 +41,39 @@ const vacationOn: MailVacation = { toDate: '2026-11-07T15:00:00.000Z', subject: '부재중입니다', textBody: '휴가입니다.\n급한 일은 이도윤 님께.', + htmlBody: null, + external: SAME_AS_INTERNAL_REPLY, +}; + +/** 개발 엔진과 같은 회사 메일 도메인 */ +const DOMAIN = 'arasystem.test'; + +/** Sieve 따옴표 문자열을 푼다 — `\"` · `\\` 만 */ +const unquote = (quoted: string) => quoted.replace(/\\(["\\])/g, '$1'); + +/** 스크립트의 `set "이름" "값";` 들 — 이름 → 값 */ +const variablesOf = (text: string): Map => + new Map( + [...text.matchAll(/set "(part\d+)" "((?:[^"\\]|\\.)*)";/g)].map( + ([, name, value]) => [name, unquote(value)], + ), + ); + +/** 스크립트의 vacation 명령들 — 태그 · MIME 개체(변수로 이었으면 이은 값) */ +const vacationCommandsOf = (text: string) => { + const variables = variablesOf(text); + return [ + ...text.matchAll( + /vacation :mime (?::handle "([^"]*)" )?(?::subject "([^"]*)" )?"((?:[^"\\]|\\.)*)";/g, + ), + ].map(([, handle, subject, argument]) => ({ + handle: handle ?? null, + subject: subject ?? null, + entity: unquote(argument).replace( + /\$\{(part\d+)\}/g, + (_whole, name: string) => variables.get(name) ?? '', + ), + })); }; /** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */ @@ -296,6 +332,236 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', ( expect(sourceOf(text)?.vacation).toEqual(off); }); + it('서식 본문이 있으면 글 + 서식 두 벌(multipart/alternative) — 사내와 같은 응답이면 봉투 검사 · :handle 없이 하나 (16단계)', () => { + const html = + '

휴가입니다. 급한 일은 이도윤 님께.

'; + const { text } = buildFilterScript({ + rules: [], + vacation: { + ...vacationOn, + fromDate: null, + toDate: null, + htmlBody: html, + }, + blocked: [], + }); + + expect(linesOf(text)[2]).toBe('require ["vacation"];'); + expect(text).not.toContain('envelope'); + expect(text).not.toContain(':handle'); + expect(vacationCommandsOf(text)).toEqual([ + { + handle: null, + subject: `=?UTF-8?B?${b64('부재중입니다')}?=`, + entity: alternativeMimeEntity(vacationOn.textBody, html), + }, + ]); + // 원본은 판 3 — 서식 본문을 쓴 직원만 + expect(sourceOf(text)?.vacation.htmlBody).toBe(html); + expect(linesOf(text)[1]).toContain( + `"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION}`, + ); + }); + + it('회사 밖 따로 쓰기 — 봉투 보낸 사람의 도메인으로 가르고(envelope :domain :is), 회사 밖 응답은 :handle 을 따로 둔다', () => { + const source: FilterScriptSource = { + rules: [], + vacation: { + ...vacationOn, + external: { + mode: 'separate', + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: '

I am away until Nov 7.

', + }, + }, + blocked: [], + }; + const { text } = buildFilterScript(source, { internalDomain: DOMAIN }); + const lines = linesOf(text); + + expect(lines[2]).toBe( + 'require ["date", "envelope", "relational", "vacation"];', + ); + expect(lines.slice(3, 6)).toEqual([ + 'if not header :matches "X-Spam-Score" "spam*" {', + ' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {', + ' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {', + ]); + expect(lines[6]).toBe( + ` if envelope :domain :is "from" "${DOMAIN}" {`, + ); + expect(lines[7]).toMatch(/^ {8}vacation :mime :subject /); + expect(lines).toContain(' } else {'); + expect(lines.slice(-5)).toEqual([' }', ' }', ' }', '}', '']); + + const [internal, external] = vacationCommandsOf(text); + expect(internal).toEqual({ + handle: null, + subject: `=?UTF-8?B?${b64('부재중입니다')}?=`, + entity: plainTextMimeEntity(vacationOn.textBody), + }); + expect(external.subject).toBe(`=?UTF-8?B?${b64('Out of office')}?=`); + expect(external.entity).toBe( + alternativeMimeEntity( + 'I am away until Nov 7.', + '

I am away until Nov 7.

', + ), + ); + expect(external.handle).toMatch(/^ara-external-[A-Za-z0-9_-]{16}$/); + expect(sourceOf(text)?.vacation).toEqual(source.vacation); + }); + + it('회사 밖 :handle 은 응답 내용의 지문 — 같은 글이면 같고, 글이 바뀌면 바뀐다', () => { + const handleOf = (subject: string) => + vacationCommandsOf( + buildFilterScript( + { + rules: [], + vacation: { + ...vacationOn, + external: { + mode: 'separate', + subject, + textBody: 'away', + htmlBody: null, + }, + }, + blocked: [], + }, + { internalDomain: DOMAIN }, + ).text, + )[1].handle; + + expect(handleOf('Away')).toBe(handleOf('Away')); + expect(handleOf('Away')).not.toBe(handleOf('Away!')); + }); + + it('회사 밖 보내지 않기 — 사내 도메인일 때만 답한다(else 없음), 회사 밖에 써 둔 글은 원본에만', () => { + const vacation: MailVacation = { + ...vacationOn, + fromDate: null, + toDate: null, + external: { + mode: 'none', + subject: '써 둔 제목', + textBody: '써 둔 글', + htmlBody: null, + }, + }; + const { text } = buildFilterScript( + { rules: [], vacation, blocked: [] }, + { internalDomain: DOMAIN }, + ); + + expect(linesOf(text).slice(2)).toEqual([ + 'require ["envelope", "vacation"];', + 'if not header :matches "X-Spam-Score" "spam*" {', + ` if envelope :domain :is "from" "${DOMAIN}" {`, + expect.stringMatching(/^ {4}vacation :mime :subject /) as string, + 'Content-Transfer-Encoding: base64', + '', + b64('휴가입니다.\r\n급한 일은 이도윤 님께.'), + '";', + ' }', + '}', + '', + ]); + expect(text).not.toContain('else'); + expect(vacationCommandsOf(text)).toHaveLength(1); + expect(sourceOf(text)?.vacation.external).toEqual(vacation.external); + }); + + it('회사 밖 응답을 가르는데 회사 메일 도메인을 받지 못하면 만들지 않는다 — 꺼져 있거나 사내와 같으면 필요 없다', () => { + const external = { + mode: 'separate' as const, + subject: 'x', + textBody: 'y', + htmlBody: null, + }; + const source = (isEnabled: boolean): FilterScriptSource => ({ + rules: [], + vacation: { ...vacationOn, isEnabled, external }, + blocked: [], + }); + expect(() => buildFilterScript(source(true))).toThrow(); + expect(() => + buildFilterScript(source(true), { internalDomain: null }), + ).toThrow(); + expect(() => buildFilterScript(source(false))).not.toThrow(); + expect(() => + buildFilterScript({ rules: [], vacation: vacationOn, blocked: [] }), + ).not.toThrow(); + }); + + it('MIME 개체가 문자열 하나 한도(4,096바이트)를 넘으면 variables 조각(3,000바이트 이하 · 줄 끝에서 끊음)으로 나눠 잇는다 — 이으면 원래 개체 그대로', () => { + const textBody = + '휴가 중이라 메일을 늦게 확인합니다. 급한 일은 담당자에게 연락해 주세요. ' + .repeat(14) + .slice(0, 600); + const html = `
${textBody + .split('. ') + .map( + (sentence) => + `

${sentence}. 담당자

`, + ) + .join('')}
`; + const built = buildFilterScript( + { + rules: [], + vacation: { + ...vacationOn, + textBody, + htmlBody: html, + external: { + mode: 'separate', + subject: 'Away', + textBody, + htmlBody: html, + }, + }, + blocked: [], + }, + { internalDomain: DOMAIN }, + ); + const variables = variablesOf(built.text); + + expect(linesOf(built.text)[2]).toBe( + 'require ["date", "envelope", "relational", "vacation", "variables"];', + ); + // 사내 · 회사 밖 모두 나뉘었다 — 변수 이름은 스크립트 안에서 겹치지 않는다 + expect(variables.size).toBeGreaterThanOrEqual(4); + expect([...variables.keys()]).toEqual( + [...variables.keys()].map((_name, index) => `part${index}`), + ); + for (const piece of variables.values()) { + expect(Buffer.byteLength(piece, 'utf8')).toBeLessThanOrEqual(3_000); + // 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 가 없다 + expect(piece.endsWith('\r\n')).toBe(true); + expect(piece.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/); + } + const [internal, external] = vacationCommandsOf(built.text); + expect(internal.entity).toBe(alternativeMimeEntity(textBody, html)); + expect(external.entity).toBe(alternativeMimeEntity(textBody, html)); + expect(built.longestStringBytes).toBeLessThanOrEqual( + SIEVE_STRING_MAX_BYTES, + ); + // 사용자 글의 ${ 는 Sieve 에 없다 — 우리 변수만 펼친다 + expect( + [...built.text.matchAll(/\$\{([^}]*)\}/g)].map(([, name]) => name), + ).toEqual([...variables.keys()].map((name) => name)); + }); + + it('문자열 하나 한도 안이면 지금처럼 한 문자열 — 글만인 응답(한글 600자)은 나누지 않는다', () => { + const built = buildFilterScript({ + rules: [], + vacation: { ...vacationOn, textBody: '가'.repeat(600) }, + blocked: [], + }); + expect(built.text).not.toContain('variables'); + expect(built.text).not.toContain('set "'); + }); + it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => { const { text } = buildFilterScript({ rules: [], diff --git a/backend/src/modules/mail/jmap/sieve-script.ts b/backend/src/modules/mail/jmap/sieve-script.ts index 4e92e1d..860362d 100644 --- a/backend/src/modules/mail/jmap/sieve-script.ts +++ b/backend/src/modules/mail/jmap/sieve-script.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto'; + import type { BlockedSender, MailFilterCondition, @@ -7,7 +9,11 @@ import type { } from '@/modules/mail/mail.types'; import { filterSourceLine, type FilterScriptSource } from './filter-source'; import { toJmapUtcDate } from './jmap.mapper'; -import { encodeHeaderWords, plainTextMimeEntity } from './mime-text'; +import { + alternativeMimeEntity, + encodeHeaderWords, + plainTextMimeEntity, +} from './mime-text'; /** * 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양): @@ -22,10 +28,17 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text'; * } * if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다 * 부재중 — 켜져 있으면 (기간은 currentdate, UTC) + * 회사 밖 응답이 사내와 같으면 vacation 하나, 아니면(16단계) if envelope :domain :is "from" "<회사 도메인>" { 사내 } else { 회사 밖 } — 보내지 않기면 else 없음 * 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop } * } * ``` * + * - 부재중의 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다(16단계) — 답장도 봉투 주소로만 가므로(엔진 확인 — engine-facts E2) + * 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다. 도메인은 그 도메인만(`:is` — 하위 도메인은 회사 밖) + * - 부재중 응답의 본문은 MIME 개체 하나(서식이 있으면 multipart/alternative — 글 + 서식, 각 base64)다. 엔진은 Sieve 문자열 하나를 4,096바이트로 막으므로 + * 그보다 길면 `variables` 로 3,000바이트 이하의 조각에 나눠 담고(`set "part0" "…";`) `vacation :mime "${part0}${part1}…"` 로 잇는다 + * (엔진 확인 — 이은 답장의 글 · 서식이 원래와 바이트까지 같다). 조각은 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 는 문법 밖이다 + * * - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다. * 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고, * id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절) @@ -90,6 +103,28 @@ export interface JunkMailboxTarget { export interface FilterScriptOptions { /** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */ readonly junkMailbox?: JunkMailboxTarget | null; + /** + * 회사 메일 도메인(`MAIL_DOMAIN`, 소문자) — 켠 부재중의 회사 밖 응답이 사내와 같지 않으면(16단계) 있어야 한다. 없으면 만들지 않는다(던진다) + */ + readonly internalDomain?: string | null; +} + +/** + * 부재중 응답 MIME 개체를 나눌 조각 크기 (UTF-8 바이트) — 엔진의 문자열 하나 한도(SIEVE_STRING_MAX_BYTES)보다 넉넉히 작게. + * 2026-11-10 개발 엔진: 3,000바이트 조각을 이은 응답이 원래와 바이트까지 같았다 (engine-facts E2) + */ +const VACATION_CHUNK_BYTES = 3_000; + +/** 회사 밖 응답의 :handle 앞부분 — 뒤에 응답 내용의 지문을 붙인다(내용이 바뀌면 같은 사람에게도 새 응답을 다시 보낸다 — 엔진 기본과 같은 결) */ +const EXTERNAL_REPLY_HANDLE_PREFIX = 'ara-external-'; +/** :handle 지문의 길이 (base64url 글자) */ +const REPLY_HANDLE_HASH_LENGTH = 16; + +/** 부재중 응답 하나 — 제목 · 보이는 글 · 서식(없으면 null) */ +interface VacationReply { + readonly subject: string; + readonly textBody: string; + readonly htmlBody: string | null; } /** @@ -108,23 +143,27 @@ type SieveExtension = | 'body' | 'copy' | 'date' + | 'envelope' | 'fileinto' | 'imap4flags' | 'mailbox' | 'mailboxid' | 'relational' - | 'vacation'; + | 'vacation' + | 'variables'; const EXTENSION_ORDER: readonly SieveExtension[] = [ 'body', 'copy', 'date', + 'envelope', 'fileinto', 'imap4flags', 'mailbox', 'mailboxid', 'relational', 'vacation', + 'variables', ]; /** 만든 스크립트 */ @@ -136,14 +175,20 @@ export interface BuiltFilterScript { readonly longestStringBytes: number; } -/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */ +/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */ +function sieveQuoted(value: string): string { + return `"${value.replace(/[\\"]/g, '\\$&')}"`; +} + +/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열 · 변수 이름을 모은다 */ class ScriptWriter { readonly extensions = new Set(); longestStringBytes = 0; + private variables = 0; /** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */ quote(value: string): string { - const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`; + const quoted = sieveQuoted(value); this.longestStringBytes = Math.max( this.longestStringBytes, Buffer.byteLength(quoted, 'utf8'), @@ -154,6 +199,13 @@ class ScriptWriter { use(...extensions: SieveExtension[]): void { for (const extension of extensions) this.extensions.add(extension); } + + /** 조각을 담을 새 변수 이름 — 스크립트 안에서 겹치지 않게 차례로 (part0, part1, …) */ + nextVariable(): string { + const name = `part${this.variables}`; + this.variables += 1; + return name; + } } function indent(depth: number, line: string): string { @@ -219,20 +271,106 @@ function ruleBlock( ]; } +/** + * MIME 개체를 줄 끝(CRLF) 뒤에서 끊어 maxBytes 이하의 조각으로 — 이으면 원래 글이다. 한 줄이 maxBytes 보다 길면 그 줄만 글자 경계에서 자른다 + * (우리 개체는 ASCII — 머리 · base64 줄은 76자라 그럴 일이 없다) + */ +function splitAtLineEnds(text: string, maxBytes: number): string[] { + const pieces: string[] = []; + let current = ''; + let currentBytes = 0; + for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) { + const lineBytes = Buffer.byteLength(line, 'utf8'); + if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) { + pieces.push(current); + current = ''; + currentBytes = 0; + } + if (lineBytes > maxBytes) { + for (const character of line) { + const characterBytes = Buffer.byteLength(character, 'utf8'); + if (currentBytes + characterBytes > maxBytes) { + pieces.push(current); + current = ''; + currentBytes = 0; + } + current += character; + currentBytes += characterBytes; + } + continue; + } + current += line; + currentBytes += lineBytes; + } + if (current !== '') pieces.push(current); + return pieces; +} + +/** 회사 밖 응답의 :handle — 응답 내용(제목 · MIME 개체)의 지문. 사내 응답과 늘 다르다 */ +function externalReplyHandle(subject: string, entity: string): string { + const digest = createHash('sha256') + .update(`${subject}\n${entity}`, 'utf8') + .digest('base64url') + .slice(0, REPLY_HANDLE_HASH_LENGTH); + return `${EXTERNAL_REPLY_HANDLE_PREFIX}${digest}`; +} + +/** + * 응답 하나 — `vacation :mime [:handle "…"] [:subject "…"] "";`. 서식이 있으면 글 + 서식 두 벌(multipart/alternative), 없으면 글만(지금과 같다). + * 개체가 문자열 하나 한도를 넘으면 앞에 조각 `set` 들을 두고 `"${part0}${part1}…"` 로 잇는다(variables). + * 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다 + */ +function vacationReplyLines( + writer: ScriptWriter, + reply: VacationReply, + side: 'internal' | 'external', +): string[] { + writer.use('vacation'); + const subject = encodeHeaderWords(reply.subject); + const entity = + reply.htmlBody === null + ? plainTextMimeEntity(reply.textBody) + : alternativeMimeEntity(reply.textBody, reply.htmlBody); + + const lines: string[] = []; + let argument: string; + if ( + Buffer.byteLength(sieveQuoted(entity), 'utf8') <= SIEVE_STRING_MAX_BYTES + ) { + argument = writer.quote(entity); + } else { + writer.use('variables'); + const names = splitAtLineEnds(entity, VACATION_CHUNK_BYTES).map((piece) => { + const name = writer.nextVariable(); + lines.push(`set ${writer.quote(name)} ${writer.quote(piece)};`); + return name; + }); + argument = writer.quote(names.map((name) => `\${${name}}`).join('')); + } + // 사내 응답은 :handle 을 쓰지 않는다(엔진 기본 — 15단계까지와 같은 글이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다) + const handle = + side === 'external' + ? `:handle ${writer.quote(externalReplyHandle(subject, entity))} ` + : ''; + lines.push( + `vacation :mime ${handle}${subject ? `:subject ${writer.quote(subject)} ` : ''}${argument};`, + ); + return lines; +} + /** * 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다), * 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word. - * `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격). - * 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다 + * `:days` 는 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격). + * 16단계 — 회사 밖 응답이 사내와 같지 않으면 봉투 보낸 사람의 도메인으로 가른다: 회사 도메인이면 사내 응답, 아니면 따로 쓴 응답(separate) · 없음(none). + * 회사 밖 응답은 `:handle` 을 따로 둔다(사내 응답과 같은 사람에게 다시 보내는 간격을 따로 센다) */ function vacationBlock( writer: ScriptWriter, vacation: MailVacation, depth: number, + internalDomain: string | null, ): string[] { - writer.use('vacation'); - const subject = encodeHeaderWords(vacation.subject); - const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`; /** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */ const period = (relation: 'ge' | 'le', iso: string) => `currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`; @@ -243,10 +381,54 @@ function vacationBlock( ]; if (periods.length > 0) writer.use('date', 'relational'); + const inner = depth + periods.length; + const internalReply: VacationReply = { + subject: vacation.subject, + textBody: vacation.textBody, + htmlBody: vacation.htmlBody, + }; + const { external } = vacation; + const replies: string[] = []; + if (external.mode === 'same') { + replies.push( + ...vacationReplyLines(writer, internalReply, 'internal').map((line) => + indent(inner, line), + ), + ); + } else { + if (!internalDomain) { + throw new Error('회사 밖 응답을 가르려면 회사 메일 도메인이 있어야 한다'); + } + writer.use('envelope'); + replies.push( + indent( + inner, + `if envelope :domain :is "from" ${writer.quote(internalDomain)} {`, + ), + ...vacationReplyLines(writer, internalReply, 'internal').map((line) => + indent(inner + 1, line), + ), + ); + if (external.mode === 'separate') { + const externalReply: VacationReply = { + subject: external.subject ?? '', + textBody: external.textBody ?? '', + htmlBody: external.htmlBody, + }; + replies.push( + indent(inner, '} else {'), + ...vacationReplyLines(writer, externalReply, 'external').map((line) => + indent(inner + 1, line), + ), + ); + } + replies.push(indent(inner, '}')); + } + const lines = periods.map((test, level) => indent(depth + level, `if ${test} {`), ); - lines.push(indent(depth + periods.length, command)); + lines.push(...replies); for (let level = periods.length - 1; level >= 0; level -= 1) { lines.push(indent(depth + level, '}')); } @@ -295,7 +477,8 @@ function blockSection( /** * 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES). - * 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001) + * 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001). + * 켠 부재중의 회사 밖 응답이 사내와 같지 않은데 회사 메일 도메인을 받지 못해도 던진다 (어댑터가 늘 넘긴다) */ export function buildFilterScript( source: FilterScriptSource, @@ -310,7 +493,14 @@ export function buildFilterScript( } const body: string[] = []; if (source.vacation.isEnabled) { - body.push(...vacationBlock(writer, source.vacation, 1)); + body.push( + ...vacationBlock( + writer, + source.vacation, + 1, + options.internalDomain ?? null, + ), + ); } for (const rule of source.rules) { if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1)); diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index 68730ee..e4199ee 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -1,3 +1,5 @@ +import type { MailVacationExternalMode } from './mail.types'; + /** * 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다. */ @@ -83,6 +85,20 @@ export const MAX_VACATION_TEXT_LENGTH = 600; export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200; export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000; +/** + * 부재중 응답의 서식 본문(16단계) 최대 길이 — 보내는 정화(그림 없음)를 거친 뒤의 JS 문자열 길이. 서명과 같은 한도(SIGNATURE_HTML_MAX_LENGTH). + * 켤 때만 본다(회사 밖 서식 본문은 따로 쓰기일 때만) — 넘으면 400 VAL_001. 정화 전 길이 · 파서 비용은 늘 본다(html/sanitize-stored-html.ts). + * 엔진에는 base64 로 들어가고 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 variables 조각으로 나눈다 (jmap/sieve-script.ts) + */ +export const MAX_VACATION_HTML_LENGTH = 10_000; + +/** 회사 밖 응답의 고르기 (16단계 — 계약 2절 "부재중 자동 응답") */ +export const MAIL_VACATION_EXTERNAL_MODES = [ + 'same', + 'none', + 'separate', +] as const satisfies readonly MailVacationExternalMode[]; + /** * 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절) */ diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 32982cc..8fccb3c 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -874,19 +874,37 @@ describe('MailService', () => { subject: '부재중입니다', textBody: '11월 7일까지 휴가입니다.', }; - - it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => { + /** 16단계 칸이 없는 옛 요청이 저장되는 값 — 글만 · 사내와 같은 응답 */ + const TEXT_ONLY = { + htmlBody: null, + external: { + mode: 'same' as const, + subject: null, + textBody: null, + htmlBody: null, + }, + }; + const echo = () => provider.setVacation.mockImplementation((_user, value) => Promise.resolve({ ...value, isActive: true }), ); + it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답, 로그에는 켬 · 끔 · 서식 · 회사 밖 고르기만', async () => { + echo(); + await expect(service.updateVacation(user, vacation)).resolves.toEqual({ ...vacation, + ...TEXT_ONLY, isActive: true, }); - expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation); + expect(provider.setVacation).toHaveBeenCalledWith(user.email, { + ...vacation, + ...TEXT_ONLY, + }); const [line] = logSpy.mock.calls[0] as [string]; expect(line).toContain('켬'); + expect(line).toContain('서식 없음'); + expect(line).toContain('회사 밖 same'); expect(line).not.toContain(vacation.subject); expect(line).not.toContain(vacation.textBody); expect(line).not.toContain(user.email); @@ -911,6 +929,7 @@ describe('MailService', () => { toDate: null, subject: '', textBody: '', + ...TEXT_ONLY, }; provider.setVacation.mockResolvedValue({ ...neverConfigured, @@ -934,9 +953,7 @@ describe('MailService', () => { } expect(provider.setVacation).not.toHaveBeenCalled(); - provider.setVacation.mockImplementation((_user, value) => - Promise.resolve({ ...value, isActive: true }), - ); + echo(); await service.updateVacation(user, { ...vacation, isEnabled: false, @@ -950,9 +967,10 @@ describe('MailService', () => { expect( provider.setVacation.mock.calls.map(([, value]) => value), ).toEqual([ - { ...vacation, isEnabled: false, subject: ' ' }, + { ...vacation, ...TEXT_ONLY, isEnabled: false, subject: ' ' }, { ...vacation, + ...TEXT_ONLY, subject: ' 부재중입니다 ', textBody: '\n휴가입니다.\n', }, @@ -990,9 +1008,7 @@ describe('MailService', () => { textLength: vacation.textBody.length, }); - provider.setVacation.mockImplementation((_user, value) => - Promise.resolve({ ...value, isActive: true }), - ); + echo(); // 한도까지는 켜도 받는다 const full = { ...vacation, @@ -1001,6 +1017,7 @@ describe('MailService', () => { }; await expect(service.updateVacation(user, full)).resolves.toEqual({ ...full, + ...TEXT_ONLY, isActive: true, }); // 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다 @@ -1012,6 +1029,7 @@ describe('MailService', () => { }; await expect(service.updateVacation(user, off)).resolves.toEqual({ ...off, + ...TEXT_ONLY, isActive: true, }); expect(provider.setVacation).toHaveBeenCalledTimes(2); @@ -1029,9 +1047,7 @@ describe('MailService', () => { ).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' }); expect(provider.setVacation).not.toHaveBeenCalled(); - provider.setVacation.mockImplementation((_user, value) => - Promise.resolve({ ...value, isActive: true }), - ); + echo(); await service.updateVacation(user, { ...vacation, fromDate: '2026-11-02T15:00:00.999Z', @@ -1039,6 +1055,7 @@ describe('MailService', () => { }); expect(provider.setVacation).toHaveBeenCalledWith(user.email, { ...vacation, + ...TEXT_ONLY, fromDate: '2026-11-02T15:00:00.000Z', toDate: '2026-11-02T15:00:01.000Z', }); @@ -1054,13 +1071,174 @@ describe('MailService', () => { } expect(provider.setVacation).not.toHaveBeenCalled(); - provider.setVacation.mockImplementation((_user, value) => - Promise.resolve({ ...value, isActive: true }), - ); + echo(); await service.updateVacation(user, { ...vacation, toDate: null }); await service.updateVacation(user, { ...vacation, fromDate: null }); expect(provider.setVacation).toHaveBeenCalledTimes(2); }); + + describe('서식 본문 · 회사 밖 응답 (16단계)', () => { + const external = { + mode: 'separate' as const, + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: '

I am away until Nov 7.

', + }; + + it('서식 본문은 보내는 정화(그림 없음)를 거쳐 넘긴다 — 정화해서 비면 null(글만), 회사 밖 칸도 같다. 로그에는 서식 있음 · 고르기만', async () => { + echo(); + + const saved = await service.updateVacation(user, { + ...vacation, + htmlBody: + '

11월 7일까지 휴가입니다.

', + external: { + ...external, + htmlBody: '', + }, + }); + + expect(saved).toEqual({ + ...vacation, + htmlBody: '

11월 7일까지 휴가입니다.

', + external: { ...external, htmlBody: null }, + isActive: true, + }); + // 서식에 그림은 없다 — 서명 그림 주소도 정화가 지운다(그 사람의 그림 목록을 읽지 않는다) + expect(signatures.imageIdsOf).not.toHaveBeenCalled(); + const [line] = logSpy.mock.calls[0] as [string]; + expect(line).toContain('서식 있음'); + expect(line).toContain('회사 밖 separate'); + expect(line).not.toContain('Out of office'); + expect(line).not.toContain('휴가'); + }); + + it('회사 밖 글 칸의 빈 글은 null 로 — 다른 고르기여도 써 둔 글은 그대로 넘긴다, external 이 null 이면 사내와 같은 응답', async () => { + echo(); + + await service.updateVacation(user, { + ...vacation, + external: { ...external, mode: 'none' }, + }); + await service.updateVacation(user, { + ...vacation, + external: { + mode: 'same', + subject: '', + textBody: 'x', + htmlBody: '', + }, + }); + await service.updateVacation(user, { ...vacation, external: null }); + + expect( + provider.setVacation.mock.calls.map(([, value]) => value.external), + ).toEqual([ + { ...external, mode: 'none' }, + { mode: 'same', subject: null, textBody: 'x', htmlBody: null }, + TEXT_ONLY.external, + ]); + }); + + it('따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 본다 — 비면 · 150 · 600자를 넘으면 VAL_001, 끈 채로 · 다른 고르기면 비어도 저장한다', async () => { + for (const [changed, reason] of [ + [{ subject: null }, 'vacation-external-without-text'], + [{ textBody: ' ' }, 'vacation-external-without-text'], + [ + { subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) }, + 'vacation-external-text-too-long', + ], + [ + { textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) }, + 'vacation-external-text-too-long', + ], + ] as const) { + expect( + await failureOf(() => + service.updateVacation(user, { + ...vacation, + external: { ...external, ...changed }, + }), + ), + ).toEqual({ code: 'VAL_001', reason }); + } + expect(provider.setVacation).not.toHaveBeenCalled(); + + echo(); + const empty = { subject: null, textBody: null, htmlBody: null }; + await service.updateVacation(user, { + ...vacation, + isEnabled: false, + external: { mode: 'separate', ...empty }, + }); + for (const mode of ['same', 'none'] as const) { + await service.updateVacation(user, { + ...vacation, + external: { mode, ...empty }, + }); + } + expect(provider.setVacation).toHaveBeenCalledTimes(3); + }); + + it('켤 때 정화한 서식은 10,000자까지 — 넘으면 VAL_001(회사 밖은 따로 쓰기일 때만), 정화 전 비용은 늘 본다', async () => { + const long = `

${'가'.repeat(10_000)}

`; + expect( + await failureOf(() => + service.updateVacation(user, { ...vacation, htmlBody: long }), + ), + ).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' }); + expect( + await failureOf(() => + service.updateVacation(user, { + ...vacation, + external: { ...external, htmlBody: long }, + }), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'vacation-external-html-too-long', + }); + for (const isEnabled of [true, false]) { + expect( + await failureOf(() => + service.updateVacation(user, { + ...vacation, + isEnabled, + htmlBody: ''.repeat(166_666), + }), + ), + ).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-complex' }); + } + expect( + await failureOf(() => + service.updateVacation(user, { + ...vacation, + isEnabled: false, + htmlBody: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1), + }), + ), + ).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' }); + expect(provider.setVacation).not.toHaveBeenCalled(); + + echo(); + // 끈 채로는 · 다른 고르기의 회사 밖 서식은 10,000자를 넘어도 저장한다(켤 때 본다) + await service.updateVacation(user, { + ...vacation, + isEnabled: false, + htmlBody: long, + }); + await service.updateVacation(user, { + ...vacation, + external: { ...external, mode: 'none', htmlBody: long }, + }); + // 경계 — 정화한 결과가 정확히 10,000자면 켜도 받는다 + await service.updateVacation(user, { + ...vacation, + htmlBody: `

${'가'.repeat(10_000 - 7)}

`, + }); + expect(provider.setVacation).toHaveBeenCalledTimes(3); + }); + }); }); it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => { @@ -1814,6 +1992,13 @@ describe('MailService', () => { toDate: null, subject: '부재중입니다', textBody: '휴가입니다.', + htmlBody: '

휴가입니다.

', + external: { + mode: 'none' as const, + subject: null, + textBody: null, + htmlBody: null, + }, isActive: false, }; provider.getVacation.mockResolvedValue(state); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 3f92fe5..3bfef02 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -64,6 +64,7 @@ import { highlightTermsOf } from '@/modules/mail-search/search-plan'; import { checkHtmlParseBudget } from './html/html-parse-budget'; import { sanitizeMailHtml } from './html/sanitize-mail-html'; import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html'; +import { sanitizeStoredHtml } from './html/sanitize-stored-html'; import { SIGNATURE_IMAGE_PATH_PREFIX, findSignatureImageIds, @@ -78,6 +79,7 @@ import { MAX_BODY_LENGTH, MAX_BODY_VALUE_BYTES, MAX_RECIPIENTS, + MAX_VACATION_HTML_LENGTH, MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_TEXT_LENGTH, SCHEDULED_SEND_MAX_LEAD_MS, @@ -248,6 +250,34 @@ function assertHtmlBytes(html: string): void { } } +/** + * 부재중 응답의 서식 본문(16단계) — 보내는 정화(그림 없음)를 거친 값, 없거나 비면 null. 정화 전 길이 · 파서 비용은 늘 보고, + * 정화 뒤 MAX_VACATION_HTML_LENGTH 는 `enforceLength` 일 때만(켤 때 — 회사 밖은 따로 쓰기일 때) 본다. 넘으면 VAL_001 (사유 · 길이만 — 글은 남기지 않는다) + */ +function vacationHtml( + raw: string | null | undefined, + enforceLength: boolean, + reason: 'vacation-html' | 'vacation-external-html', +): string | null { + const sanitized = sanitizeStoredHtml(raw, { + maxLength: enforceLength ? MAX_VACATION_HTML_LENGTH : null, + }); + if (!sanitized.ok) { + throw new BusinessException('VAL_001', { + context: { + reason: `${reason}-${sanitized.problem}`, + length: sanitized.length, + }, + }); + } + return sanitized.html; +} + +/** 회사 밖 응답의 글 칸 — 빈 글은 "쓴 적 없음"(null) */ +function writtenOrNull(value: string | null | undefined): string | null { + return value === undefined || value === null || value === '' ? null : value; +} + /** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */ function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate { const { isRead, isFlagged, mailboxId, isJunk } = dto; @@ -549,18 +579,35 @@ export class MailService { * - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 — * 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거") * - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝 - * 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이) + * - 서식 본문(16단계)은 보내는 정화(그림 없음)를 거쳐 저장한다 — 켤 때는 정화 뒤 10,000자까지(정화 전 길이 · 파서 비용은 늘 본다) + * - 회사 밖 응답(16단계) — 따로 쓰기이고 켤 때만 그 제목 · 내용을 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 본다. + * 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장하고, 따로 써 둔 글은 고르기와 상관없이 그대로 저장한다(빈 글은 null) + * - 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답으로 저장한다(옛 화면이 보는 그대로) + * 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 (제목 · 내용 없이) */ async updateVacation( user: AuthenticatedUser, dto: MailVacationDto, ): Promise { + const external = dto.external ?? null; + const mode = external?.mode ?? 'same'; const vacation: MailVacation = { isEnabled: dto.isEnabled, fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate), toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate), subject: dto.subject, textBody: dto.textBody, + htmlBody: vacationHtml(dto.htmlBody, dto.isEnabled, 'vacation-html'), + external: { + mode, + subject: writtenOrNull(external?.subject), + textBody: writtenOrNull(external?.textBody), + htmlBody: vacationHtml( + external?.htmlBody, + dto.isEnabled && mode === 'separate', + 'vacation-external-html', + ), + }, }; if ( vacation.isEnabled && @@ -584,6 +631,27 @@ export class MailService { }, }); } + if (vacation.isEnabled && mode === 'separate') { + const subject = vacation.external.subject ?? ''; + const textBody = vacation.external.textBody ?? ''; + if (subject.trim() === '' || textBody.trim() === '') { + throw new BusinessException('VAL_001', { + context: { reason: 'vacation-external-without-text' }, + }); + } + if ( + subject.length > MAX_VACATION_SUBJECT_LENGTH || + textBody.length > MAX_VACATION_TEXT_LENGTH + ) { + throw new BusinessException('VAL_001', { + context: { + reason: 'vacation-external-text-too-long', + subjectLength: subject.length, + textLength: textBody.length, + }, + }); + } + } if ( vacation.fromDate !== null && vacation.toDate !== null && @@ -596,7 +664,7 @@ export class MailService { const saved = await this.provider.setVacation(user.email, vacation); this.logger.log( - `${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`, + `${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}, 서식 ${saved.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${saved.external.mode}`, ); return saved; } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index d9a4927..5de2ee2 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -327,6 +327,23 @@ export interface MailboxBulkResult { readonly remaining: number; } +/** + * 회사 밖에서 온 메일(봉투 보낸 사람의 도메인이 `MAIL_DOMAIN` 이 아님)에 하는 응답 (16단계) — + * same: 사내와 같은 응답 · none: 보내지 않기 · separate: 따로 쓴 제목 · 내용으로 + */ +export type MailVacationExternalMode = 'same' | 'none' | 'separate'; + +/** 회사 밖 응답 (16단계) — 다른 mode 로 바꿔도 따로 쓴 글은 남겨 둔다(함께 저장한다) */ +export interface MailVacationExternal { + readonly mode: MailVacationExternalMode; + /** 따로 쓰기의 제목 — 쓴 적 없으면(빈 글) null */ + readonly subject: string | null; + /** 따로 쓰기의 글 — 쓴 적 없으면(빈 글) null */ + readonly textBody: string | null; + /** 따로 쓰기의 서식 본문(정화한 값 — 그림 없음) — 없으면 null(글만) */ + readonly htmlBody: string | null; +} + /** * 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 저장은 메일 필터와 같은 엔진 규칙에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) */ @@ -338,8 +355,12 @@ export interface MailVacation { readonly toDate: string | null; /** 엔진에 없으면(null) 빈 문자열 */ readonly subject: string; - /** 글자만(서식 없음) — 엔진에 없으면(null) 빈 문자열 */ + /** 보이는 글(서식을 뺀 글) — 엔진에 없으면(null) 빈 문자열. 서식 본문이 있으면 받는 쪽에는 두 벌(글 + 서식)로 간다 */ readonly textBody: string; + /** 서식 본문(정화한 값 — 그림 없음, 16단계) — null 이면 글만 */ + readonly htmlBody: string | null; + /** 회사 밖에서 온 메일에 하는 응답 (16단계) — 처음 값은 사내와 같은 응답 */ + readonly external: MailVacationExternal; } /** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */ diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index fa42085..96c0538 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -42,6 +42,7 @@ import { import { MailIdPipe } from './mail-id.pipe'; import { MAX_MAILBOX_NAME_LENGTH, + MAX_VACATION_HTML_LENGTH, MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, MAX_VACATION_TEXT_LENGTH, @@ -234,7 +235,8 @@ export class MailVacationController { @ApiOperation({ summary: '부재중 자동 응답', description: - '한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' + + '한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, ' + + 'external: { mode: "same", subject: null, textBody: null, htmlBody: null }, isActive: true } 다(서식 본문 · 회사 밖 응답은 16단계). ' + 'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).', }) @ApiStandardResponse(MailVacationStateDto, { @@ -253,7 +255,10 @@ export class MailVacationController { `꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` + '시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' + '제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' + - '꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' + + '꺼도 쓴 제목 · 내용은 남겨 둔다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' + + `16단계 — htmlBody(서식 본문, 그림 없음 — 정화해 저장, 켤 때 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지)와 external(회사 밖에서 온 메일 — same 사내와 같은 응답 · none 보내지 않기 · separate 따로 쓰기)을 받는다. ` + + '둘 다 보내지 않아도 된다(글만 · 사내와 같은 응답으로 저장). 따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 사내와 같은 규칙으로 본다 — 다른 고르기여도 써 둔 글은 그대로 저장한다. ' + + '사내 · 회사 밖은 봉투 보낸 사람의 도메인(회사 메일 도메인인지)으로 가른다. ' + '저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).', }) @ApiStandardResponse(MailVacationStateDto, { @@ -261,7 +266,7 @@ export class MailVacationController { }) @ApiErrorResponse( 'VAL_001', - `VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` + + `VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자 · 정화한 서식 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자 — 따로 쓰기면 회사 밖 칸도) · 서식이 너무 깊게 중첩됨 · 제어 문자 · 켤 때 공백뿐인 제목 · 내용(따로 쓰기면 회사 밖 칸도) · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` + '/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다', ) @ApiErrorResponse( diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 1f36631..09601d9 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -24,6 +24,7 @@ import { type CreatedAppPassword, type TwoFactorDisableResult, type TwoFactorEnableResult, + type TwoFactorReplaceResult, } from '@/modules/account-security/account-security.provider'; import { ACCOUNT_SECURITY_SETTINGS, @@ -33,9 +34,17 @@ import { AppReleaseStorage } from '@/modules/app-releases/app-release-storage'; import { AppReleaseEntity } from '@/modules/app-releases/app-release.entity'; import { APK_ZIP_SIGNATURE } from '@/modules/app-releases/app-releases.constants'; import { AppReleasesService } from '@/modules/app-releases/app-releases.service'; -import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants'; +import { + MAX_REFRESH_TOKEN_LENGTH, + TWO_FACTOR_CODE_ADDRESS_LIMIT, +} from '@/modules/auth/auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; -import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants'; +import { + MAX_CONTACT_DELETIONS, + MAX_CONTACT_GROUP_MEMBERS, + MAX_CONTACT_IMPORT_BATCH, + MAX_CONTACTS, +} from '@/modules/contacts/contacts.constants'; import { DirectoryService } from '@/modules/contacts/directory.service'; import { CONTACTS_PROVIDER, @@ -45,6 +54,7 @@ import { type AddressBookContactCard, type AddressBookGroupCard, type AddressBookSnapshot, + type AddressBookWriteOptions, type AddressBookWriteOutcome, type DirectoryAccount, type RemovedCardOriginal, @@ -68,6 +78,7 @@ import { MAX_SEARCH_LENGTH, MAX_SEARCH_WORDS, MAX_SUBJECT_LENGTH, + MAX_VACATION_HTML_LENGTH, MAX_VACATION_SUBJECT_LENGTH, MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, MAX_VACATION_TEXT_LENGTH, @@ -128,10 +139,13 @@ import type { SentMessage, } from '@/modules/mail/mail.types'; import { + LEGACY_SIGNATURE_NAME, + SIGNATURES_MAX, SIGNATURE_IMAGES_PER_SIGNATURE, SIGNATURE_IMAGES_STORED_MAX, SIGNATURE_IMAGE_DIR, SIGNATURE_IMAGE_MAX_BYTES, + SIGNATURE_NAME_MAX_LENGTH, } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; import { @@ -172,6 +186,7 @@ import { import { UPLOAD_DIR } from '@/modules/upload/upload.config'; import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; +import { parsePositiveInt } from '@/shared/utils/env'; import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; import { TOTP_PERIOD_SECONDS, @@ -320,6 +335,17 @@ const OTHER_RULE_CURSOR = 'otherrulecursor'; const fakeFilterVersion = (rules: readonly MailFilterRule[]) => createHash('sha256').update(JSON.stringify(rules)).digest('base64url'); +/** 16단계 칸이 없는 옛 요청이 저장되는 값 — 글만 · 사내와 같은 응답 (계약 2절 "부재중 자동 응답") */ +const TEXT_ONLY_VACATION = { + htmlBody: null, + external: { + mode: 'same' as const, + subject: null, + textBody: null, + htmlBody: null, + }, +}; + /** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */ const NEVER_CONFIGURED_VACATION: MailVacation = { isEnabled: false, @@ -327,6 +353,7 @@ const NEVER_CONFIGURED_VACATION: MailVacation = { toDate: null, subject: '', textBody: '', + ...TEXT_ONLY_VACATION, }; /** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */ @@ -947,6 +974,24 @@ describe('응답 계약 (e2e)', () => { twoFactorUrls.delete(user); return Promise.resolve('disabled'); }, + // 16단계 — 켠 채 지금 코드와 새 주소를 한 요청으로 (엔진처럼 비밀번호 틀림과 코드 틀림을 가르지 않는다) + replaceTwoFactor: ( + user: string, + currentPassword: string, + otpCode: string, + otpauthUrl: string, + ): Promise => { + const url = twoFactorUrls.get(user); + if ( + currentPassword !== CORRECT_PASSWORD || + url === undefined || + verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) === null + ) { + return Promise.resolve('code-rejected'); + } + twoFactorUrls.set(user, otpauthUrl); + return Promise.resolve('replaced'); + }, listAppPasswords: (user: string) => Promise.resolve([...(appPasswordsByUser.get(user) ?? [])]), createAppPassword: ( @@ -1035,6 +1080,8 @@ describe('응답 계약 (e2e)', () => { } >(); let addressBookSequence = 0; + /** 16단계 — 대역 엔진이 만들기를 받지 않을 uid (가져오기의 일부만 받기) */ + const refusedImportUids = new Set(); const addressBookOf = (user: string) => { let book = addressBooks.get(user); if (!book) { @@ -1058,15 +1105,29 @@ describe('응답 계약 (e2e)', () => { user: string, version: string, changes: AddressBookChanges, + options?: AddressBookWriteOptions, ): Promise => { const book = addressBookOf(user); if (version !== `v${book.state}`) { return Promise.resolve({ kind: 'conflict' }); } + // 16단계 — 받지 않는 만들기: 가져오기(allowPartialCreate)면 결과에 적고, 아니면 엔진처럼 그 코드로 던진다 + const refusedKeys = new Set( + changes.create + .filter((card) => refusedImportUids.has(card.uid)) + .map((card) => card.key), + ); + if (refusedKeys.size > 0 && !options?.allowPartialCreate) { + return Promise.reject( + new BusinessException('VAL_001', { + context: { reason: 'e2e-create-refused' }, + }), + ); + } const createdIds = new Map(); const cards = new Map(); const removed = new Map(); - for (const id of changes.destroy) { + for (const id of options?.withoutOriginals ? [] : changes.destroy) { const card = book.contacts.get(id) ?? book.groups.get(id); if (card) { const source = JSON.stringify(card); @@ -1074,6 +1135,7 @@ describe('응답 계약 (e2e)', () => { } } for (const card of changes.create) { + if (refusedKeys.has(card.key)) continue; addressBookSequence += 1; const id = `card${addressBookSequence}`; createdIds.set(card.key, id); @@ -1144,6 +1206,7 @@ describe('응답 계약 (e2e)', () => { createdIds, cards, removed, + ...(options?.allowPartialCreate ? { refusedKeys } : {}), }); }, }; @@ -2831,7 +2894,7 @@ describe('응답 계약 (e2e)', () => { .set('Cookie', cookieOf(email)) .send({ isForNew: true, isForReply: true, ...payload }); - it('저장한 적 없으면 기본값', async () => { + it('기본 서명(「새 메일」 · 「답장 · 전달」)이 없으면 본문 없음 · 두 표시 false (16단계부터 옛 경로 — 앱 첫 판은 본문이 없으면 서명을 넣지 않는다)', async () => { const res = await request(server) .get('/api/mail-signature') .set('Cookie', cookieOf('sign.default@e2e.arasystem.test')) @@ -2840,8 +2903,8 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(res)).toEqual({ bodyHtml: null, body: null, - isForNew: true, - isForReply: true, + isForNew: false, + isForReply: false, images: [], updatedAt: null, }); @@ -3937,19 +4000,30 @@ describe('응답 계약 (e2e)', () => { expect(vacationSaves).toHaveLength(before); }); - it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => { + it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다 — 16단계 칸이 없는 옛 요청은 글만 · 사내와 같은 응답', async () => { const res = await put(vacation).expect(200); - expect(dataOf(res)).toEqual({ ...vacation, isActive: true }); - expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation }); + expect(dataOf(res)).toEqual({ + ...vacation, + ...TEXT_ONLY_VACATION, + isActive: true, + }); + expect(vacationSaves.at(-1)).toEqual({ + user: ME, + vacation: { ...vacation, ...TEXT_ONLY_VACATION }, + }); const read = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); + expect(dataOf(read)).toEqual({ + ...vacation, + ...TEXT_ONLY_VACATION, + isActive: true, + }); }); - it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { + it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 글이 아닌 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => { const before = vacationSaves.length; const withoutToDate = Object.fromEntries( Object.entries(vacation).filter(([key]) => key !== 'toDate'), @@ -3975,7 +4049,7 @@ describe('응답 계약 (e2e)', () => { isEnabled: false, textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1), }, - { ...vacation, htmlBody: '

휴가

' }, + { ...vacation, htmlBody: 1 }, { ...vacation, subject: ' ' }, { ...vacation, textBody: ' \n\t ' }, { ...vacation, subject: '부재\r\n중' }, @@ -4002,7 +4076,11 @@ describe('응답 계약 (e2e)', () => { const res = await put(full).expect(200); - expect(dataOf(res)).toEqual({ ...full, isActive: true }); + expect(dataOf(res)).toEqual({ + ...full, + ...TEXT_ONLY_VACATION, + isActive: true, + }); }); it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => { @@ -4013,8 +4091,15 @@ describe('응답 계약 (e2e)', () => { }; const res = await put(longOff).expect(200); - expect(dataOf(res)).toEqual({ ...longOff, isActive: true }); - expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff }); + expect(dataOf(res)).toEqual({ + ...longOff, + ...TEXT_ONLY_VACATION, + isActive: true, + }); + expect(vacationSaves.at(-1)).toEqual({ + user: ME, + vacation: { ...longOff, ...TEXT_ONLY_VACATION }, + }); const before = vacationSaves.length; for (const enabling of [ @@ -4047,6 +4132,7 @@ describe('응답 계약 (e2e)', () => { ...off, fromDate: '2026-11-02T15:00:00.000Z', toDate: '2026-11-07T15:00:00.000Z', + ...TEXT_ONLY_VACATION, }; expect(dataOf(res)).toEqual({ ...saved, isActive: true }); expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved }); @@ -4567,7 +4653,11 @@ describe('응답 계약 (e2e)', () => { .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual({ ...vacation, isActive: true }); + expect(dataOf(read)).toEqual({ + ...vacation, + ...TEXT_ONLY_VACATION, + isActive: true, + }); await request(server) .put('/api/mail-vacation') @@ -4746,14 +4836,22 @@ describe('응답 계약 (e2e)', () => { .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); - expect(dataOf(read)).toEqual({ ...vacation, isActive: false }); + expect(dataOf(read)).toEqual({ + ...vacation, + ...TEXT_ONLY_VACATION, + isActive: false, + }); const saved = await request(server) .put('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .send(vacation) .expect(200); - expect(dataOf(saved)).toEqual({ ...vacation, isActive: true }); + expect(dataOf(saved)).toEqual({ + ...vacation, + ...TEXT_ONLY_VACATION, + isActive: true, + }); }); }); }); @@ -11676,6 +11774,1217 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('16단계 — 주소록 · 2단계 휴대폰 바꾸기', () => { + const expectError = (res: Response, code: string) => { + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe(code); + expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); + }; + + describe('주소록 가져오기 · 한꺼번에 지우기 · 내보내기 (/api/contact-import-checks · /api/contact-imports · /api/contact-deletions · /api/contact-exports)', () => { + interface ImportedBody { + uid: string; + id: string; + skippedGroupIds: string[]; + } + const uidOf = (n: number) => + `urn:uuid:00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; + const person = (n: number, overrides: Record = {}) => ({ + name: `가져옴${n}`, + emails: [{ address: `p${n}@import.e2e.example`, label: 'work' }], + phones: [], + company: null, + department: null, + title: null, + note: null, + groupIds: [], + uid: uidOf(n), + ...overrides, + }); + const as = (user: string) => ({ + post: (path: string) => + request(server).post(path).set('Cookie', cookieOf(user)), + get: (path: string) => + request(server).get(path).set('Cookie', cookieOf(user)), + }); + const importedOf = (res: Response) => + dataOf<{ items: ImportedBody[] }>(res).items; + /** 대역 엔진 주소록에 연락처를 바로 넣는다 (한도를 채울 때) */ + const seedContact = (user: string, id: string, uid: string | null) => { + const book = addressBookOf(user); + book.contacts.set(id, { + id, + uid, + name: `채움 ${id}`, + emails: [], + phones: [], + company: null, + department: null, + title: null, + note: null, + }); + book.state += 1; + }; + + afterEach(() => refusedImportUids.clear()); + + it('로그인 없이는 401 AUTH_001', async () => { + for (const [method, path] of [ + ['post', '/api/contact-import-checks'], + ['post', '/api/contact-imports'], + ['post', '/api/contact-deletions'], + ['get', '/api/contact-exports?format=vcard'], + ] as const) { + expectError( + await request(server)[method](path).expect(401), + 'AUTH_001', + ); + } + }); + + it('요청 수 한도 — 경로마다 IP 당 1분 THROTTLE_LIMIT 회(기본 100)를 넘으면 429 REQ_001 (사무실은 IP 하나 — 화면은 100명씩 보낸다)', async () => { + const limit = parsePositiveInt(process.env.THROTTLE_LIMIT, 100); + for (const [method, path, ip] of [ + ['post', '/api/contact-import-checks', '198.18.16.1'], + ['post', '/api/contact-imports', '198.18.16.2'], + ['post', '/api/contact-deletions', '198.18.16.3'], + ['get', '/api/contact-exports?format=csv', '198.18.16.4'], + ] as const) { + for (let i = 0; i < limit; i += 1) { + await request(server) + [method](path) + .set('X-Forwarded-For', ip) + .expect(401); + } + expectError( + await request(server) + [method](path) + .set('X-Forwarded-For', ip) + .expect(429), + 'REQ_001', + ); + } + }); + + it('확인 — 같은 이메일(대소문자 무시) · 이름 + 전화 숫자 · 같은 uid 는 exists, remaining 은 3,000 − 지금 수 (200 · 표준 래퍼, 만들지 않는다)', async () => { + const USER = 'import.check@e2e.arasystem.test'; + const me = as(USER); + const blank = { + phones: [], + company: null, + department: null, + title: null, + note: null, + groupIds: [], + }; + await me + .post('/api/contacts') + .send({ + ...blank, + name: '박지훈', + emails: [{ address: 'Jihoon@Hanbit.e2e.example', label: 'work' }], + }) + .expect(201); + await me + .post('/api/contacts') + .send({ + ...blank, + name: '김철수', + emails: [], + phones: [{ number: '010-1111-2222', label: 'mobile' }], + }) + .expect(201); + + const res = await me + .post('/api/contact-import-checks') + .send({ + contacts: [ + person(1, { + emails: [ + { address: ' jihoon@hanbit.E2E.example', label: 'other' }, + ], + }), + person(2, { + name: '김철수', + emails: [], + phones: [{ number: '01011112222', label: 'mobile' }], + }), + person(3), + ], + }) + .expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + items: [ + { uid: uidOf(1), status: 'exists' }, + { uid: uidOf(2), status: 'exists' }, + { uid: uidOf(3), status: 'new' }, + ], + remaining: MAX_CONTACTS - 2, + }, + }); + expect(addressBookOf(USER).contacts.size).toBe(2); + }); + + it('넣기 — 201 · 보낸 차례 · 그룹에 넣고 목록에 보인다 · 같은 묶음을 다시 보내면 같은 id (두 번 들어가지 않는다)', async () => { + const USER = 'import.me@e2e.arasystem.test'; + const me = as(USER); + const group = dataOf<{ id: string }>( + await me + .post('/api/contact-groups') + .send({ name: '가져온 사람' }) + .expect(201), + ); + const batch = { + contacts: [ + person(2, { + phones: [{ number: '010-2222-3333', label: 'mobile' }], + note: '첫 줄\n둘째 줄', + }), + person(1, { groupIds: [group.id] }), + ], + }; + + const first = await me + .post('/api/contact-imports') + .send(batch) + .expect(201); + expect(body(first).success).toBe(true); + const items = importedOf(first); + expect(items).toEqual([ + { + uid: uidOf(2), + id: expect.any(String) as unknown, + skippedGroupIds: [], + }, + { + uid: uidOf(1), + id: expect.any(String) as unknown, + skippedGroupIds: [], + }, + ]); + + // 응답을 잃고 같은 묶음을 다시 보냈다 — 새로 만들지 않는다 + const again = importedOf( + await me.post('/api/contact-imports').send(batch).expect(201), + ); + expect(again).toEqual(items); + expect(addressBookOf(USER).contacts.size).toBe(2); + + const page = dataOf< + Page<{ id: string; name: string; note: string; groupIds: string[] }> + >(await me.get('/api/contacts').expect(200)); + expect( + page.items.map((contact) => [contact.name, contact.groupIds]), + ).toEqual([ + ['가져옴1', [group.id]], + ['가져옴2', []], + ]); + expect(page.items[1].note).toBe('첫 줄\n둘째 줄'); + }); + + it('넣기 · 확인 — 400 VAL_001(묶음 · uid · 같은 uid · 이름도 이메일도 없음 · 모르는 칸) · 404 RES_001(없는 그룹) · 409 CONTACT_002(연락처 한도 — 묶음 전체로) — 아무것도 만들지 않는다', async () => { + const USER = 'import.errors@e2e.arasystem.test'; + const me = as(USER); + for (const payload of [ + {}, + { contacts: [] }, + { + contacts: Array.from( + { length: MAX_CONTACT_IMPORT_BATCH + 1 }, + (_, n) => person(n), + ), + }, + { contacts: [person(1, { uid: 'URN:UUID:X' })] }, + { contacts: [person(1), person(2, { uid: uidOf(1) })] }, + { contacts: [person(1, { name: null, emails: [] })] }, + { contacts: [person(1, { extra: true })] }, + ]) { + expectError( + await me.post('/api/contact-imports').send(payload).expect(400), + 'VAL_001', + ); + expectError( + await me + .post('/api/contact-import-checks') + .send(payload) + .expect(400), + 'VAL_001', + ); + } + expectError( + await me + .post('/api/contact-imports') + .send({ contacts: [person(1, { groupIds: ['nogroup'] })] }) + .expect(404), + 'RES_001', + ); + expect(addressBookOf(USER).contacts.size).toBe(0); + + for (let i = 0; i < MAX_CONTACTS - 1; i += 1) { + seedContact(USER, `f${i}`, null); + } + expectError( + await me + .post('/api/contact-imports') + .send({ contacts: [person(1), person(2)] }) + .expect(409), + 'CONTACT_002', + ); + expect(addressBookOf(USER).contacts.size).toBe(MAX_CONTACTS - 1); + const check = dataOf<{ remaining: number }>( + await me + .post('/api/contact-import-checks') + .send({ contacts: [person(1)] }) + .expect(200), + ); + expect(check.remaining).toBe(1); + }); + + it('넣기 — 메일 서버가 일부만 받으면 받은 것만 items (201 — 빠진 uid 는 넣지 못한 것)', async () => { + const USER = 'import.partial@e2e.arasystem.test'; + refusedImportUids.add(uidOf(2)); + + const items = importedOf( + await as(USER) + .post('/api/contact-imports') + .send({ contacts: [person(1), person(2), person(3)] }) + .expect(201), + ); + expect(items.map((item) => item.uid)).toEqual([uidOf(1), uidOf(3)]); + expect(addressBookOf(USER).contacts.size).toBe(2); + }); + + it(`넣기 — 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명을 넘기는 넣기는 그 그룹만 빼고 skippedGroupIds (실패가 아니다)`, async () => { + const USER = 'import.full@e2e.arasystem.test'; + const uids: string[] = []; + for (let i = 0; i < MAX_CONTACT_GROUP_MEMBERS; i += 1) { + uids.push(`urn:uuid:full-${i}`); + seedContact(USER, `m${i}`, `urn:uuid:full-${i}`); + } + const book = addressBookOf(USER); + book.groups.set('gfull', { + id: 'gfull', + uid: 'urn:uuid:gfull', + name: '꽉 참', + memberUids: uids, + }); + book.state += 1; + + const items = importedOf( + await as(USER) + .post('/api/contact-imports') + .send({ contacts: [person(1, { groupIds: ['gfull'] })] }) + .expect(201), + ); + expect(items).toEqual([ + { + uid: uidOf(1), + id: expect.any(String) as unknown, + skippedGroupIds: ['gfull'], + }, + ]); + expect(book.groups.get('gfull')?.memberUids).toHaveLength( + MAX_CONTACT_GROUP_MEMBERS, + ); + }); + + it('지우기 — 200 deletedIds · notFoundIds(보낸 차례), 그룹 구성원에서도 빠진다 · 400 VAL_001(빈 · 겹침 · 모양 · 501개)', async () => { + const USER = 'import.undo@e2e.arasystem.test'; + const me = as(USER); + const group = dataOf<{ id: string }>( + await me + .post('/api/contact-groups') + .send({ name: '되돌릴 그룹' }) + .expect(201), + ); + const ids = importedOf( + await me + .post('/api/contact-imports') + .send({ + contacts: [person(1, { groupIds: [group.id] }), person(2)], + }) + .expect(201), + ).map((item) => item.id); + + const res = await me + .post('/api/contact-deletions') + .send({ ids: [ids[1], 'gone', ids[0], group.id] }) + .expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + deletedIds: [ids[1], ids[0]], + notFoundIds: ['gone', group.id], + }, + }); + expect(addressBookOf(USER).contacts.size).toBe(0); + expect(addressBookOf(USER).groups.get(group.id)?.memberUids).toEqual( + [], + ); + + for (const payload of [ + {}, + { ids: [] }, + { ids: ['a', 'a'] }, + { ids: ['../x'] }, + { + ids: Array.from( + { length: MAX_CONTACT_DELETIONS + 1 }, + (_, n) => `x${n}`, + ), + }, + ]) { + expectError( + await me.post('/api/contact-deletions').send(payload).expect(400), + 'VAL_001', + ); + } + }); + + it('내보내기 — vCard · CSV 파일(표준 래퍼 없음 · attachment · private, no-store · nosniff) · 그룹 하나 · 400 VAL_001 · 404 RES_001', async () => { + const USER = 'import.export@e2e.arasystem.test'; + const me = as(USER); + const group = dataOf<{ id: string }>( + await me + .post('/api/contact-groups') + .send({ name: '거래처' }) + .expect(201), + ); + await me + .post('/api/contact-imports') + .send({ + contacts: [ + person(1, { + name: '박지훈', + groupIds: [group.id], + phones: [{ number: '+82 10-1234-5678', label: 'mobile' }], + }), + person(2, { name: '김아라' }), + ], + }) + .expect(201); + const today = kstDateString(new Date()); + + const vcard = await me + .get('/api/contact-exports') + .query({ format: 'vcard' }) + .responseType('blob') + .expect(200); + expect(vcard.headers['content-type']).toBe('text/vcard; charset=utf-8'); + expect(vcard.headers['content-disposition']).toBe( + buildAttachmentDisposition(`주소록-${today}.vcf`), + ); + expect(vcard.headers['cache-control']).toBe('private, no-store'); + expect(vcard.headers['x-content-type-options']).toBe('nosniff'); + const cards = (vcard.body as Buffer).toString('utf8'); + expect(cards.startsWith('BEGIN:VCARD\r\nVERSION:3.0\r\n')).toBe(true); + expect(cards.match(/BEGIN:VCARD/g)).toHaveLength(2); + expect(cards.indexOf('FN:김아라')).toBeLessThan( + cards.indexOf('FN:박지훈'), + ); + expect(cards).toContain(`UID:${uidOf(1)}\r\n`); + expect(cards).toContain('CATEGORIES:거래처\r\n'); + + const csv = await me + .get('/api/contact-exports') + .query({ format: 'csv', groupId: group.id }) + .responseType('blob') + .expect(200); + expect(csv.headers['content-type']).toBe('text/csv; charset=utf-8'); + expect(csv.headers['content-disposition']).toBe( + buildAttachmentDisposition(`주소록-거래처-${today}.csv`), + ); + expect((csv.body as Buffer).toString('utf8')).toBe( + '\uFEFF이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' + + "박지훈,p1@import.e2e.example,'+82 10-1234-5678,,,,,,,,거래처\r\n", + ); + + for (const query of [ + {}, + { format: 'xlsx' }, + { format: 'csv', groupId: '../x' }, + { format: 'csv', limit: '10' }, + ]) { + expectError( + await me.get('/api/contact-exports').query(query).expect(400), + 'VAL_001', + ); + } + expectError( + await me + .get('/api/contact-exports') + .query({ format: 'csv', groupId: 'nogroup' }) + .expect(404), + 'RES_001', + ); + }); + }); + + describe('2단계 인증 휴대폰 바꾸기 (/api/two-factor/enrollments/{id}/verification · /api/two-factor/replacements)', () => { + /** 켜진 2단계의 지금 휴대폰 — 대역 엔진에 바로 둔다 (엔진 관리 화면에서 켠 것처럼) */ + const OLD_SECRET = Buffer.alloc(20, 9); + const turnOn = (user: string) => + twoFactorUrls.set( + user, + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: user, + secret: encodeBase32(OLD_SECRET), + }), + ); + const as = (user: string, ip: string) => ({ + post: (path: string) => + request(server) + .post(path) + .set('Cookie', cookieOf(user)) + .set('X-Forwarded-For', ip), + }); + interface EnrollmentBody { + enrollmentId: string; + secret: string; + otpauthUrl: string; + } + const verification = (id: string) => + `/api/two-factor/enrollments/${id}/verification`; + + it('로그인 없이는 401 AUTH_001', async () => { + for (const path of [ + verification(randomUUID()), + '/api/two-factor/replacements', + ]) { + expectError(await request(server).post(path).expect(401), 'AUTH_001'); + } + }); + + it('켜져 있어도 등록(201) → ① 새 휴대폰의 코드 확인(200) → ② 지금 코드 + 비밀번호로 바꾸기(200) — 옛 휴대폰의 코드는 막히고 새 휴대폰의 코드로 로그인한다', async () => { + const ME = 'phone.swap@e2e.arasystem.test'; + turnOn(ME); + try { + const me = as(ME, '198.18.17.1'); + const enrollment = dataOf( + await me.post('/api/two-factor/enrollments').expect(201), + ); + const newSecret = decodeBase32(enrollment.secret); + + const newCode = otpCodeOf(newSecret); + const verified = await me + .post(verification(enrollment.enrollmentId)) + .send({ otpCode: newCode }) + .expect(200); + expect(body(verified)).toEqual({ + success: true, + data: { verified: true }, + }); + // 확인만으로는 엔진에 아무것도 바꾸지 않는다 + expect(twoFactorUrls.get(ME)).not.toBe(enrollment.otpauthUrl); + + const oldCode = otpCodeOf(OLD_SECRET); + const replaced = await me + .post('/api/two-factor/replacements') + .send({ + enrollmentId: enrollment.enrollmentId, + currentOtpCode: oldCode, + currentPassword: CORRECT_PASSWORD, + }) + .expect(200); + expect(body(replaced).data).toEqual({ + isEnabled: true, + enabledAt: null, + isRequired: false, + deadline: null, + }); + expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl); + + // 옛 휴대폰의 코드(아직 쓰지 않은 것)는 엔진이 받지 않는다 + const staleCode = await unusedOtpCode(OLD_SECRET, [oldCode]); + expectError( + await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.17.2') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: staleCode, + }) + .expect(400), + 'AUTH_006', + ); + // 새 휴대폰의 코드(①에서 쓴 것 말고)로 로그인한다 + const loginCode = await unusedOtpCode(newSecret, [newCode]); + const login = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.17.3') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: loginCode, + }) + .expect(200); + expect(body(login).data).toMatchObject({ + status: 'signedIn', + email: ME, + }); + + // 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다 + expectError( + await me + .post('/api/two-factor/replacements') + .send({ + enrollmentId: enrollment.enrollmentId, + currentOtpCode: loginCode, + currentPassword: CORRECT_PASSWORD, + }) + .expect(409), + 'AUTH_008', + ); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it('400 — VAL_001(형식) · AUTH_006(새 코드 틀림 · 지금 코드 틀림) · AUTH_005(지금 비밀번호 틀림) / 409 AUTH_008 — 없는 등록 · 확인하지 않은 등록(버린다) · 그사이 꺼짐', async () => { + const ME = 'phone.errors@e2e.arasystem.test'; + turnOn(ME); + try { + const me = as(ME, '198.18.17.10'); + expectError( + await me + .post(verification('not-a-uuid')) + .send({ otpCode: '123456' }) + .expect(400), + 'VAL_001', + ); + expectError( + await me + .post(verification(randomUUID())) + .send({ otpCode: '12-34' }) + .expect(400), + 'VAL_001', + ); + expectError( + await me + .post('/api/two-factor/replacements') + .send({ + enrollmentId: 'x', + currentOtpCode: '123456', + currentPassword: CORRECT_PASSWORD, + }) + .expect(400), + 'VAL_001', + ); + expectError( + await me + .post(verification(randomUUID())) + .send({ otpCode: '123456' }) + .expect(409), + 'AUTH_008', + ); + + // 확인하지 않은 등록으로 바꾸기 — 409 AUTH_008, 그 등록은 버린다 + const first = dataOf( + await me.post('/api/two-factor/enrollments').expect(201), + ); + expectError( + await me + .post('/api/two-factor/replacements') + .send({ + enrollmentId: first.enrollmentId, + currentOtpCode: otpCodeOf(OLD_SECRET), + currentPassword: CORRECT_PASSWORD, + }) + .expect(409), + 'AUTH_008', + ); + expectError( + await me + .post(verification(first.enrollmentId)) + .send({ otpCode: otpCodeOf(decodeBase32(first.secret)) }) + .expect(409), + 'AUTH_008', + ); + + const second = dataOf( + await me.post('/api/two-factor/enrollments').expect(201), + ); + const right = otpCodeOf(decodeBase32(second.secret)); + const wrong = right === '000000' ? '111111' : '000000'; + expectError( + await me + .post(verification(second.enrollmentId)) + .send({ otpCode: wrong }) + .expect(400), + 'AUTH_006', + ); + await me + .post(verification(second.enrollmentId)) + .send({ otpCode: right }) + .expect(200); + + const replace = (overrides: object) => + me.post('/api/two-factor/replacements').send({ + enrollmentId: second.enrollmentId, + currentOtpCode: otpCodeOf(OLD_SECRET), + currentPassword: CORRECT_PASSWORD, + ...overrides, + }); + expectError( + await replace({ currentPassword: 'wrong-password' }).expect(400), + 'AUTH_005', + ); + const oldRight = otpCodeOf(OLD_SECRET); + expectError( + await replace({ + currentOtpCode: oldRight === '222222' ? '333333' : '222222', + }).expect(400), + 'AUTH_006', + ); + expect(twoFactorUrls.get(ME)).not.toBe(second.otpauthUrl); + + // 그사이 다른 곳에서 꺼졌다 — 바꿀 것이 없다 + twoFactorUrls.delete(ME); + expectError(await replace({}).expect(409), 'AUTH_008'); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it(`429 REQ_001 — 새 휴대폰의 코드 실패 한도(같은 주소 15분 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}회 — 켜기 · 끄기 · 로그인과 같은 카운터)`, async () => { + const ME = 'phone.limit@e2e.arasystem.test'; + turnOn(ME); + try { + const enrollment = dataOf( + await as(ME, '198.18.17.20') + .post('/api/two-factor/enrollments') + .expect(201), + ); + const right = otpCodeOf(decodeBase32(enrollment.secret)); + const wrong = right === '000000' ? '111111' : '000000'; + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + expectError( + await as(ME, `198.18.17.${21 + i}`) + .post(verification(enrollment.enrollmentId)) + .send({ otpCode: wrong }) + .expect(400), + 'AUTH_006', + ); + } + expectError( + await as(ME, '198.18.17.40') + .post(verification(enrollment.enrollmentId)) + .send({ otpCode: right }) + .expect(429), + 'REQ_001', + ); + } finally { + twoFactorUrls.delete(ME); + } + }); + }); + }); + + /** + * 16단계 — 여러 서명 · 부재중의 서식 본문과 회사 밖 응답 (계약 2절 "서명" · "부재중 자동 응답"). + * 서명은 실제 PostgreSQL(표 · 사용자 잠금 · 기본 표시의 부분 유일 인덱스 · 그림 정리)로 본다. 부재중은 대역 엔진이라 + * 라우트 · 검증 · 정화 · 저장 값만 보고, 만드는 Sieve(글 + 서식 · 봉투 도메인 · 조각)는 sieve-script.spec.ts · jmap-filter-script.spec.ts 가 본다 + */ + describe('16단계 — 여러 서명 · 부재중', () => { + /** 이번 실행만의 주소 — 앞선 실행이 남긴 줄과 섞이지 않게 */ + const RUN = Date.now().toString(36); + const userOf = (name: string) => `sig16.${name}.${RUN}@e2e.arasystem.test`; + const NOT_MINE = '11111111-1111-4111-8111-111111111111'; + const SIGNATURE_ID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + const imageUrl = (id: string) => `/api/mail-signature/images/${id}`; + + interface SignatureBody { + id: string; + name: string; + bodyHtml: string | null; + body: string | null; + images: { id: string }[]; + updatedAt: string; + } + interface DefaultsBody { + newMessageId: string | null; + replyId: string | null; + } + + const createAs = (email: string, payload: object) => + request(server) + .post('/api/mail-signatures') + .set('Cookie', cookieOf(email)) + .send(payload); + const created = async (email: string, payload: object) => + dataOf(await createAs(email, payload).expect(201)); + const listOf = async (email: string) => + dataOf<{ + items: SignatureBody[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number; + }; + }>( + await request(server) + .get('/api/mail-signatures') + .set('Cookie', cookieOf(email)) + .expect(200), + ); + const defaultsOf = async (email: string) => + dataOf( + await request(server) + .get('/api/mail-signature-defaults') + .set('Cookie', cookieOf(email)) + .expect(200), + ); + const putDefaults = (email: string, payload: object) => + request(server) + .put('/api/mail-signature-defaults') + .set('Cookie', cookieOf(email)) + .send(payload); + const uploaded = async (email: string) => + dataOf<{ id: string }>( + await request(server) + .post('/api/mail-signature/images') + .set('Cookie', cookieOf(email)) + .attach('file', pngBytes(40, 10), { + filename: 'logo.png', + contentType: 'image/png', + }) + .expect(201), + ).id; + + it.each([ + ['get', '/api/mail-signatures'], + ['post', '/api/mail-signatures'], + ['put', `/api/mail-signatures/${NOT_MINE}`], + ['delete', `/api/mail-signatures/${NOT_MINE}`], + ['get', '/api/mail-signature-defaults'], + ['put', '/api/mail-signature-defaults'], + ] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => { + const res = await request(server)[method](path).expect(401); + expect(body(res).error?.code).toBe('AUTH_001'); + }); + + describe('여러 서명 (/api/mail-signatures · /api/mail-signature-defaults)', () => { + it('첫 서명은 새 메일 · 답장과 전달 두 곳의 기본 — 목록(만든 차례 · hasNext false · total)과 옛 경로(앱 첫 판의 네 칸)가 그것을 보인다', async () => { + const me = userOf('first'); + await expect(listOf(me)).resolves.toEqual({ + items: [], + pageInfo: { hasNext: false, nextCursor: null, total: 0 }, + }); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: null, + replyId: null, + }); + + const first = await created(me, { + name: ' 사내 ', + bodyHtml: '

김아라 · 인프라팀

', + body: '김아라 · 인프라팀', + }); + expect(first).toEqual({ + id: expect.stringMatching(SIGNATURE_ID) as string, + name: '사내', + bodyHtml: '

김아라 · 인프라팀

', + body: '김아라 · 인프라팀', + images: [], + updatedAt: expect.any(String) as string, + }); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: first.id, + replyId: first.id, + }); + + const second = await created(me, { name: '대외', body: 'Ara Kim' }); + const list = await listOf(me); + expect(list.items.map((item) => item.id)).toEqual([ + first.id, + second.id, + ]); + expect(list.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 2, + }); + // 새로 만든 서명은 기본을 바꾸지 않는다 + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: first.id, + replyId: first.id, + }); + const legacy = await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .expect(200); + expect(dataOf(legacy)).toEqual({ + bodyHtml: first.bodyHtml, + body: first.body, + isForNew: true, + isForReply: true, + images: [], + updatedAt: first.updatedAt, + }); + }); + + it('기본 고르기 — 두 곳을 따로 · 넣지 않음(null)으로, 내 서명이 아닌 id 는 404 RES_001 · 빠진 키 · 모르는 칸은 400 VAL_001 (아무것도 바꾸지 않는다)', async () => { + const me = userOf('defaults'); + const a = await created(me, { name: '사내' }); + const b = await created(me, { name: '대외' }); + const others = await created(userOf('defaults-other'), { + name: '남의 서명', + }); + + const res = await putDefaults(me, { + newMessageId: b.id, + replyId: a.id.toUpperCase(), + }).expect(200); + expect(dataOf(res)).toEqual({ + newMessageId: b.id, + replyId: a.id, + }); + const legacy = dataOf<{ body: string | null; isForReply: boolean }>( + await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .expect(200), + ); + expect(legacy).toMatchObject({ isForNew: true, isForReply: false }); + + for (const id of [others.id, NOT_MINE, 'not-a-uuid']) { + const rejected = await putDefaults(me, { + newMessageId: null, + replyId: id, + }).expect(404); + expect(body(rejected).error?.code).toBe('RES_001'); + } + for (const payload of [ + { newMessageId: null }, + { newMessageId: 1, replyId: null }, + { newMessageId: null, replyId: null, forwardId: null }, + ]) { + const rejected = await putDefaults(me, payload).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: b.id, + replyId: a.id, + }); + + await putDefaults(me, { newMessageId: null, replyId: null }).expect( + 200, + ); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: null, + replyId: null, + }); + }); + + it(`이름(앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자 · 겹치지 않음 — 영문 대소문자 무시) · ${SIGNATURES_MAX}개를 넘으면 400 VAL_001 — 저장하지 않는다`, async () => { + const me = userOf('limits'); + await created(me, { name: 'Office' }); + + for (const payload of [ + { name: 'office' }, + { name: ' ' }, + { name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH + 1) }, + { name: '사내\n영문' }, + { body: '이름 없음' }, + { name: '긴 글', body: '가'.repeat(2_001) }, + { name: '긴 서식', bodyHtml: `

${'가'.repeat(10_000)}

` }, + { name: '옛 칸', isForNew: true }, + ]) { + const res = await createAs(me, payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + for (let index = 2; index <= SIGNATURES_MAX; index += 1) { + await created(me, { name: `서명 ${index}` }); + } + const overflow = await createAs(me, { name: '열한 번째' }).expect(400); + expect(body(overflow).error?.code).toBe('VAL_001'); + expect((await listOf(me)).pageInfo.total).toBe(SIGNATURES_MAX); + }); + + it('고치기 · 지우기 — 기본 표시는 고치기에서 그대로 · 지우면 그 자리는 넣지 않음, 없는 · 남의 · 형식이 틀린 id 는 404 RES_001, 지우기는 204(본문 없음)', async () => { + const me = userOf('edit'); + const a = await created(me, { name: '사내', body: '처음' }); + const b = await created(me, { name: '대외' }); + const others = await created(userOf('edit-other'), { name: '사내' }); + + const updated = await request(server) + .put(`/api/mail-signatures/${a.id.toUpperCase()}`) + .set('Cookie', cookieOf(me)) + .send({ name: '사내 (새)', body: '바꾼 글' }) + .expect(200); + expect(dataOf(updated)).toMatchObject({ + id: a.id, + name: '사내 (새)', + body: '바꾼 글', + bodyHtml: null, + }); + const taken = await request(server) + .put(`/api/mail-signatures/${b.id}`) + .set('Cookie', cookieOf(me)) + .send({ name: '사내 (새)' }) + .expect(400); + expect(body(taken).error?.code).toBe('VAL_001'); + + for (const id of [others.id, NOT_MINE, 'not-a-uuid']) { + const put = await request(server) + .put(`/api/mail-signatures/${id}`) + .set('Cookie', cookieOf(me)) + .send({ name: '바꿈' }) + .expect(404); + expect(body(put).error?.code).toBe('RES_001'); + const del = await request(server) + .delete(`/api/mail-signatures/${id}`) + .set('Cookie', cookieOf(me)) + .expect(404); + expect(body(del).error?.code).toBe('RES_001'); + } + + const removed = await request(server) + .delete(`/api/mail-signatures/${a.id}`) + .set('Cookie', cookieOf(me)) + .expect(204); + expect(removed.text).toBe(''); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: null, + replyId: null, + }); + expect((await listOf(me)).items.map((item) => item.id)).toEqual([b.id]); + // 남의 서명은 그대로다 + expect((await listOf(userOf('edit-other'))).items).toHaveLength(1); + }); + + it('그림 정리는 모든 서명 HTML 의 합집합 — 다른 서명이 쓰는 그림은 남기고, 지운 서명에만 쓰던 그림은 표와 파일에서 지운다', async () => { + const me = userOf('images'); + const [shared, onlyA] = [await uploaded(me), await uploaded(me)]; + const img = (id: string) => ``; + const a = await created(me, { + name: '사내', + bodyHtml: `

사내

${img(shared)}${img(onlyA)}`, + }); + const b = await created(me, { + name: '대외', + bodyHtml: `

대외

${img(shared)}`, + }); + expect(a.images.map((image) => image.id)).toEqual([shared, onlyA]); + expect(b.images.map((image) => image.id)).toEqual([shared]); + + const statusOf = async (id: string) => + (await request(server).get(imageUrl(id)).set('Cookie', cookieOf(me))) + .status; + await request(server) + .delete(`/api/mail-signatures/${a.id}`) + .set('Cookie', cookieOf(me)) + .expect(204); + expect(await statusOf(shared)).toBe(200); + expect(await statusOf(onlyA)).toBe(404); + expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${onlyA}.png`))).toBe( + false, + ); + + await request(server) + .put(`/api/mail-signatures/${b.id}`) + .set('Cookie', cookieOf(me)) + .send({ name: '대외', body: '글만' }) + .expect(200); + expect(await statusOf(shared)).toBe(404); + }); + + it('서명이 없던 직원이 동시에 둘을 만들어도 기본은 한 곳에 하나씩 — 먼저 잠근 첫 서명이 두 곳의 기본 (사용자 잠금 · 부분 유일 인덱스)', async () => { + const me = userOf('race'); + const [first, second] = await Promise.all([ + createAs(me, { name: '하나' }), + createAs(me, { name: '둘' }), + ]); + expect([first.status, second.status]).toEqual([201, 201]); + + const flags: { new_count: string; reply_count: string }[] = + await dataSource.query( + 'SELECT count(*) FILTER (WHERE is_new_message_default) AS new_count, count(*) FILTER (WHERE is_reply_default) AS reply_count FROM mail_signatures WHERE user_email = $1', + [me], + ); + expect(flags).toEqual([{ new_count: '1', reply_count: '1' }]); + const list = await listOf(me); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: list.items[0].id, + replyId: list.items[0].id, + }); + }); + + it(`옛 경로 PUT(16단계 전의 웹 탭) — 서명이 없으면 "${LEGACY_SIGNATURE_NAME}"을 만들고 두 표시를 기본 둘로 옮긴다, 다시 저장하면 그 서명을 고친다`, async () => { + const me = userOf('legacy'); + const saved = await request(server) + .put('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .send({ body: '김아라', isForNew: true, isForReply: false }) + .expect(200); + expect(dataOf(saved)).toMatchObject({ + body: '김아라', + isForNew: true, + isForReply: false, + }); + await request(server) + .put('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .send({ body: '바꾼 글', isForNew: true, isForReply: true }) + .expect(200); + + const list = await listOf(me); + expect(list.items.map((item) => [item.name, item.body])).toEqual([ + [LEGACY_SIGNATURE_NAME, '바꾼 글'], + ]); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: list.items[0].id, + replyId: list.items[0].id, + }); + }); + + it('옛 경로 GET · PUT — 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 isForNew false · isForReply true 로 보이고, PUT 도 그 서명을 고친다', async () => { + const me = userOf('legacy-reply'); + const a = await created(me, { name: '사내', body: '사내 서명' }); + const b = await created(me, { name: '대외', body: 'Ara Kim' }); + await putDefaults(me, { newMessageId: null, replyId: b.id }).expect( + 200, + ); + + const legacy = await request(server) + .get('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .expect(200); + expect(dataOf(legacy)).toEqual({ + bodyHtml: null, + body: 'Ara Kim', + isForNew: false, + isForReply: true, + images: [], + updatedAt: b.updatedAt, + }); + + // 옛 웹 탭이 읽은 그대로 저장 — "기본 서명"을 새로 만들지 않고 답장 기본 서명을 고친다 + await request(server) + .put('/api/mail-signature') + .set('Cookie', cookieOf(me)) + .send({ body: 'Ara Kim · Infra', isForNew: false, isForReply: true }) + .expect(200); + const list = await listOf(me); + expect(list.items.map((item) => [item.id, item.body])).toEqual([ + [a.id, '사내 서명'], + [b.id, 'Ara Kim · Infra'], + ]); + await expect(defaultsOf(me)).resolves.toEqual({ + newMessageId: null, + replyId: b.id, + }); + }); + }); + + describe('부재중 서식 본문 · 회사 밖 응답 (PUT /api/mail-vacation)', () => { + const ME = userOf('vacation'); + const vacation = { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재중입니다', + textBody: '11월 7일까지 휴가입니다.', + }; + const external = { + mode: 'separate', + subject: 'Out of office', + textBody: 'I am away until Nov 7.', + htmlBody: '

I am away until Nov 7.

', + }; + const put = (payload: object) => + request(server) + .put('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('서식 본문은 보내는 정화(그림 없음)를 거쳐 저장하고, 회사 밖 응답(따로 쓰기)을 함께 저장한다 — 다음 조회도 그 값', async () => { + const res = await put({ + ...vacation, + htmlBody: + '

11월 7일까지 휴가입니다.

', + external, + }).expect(200); + + const saved = { + ...vacation, + htmlBody: '

11월 7일까지 휴가입니다.

', + external, + }; + expect(dataOf(res)).toEqual({ ...saved, isActive: true }); + expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved }); + const read = await request(server) + .get('/api/mail-vacation') + .set('Cookie', cookieOf(ME)) + .expect(200); + expect(dataOf(read)).toEqual({ ...saved, isActive: true }); + }); + + it('다른 고르기여도 써 둔 회사 밖 글은 그대로 저장한다 — 빈 글은 null, 사내와 같은 응답 · 보내지 않기면 비어도 저장한다', async () => { + for (const mode of ['none', 'same'] as const) { + const res = await put({ + ...vacation, + external: { ...external, mode, subject: '' }, + }).expect(200); + expect(dataOf<{ external: unknown }>(res).external).toEqual({ + ...external, + mode, + subject: null, + }); + } + await put({ + ...vacation, + external: { + mode: 'none', + subject: null, + textBody: null, + htmlBody: null, + }, + }).expect(200); + }); + + it(`켤 때 따로 쓰기의 빈 제목 · 내용, 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH} · 내용 ${MAX_VACATION_TEXT_LENGTH} · 정화한 서식 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자), 모르는 고르기 · 칸, 글이 아닌 값은 400 VAL_001 — 저장하지 않는다`, async () => { + const before = vacationSaves.length; + const longHtml = `

${'가'.repeat(MAX_VACATION_HTML_LENGTH)}

`; + for (const payload of [ + { ...vacation, external: { ...external, subject: null } }, + { ...vacation, external: { ...external, textBody: ' ' } }, + { + ...vacation, + external: { + ...external, + textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1), + }, + }, + { ...vacation, external: { ...external, htmlBody: longHtml } }, + { ...vacation, htmlBody: longHtml }, + { ...vacation, external: { ...external, mode: 'internal' } }, + { ...vacation, external: { ...external, isActive: true } }, + { ...vacation, external: { mode: 'same' } }, + { ...vacation, external: [] }, + { ...vacation, htmlBody: 1 }, + ]) { + const res = await put(payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(vacationSaves).toHaveLength(before); + + // 꺼 둔 채로는 긴 서식 · 빈 회사 밖 칸도 저장한다(켤 때 본다) + await put({ + ...vacation, + isEnabled: false, + htmlBody: longHtml, + external: { ...external, subject: null }, + }).expect(200); + }); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example';