Compare commits

..

5 Commits

Author SHA1 Message Date
ttipo 19dbb2babb docs: 메일 16단계(개인 설정)를 계약 · 디자인 README · 스킬 · 로드맵에 적는다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 15m17s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 14m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m13s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m54s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m55s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m43s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m39s
계약 2절(서명 · 부재중 — 원본 판 3 과 되돌리기 계획) · 3절(가져오기 · 내보내기) · 5절(휴대폰 바꾸기),
디자인 README 16단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표, 로드맵의 추후 후보(16단계에서 미룬 것).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:43 +09:00
ttipo 907287021f feat(frontend): 주소록 가져오기 · 내보내기 · 여러 서명과 서명 바꾸기 · 부재중 서식 · 휴대폰 바꾸기
메일 16단계(개인 설정)의 화면이다(보드 Contacts16 · Signature16 · Settings16).
- 주소록: 브라우저가 vCard · CSV · 엑셀(.xlsx)을 읽어 미리 보기(파일은 서버로 가지 않는다 — 새 의존성 없이
  DecompressionStream · DOMParser), 네이버 · Google · Outlook 머리를 알아보고 칸 없는 정보는 메모 끝에,
  묶음(100명 · 값 8,000 · 700KB)으로 넣고 ✕까지 남는 결과 알림의 [되돌리기], 내보내기 창.
- 서명: 왼쪽 목록 · 오른쪽 편집 · 메일에 넣을 서명 둘, 쓰기 화면 [서명 ▾]로 서명 자리만 바꾸기
  (앱과 같은 경계 — 앞 빈 줄을 지켜 임시 저장 · 이어 쓰기에서도 찾는다).
- 부재중: 내용 편집기(서식이 있을 때만 서식 본문) · 회사 밖에서 온 메일 응답 셋 · 미리 보기 전환.
- 2단계 인증: [휴대폰 바꾸기] 두 걸음 창(비밀은 창 안에만).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:42 +09:00
ttipo 0c3238936a feat(backend): 주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 2단계 인증 휴대폰 바꾸기
메일 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 <noreply@anthropic.com>
2026-11-10 11:44:41 +09:00
ttipo fceb6ed3e4 docs: 메일 16단계(개인 설정) 보드 셋을 더한다
주소록 가져오기 · 내보내기(Contacts16), 여러 서명과 쓰기 화면 서명 바꾸기(Signature16),
부재중 서식 · 회사 밖 응답과 2단계 인증 휴대폰 바꾸기(Settings16). 캔버스 v74 에서 확정했다 — 글은 화면 상수가 기준이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:55:04 +09:00
ttipo 060265c8b0 docs: 로드맵에 15단계 합침과 16단계 시작을 적는다
15단계(메일 · 메일함 다루기)는 main 에 합쳐 14단계와 함께 배포를 기다리고,
16단계(개인 설정)를 개발하기 시작했다. 16단계 가져오기는 엑셀(.xlsx)도 받는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:53:04 +09:00
174 changed files with 26832 additions and 1439 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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<void> {
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 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<void> {
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
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"`);
}
}
@@ -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<boolean>;
@@ -64,6 +72,17 @@ export interface AccountSecurityProvider {
otpCode: string,
): Promise<TwoFactorDisableResult>;
/**
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
*/
replaceTwoFactor(
user: string,
currentPassword: string,
otpCode: string,
otpauthUrl: string,
): Promise<TwoFactorReplaceResult>;
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
@@ -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({
@@ -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' },
});
}
}
@@ -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);
@@ -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<TwoFactorEnrollmentDto> {
@@ -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<TwoFactorVerifiedDto> {
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<TwoFactorStatusDto> {
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)
@@ -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<TwoFactorReplaceResult>,
[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;
@@ -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<TwoFactorEnrollmentView> {
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<void> {
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<undefined>(
{ 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<TwoFactorStatus> {
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<undefined>(
{ 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<Date | null> {
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,
+4 -1
View File
@@ -320,12 +320,14 @@ export function weightAfterChanges(
export interface WrittenChanges {
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
/** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */
readonly refusedKeys?: ReadonlySet<string>;
}
/**
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(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;
@@ -0,0 +1,291 @@
import {
contactExportContentType,
contactExportFileName,
contactsToCsv,
contactsToVcard,
CSV_HEADER,
foldVcardLine,
type ExportedContact,
} from './contact-export';
const contact = (
overrides: Partial<ExportedContact> = {},
): 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');
});
});
@@ -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<ContactEmailLabel, string> = {
work: 'INTERNET,WORK',
personal: 'INTERNET,HOME',
other: 'INTERNET',
};
const VCARD_PHONE_TYPES: Record<ContactPhoneLabel, string> = {
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<ContactExportFormat, string> = {
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);
}
@@ -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. ' +
'화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 <a href download> 로 받는다.',
})
@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<StreamableFile> {
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,
});
}
}
@@ -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<ContactFields> = {}) => ({
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);
});
});
@@ -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<string>();
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<ContactFields, 'name' | 'phones'>,
): string | null {
const name = fields.name?.trim() ?? '';
return name === ''
? null
: `${name}${KEY_SEPARATOR}${phoneDigitsKey(fields.phones)}`;
}
/** 내 주소록의 "이미 있음" 찾기 자료 — 이메일 모음 · 이름 + 전화 열쇠 모음 */
export interface ImportDuplicateIndex {
readonly emails: ReadonlySet<string>;
readonly namePhones: ReadonlySet<string>;
}
/** 주소록의 연락처들로 찾기 자료를 만든다 — 연락처 수에 비례 */
export function importDuplicateIndex(
contacts: readonly Pick<ContactFields, 'name' | 'emails' | 'phones'>[],
): ImportDuplicateIndex {
const emails = new Set<string>();
const namePhones = new Set<string>();
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<ContactFields, 'name' | 'emails' | 'phones'>,
): 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<string>;
readonly memberCount: number;
}
/** 그룹에 넣을 사람 하나 — uid 와 넣을 그룹 */
export interface GroupJoin {
readonly uid: string;
readonly groupIds: readonly string[];
}
export interface GroupJoinPlan {
/** 그룹 id → 새로 넣을 uid (넣는 차례) */
readonly adds: ReadonlyMap<string, readonly string[]>;
/** uid → 구성원 수 한도가 차서 넣지 못한 그룹 id (보낸 차례) */
readonly skipped: ReadonlyMap<string, readonly string[]>;
}
/**
* 가져온 사람들을 그룹에 넣는 계획 — 보낸 차례대로 넣고, 그룹이 max 명에 닿으면 그 뒤 사람은 그 그룹만 빼고(skipped) 나머지 그룹에는 넣는다.
* 이미 구성원인 uid 는 세지도 넣지도 않는다. 모르는 그룹 id 는 부르는 쪽이 먼저 막는다(여기서는 건너뛴다)
*/
export function planGroupJoins(
rooms: ReadonlyMap<string, GroupRoom>,
joins: readonly GroupJoin[],
max: number,
): GroupJoinPlan {
const counts = new Map<string, number>();
const adds = new Map<string, string[]>();
const skipped = new Map<string, string[]>();
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<string>();
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;
}
@@ -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);
}
}
@@ -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 가 맞는지 본다
@@ -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 },
@@ -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<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
/** 엔진이 받지 않은 만들기의 key — allowPartialCreate 로 쓸 때만 있다(없으면 모두 받았다) */
readonly refusedKeys?: ReadonlySet<string>;
}
| { 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<AddressBookWriteOutcome>;
}
@@ -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<string>();
/** 쓰기마다 받은 선택 */
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<AddressBookWriteOutcome> => {
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<string, RemovedCardOriginal>();
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<string> = new Set(),
): {
createdIds: Map<string, string>;
cards: Map<string, AddressBookCard>;
} {
@@ -173,6 +204,7 @@ class FakeAddressBook implements ContactsProvider {
const createdIds = new Map<string, string>();
const cards = new Map<string, AddressBookCard>();
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> = {},
): 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');
});
});
});
});
@@ -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<T> {
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<string, Loading>();
/** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */
private readonly writeQueues = new Map<string, Promise<void>>();
/** 되돌리기 표의 시계 · 내보낸 파일 이름의 날짜 */
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<ContactImportCheck> {
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<ContactImportResult> {
this.assertImportItems(items);
const result = await this.change(actor, (book) => {
const groups = new Map<string, BookGroup>();
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<string, BookContact>();
/** uid → 이 바꾸기 안의 key */
const keys = new Map<string, string>();
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<string, GroupRoom>(
[...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<ContactsDeleted> {
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<string>();
let pending: readonly string[] = ids;
while (pending.length > 0) {
const batch = pending;
/** 이 몫에서 지우려 했던 id — 엔진이 바쁘다고 했는데 들어갔으면 다음 시도에서 없어진 것을 지운 것으로 센다 */
const tried = new Set<string>();
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<string, string[]>();
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<ContactExportFile> {
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<string>();
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<string>();
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);
@@ -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';
@@ -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;
}
@@ -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<T extends object>(
type: new () => T,
body: object,
stopAtFirstError = false,
): Promise<string[]> {
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<string, unknown> = 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([]);
}
});
});
@@ -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[];
}
@@ -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<string[]> {
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: '<p>김아라</p>', 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: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
}),
).toEqual([]);
expect(
await constraintsOf(UpdateLegacyMailSignatureDto, {
isForNew: 'true',
isForReply: true,
}),
).toEqual(['isBoolean']);
});
});
@@ -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 — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
'어느 서명도 더는 가리키지 않는 그림은 저장할 때 지운다';
const BODY_HTML_EXAMPLE =
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">';
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 — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${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:
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
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',
@@ -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;
@@ -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')
@@ -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;
@@ -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,
{
@@ -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<MailSignatureEntity | null> {
return this.signatures.findOneBy({ userEmail });
/** 그 사람의 서명 — 만든 차례 */
listSignatures(userEmail: string): Promise<MailSignatureEntity[]> {
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<SavedSignature> {
): Promise<SignatureChangeResult> {
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<void> {
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<MailSignatureImageEntity[]> {
return this.images.find({
@@ -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<string, MailSignatureEntity>();
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<MailSignatureEntity | null> {
return Promise.resolve(this.signatures.get(user) ?? null);
listSignatures(user: string): Promise<MailSignatureEntity[]> {
return Promise.resolve(this.mine(user).map((row) => ({ ...row })));
}
listImages(user: string): Promise<MailSignatureImageEntity[]> {
@@ -76,38 +81,91 @@ class FakeRepository {
return Promise.resolve();
}
saveSignature(
change(
user: string,
prepare: PrepareSignature,
plan: PlanSignatureChange,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
if (this.deletedBeforeNextSave) {
const gone = this.deletedBeforeNextSave;
this.deletedBeforeNextSave = null;
): Promise<SignatureChangeResult> {
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<MailSignatureImageEntity> {
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 = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src(NOT_MINE)}">`;
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: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
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: '<p>바꾼 서명</p>',
});
expect(updated).toMatchObject({
id: a.id,
name: '사내 (새)',
body: '바꾼 서명',
bodyHtml: '<p>바꾼 서명</p>',
});
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) => `<img src="${src(id)}" width="96">`;
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: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toBe(
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
);
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: `<img src="${src(kept.id)}">`,
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: `<img src="${src(kept.id)}">`,
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: '<p>그림 없음</p>',
body: null,
isForNew: true,
isForReply: true,
}),
create('사내', { bodyHtml: '<p>그림 없음</p>' }),
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', 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 ['', '<script>x()</script>', 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) => `<img src="${src(id)}" width="96">`;
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: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
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: `<p>사내</p><img src="${src(image.id)}" width="96">`,
});
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: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
});
expect(saved).toMatchObject({
bodyHtml: '<p>김아라</p>',
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',
@@ -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<MailSignature, 'images'> = {
/** 옛 경로 — 「새 메일」 · 「답장 · 전달」 기본 서명이 모두 없을 때 (앱 첫 판은 본문이 없으면 서명을 넣지 않는다) */
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<MailSignatureEntity, 'bodyHtml'>[],
): 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<string, Promise<void>>();
constructor(
@@ -84,76 +178,236 @@ export class MailSignatureService {
private readonly storage: SignatureImageStorage,
) {}
async getSignature(userEmail: string): Promise<MailSignature> {
/** 내 서명 — 만든 차례, 한 번에 모두 (hasNext 늘 false) */
async listSignatures(
userEmail: string,
): Promise<PaginatedResult<MailSignature>> {
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<MailSignature> {
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<MailSignature> {
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<void> {
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<MailSignatureDefaults> {
return defaultsOf(
await this.repository.listSignatures(normalizeUser(userEmail)),
);
}
/** 「메일에 넣을 서명」 둘을 바꾼다 — 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 RES_001 (아무것도 바꾸지 않는다) */
async setDefaults(
userEmail: string,
defaults: MailSignatureDefaults,
): Promise<MailSignatureDefaults> {
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<LegacyMailSignature> {
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<LegacyMailSignature> {
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<string | null> {
if (!html) return null;
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
if (html.length > MAX_BODY_HTML_LENGTH) {
input: { readonly bodyHtml?: string | null; readonly body?: string | null },
): Promise<PreparedSignatureBody> {
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(),
};
}
@@ -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;
@@ -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<void> {
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);
}
}
@@ -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<unknown, string> {
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<unknown, string> {
transform(value: unknown): string {
return serverUuid(value, 'malformed-signature-id');
}
}
@@ -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<string[]> {
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
/** 안쪽 객체(external — 16단계)까지 — `칸.규칙` 모양으로 모두 */
async function allConstraintsOf(body: object): Promise<string[]> {
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: '<p>휴가</p>' }),
).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: '<p>I am away until <strong>Nov 7</strong>.</p>',
};
it('htmlBody · external 은 보내지 않아도 된다(옛 요청) — 보내면 htmlBody 는 글 · null, external 은 네 키(글 칸은 null 가능) · null', async () => {
expect(await allConstraintsOf(enabled)).toEqual([]);
expect(
await allConstraintsOf({
...enabled,
htmlBody: '<p>휴가</p>',
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);
+122 -10
View File
@@ -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:
'<p style="margin: 0">I am out of the office until <strong>November 7</strong>.</p>',
})
@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:
'<p style="margin: 0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
})
@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 = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
@@ -775,4 +775,60 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
}
});
});
/**
* 쓰기 화면의 서명 자리(16단계 — 계약 2절 "앱이 만드는 모양") — 빈 문단 하나 뒤의 **속성 없는 div 하나**가 서명 경계다.
* 웹 편집기도 같은 경계로 서명을 넣고 임시 저장 · 이어 쓰기에서 다시 찾으므로, 보내는 정화 · 임시 저장본 다시 읽기 · 받은 메일 정화를 지나도 남아야 한다
*/
describe('서명 자리 — 빈 문단 뒤 속성 없는 div (16단계)', () => {
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const signatureBlock =
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></div>`;
const composed =
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75">` +
'<p style="margin: 0">안녕하세요.</p><p style="margin: 0"><br></p>' +
signatureBlock +
'<div data-ara-quote-start=""></div>' +
'<blockquote style="margin: 0; padding-left: 12px; border-left: 2px solid #D0D5DD"><p style="margin: 0">원문</p></blockquote></div>';
/** 정화한 서명 자리 — 빈 문단 바로 뒤의 속성 없는 div, 안은 서명 */
const keptBoundary =
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="" /></div>`;
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(
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>',
);
});
it('속성이 모두 지워진 div 도 속성 없는 div 가 된다 — 경계는 자리(빈 문단 바로 뒤)로 알아본다 (화면 · 앱이 함께 보는 알려진 모양)', () => {
for (const div of [
'<div style="">',
'<div style="position: absolute">',
'<div class="sig" data-x="1">',
]) {
expect(
clean(`<p style="margin: 0"><br></p>${div}<p>서명</p></div>`),
).toBe('<p style="margin:0"><br /></p><div><p>서명</p></div>');
}
});
});
});
@@ -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 = `<img src="/api/mail-signature/images/${IMAGE_ID}" width="96">`;
it('보내는 정화를 거친다 — 스크립트 · 이벤트 속성은 지우고, 그림은 넘겨준 서명 그림만 남긴다', () => {
expect(
sanitizeStoredHtml(
`<p onclick="x()"><strong>휴가</strong><script>x()</script></p>${image}`,
{ maxLength: null },
),
).toEqual({ ok: true, html: '<p><strong>휴가</strong></p>' });
expect(
sanitizeStoredHtml(`<p>서명</p>${image}`, {
maxLength: null,
signatureImageIds: new Set([IMAGE_ID]),
}),
).toEqual({
ok: true,
html: `<p>서명</p><img src="/api/mail-signature/images/${IMAGE_ID}" width="96" />`,
});
});
it('없거나 비었거나 정화해서 남는 것이 없으면 null', () => {
for (const html of [null, undefined, '', '<script>x()</script>', ' ']) {
expect(sanitizeStoredHtml(html, { maxLength: 10 })).toEqual({
ok: true,
html: null,
});
}
});
it('정화 뒤 길이는 maxLength 일 때만 본다 — 정화 전 길이 · 파서 비용은 늘 본다', () => {
const long = `<p>${'가'.repeat(10_000)}</p>`;
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(`<p>${'가'.repeat(10_000 - 7)}</p>`, {
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('<b>'.repeat(166_666), { maxLength: null }),
).toMatchObject({ ok: false, problem: 'too-complex' });
});
});
@@ -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<string>;
}
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 };
}
@@ -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:
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
};
const richSource: FilterScriptSource = { ...source, vacation: rich };
const formatOf = (value: FilterScriptSource) =>
(
JSON.parse(
filterSourceLine(value).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>
).v;
const storedV3 = (overrides: Record<string, unknown> = {}) =>
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: '<p>휴가</p>' },
{ ...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<string, unknown>;
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:
'<p onclick="x()">휴가<script>alert(1)</script></p><img src="https://evil.example/x.png">',
external: {
...rich.external,
htmlBody: '<a href="javascript:x()">링크</a><b>굵게</b>',
},
},
}),
),
);
expect(read?.vacation.htmlBody).toBe('<p>휴가</p>');
expect(read?.vacation.external.htmlBody).toBe(
'<span>링크</span><b>굵게</b>',
);
// 정화해서 비면 null — 글만
expect(
sourceOf(
scriptWithJson(
storedV3({ vacation: { ...rich, htmlBody: '<script>x()</script>' } }),
),
)?.vacation.htmlBody,
).toBeNull();
});
it.each<[string, Record<string, unknown>]>([
['회사 밖 응답 없음', { ...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: '<b>'.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;
+122 -25
View File
@@ -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;
@@ -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('앱 비밀번호', () => {
@@ -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<TwoFactorReplaceResult> {
// 켠 채 지금 코드와 새 주소를 한 요청으로 — 엔진이 지금 코드를 확인하고 새 비밀로 바꾼다(옛 비밀은 바로 죽는다 — 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<AppPasswordRecord[]> {
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') {
@@ -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<string, Record<string, unknown>>;
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({
@@ -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>` 패치를 받지 않는다 — 그래서 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<AddressBookWriteOutcome> {
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<string>();
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<string, string>();
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<string> {
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<string, Record<string, unknown>>;
originals: ReadonlyMap<string, RemovedCardOriginal>;
@@ -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)]),
),
};
}
@@ -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: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
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);
@@ -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<MailSettings, 'domain'>,
) {}
/** 지금 규칙 · 지문 · 동작하는지 */
async getFilters(user: string): Promise<MailFilterSet> {
@@ -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 };
}
@@ -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);
@@ -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: '<p>퇴사했습니다.</p>',
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: '<p>퇴사했습니다.</p>',
external,
});
});
it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => {
@@ -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,
});
}
}
+4 -1
View File
@@ -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 },
};
}
@@ -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급한 일은 이도윤 님께.',
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
);
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: '<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
},
]);
});
it('ASCII 만 나오고 경계는 본문 줄과 겹치지 않는다 — 글 · 서식의 ${ · 역슬래시 · 따옴표 · 경계와 같은 글도 base64 안에만 있다', () => {
const entity = alternativeMimeEntity(
'${x} \\ "따옴표" --ara-vacation-alternative',
'<p>${x} --ara-vacation-alternative</p>',
);
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 실배달 메일에서 받은 값)
+31 -1
View File
@@ -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)는 풀 때 뗀다
@@ -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<string, string> =>
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 =
'<p style="margin:0">휴가입니다. <strong>급한 일</strong>은 이도윤 님께.</p>';
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: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
},
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.',
'<p>I am away until <strong>Nov 7</strong>.</p>',
),
);
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 = `<div style="font-family: 'Malgun Gothic', sans-serif">${textBody
.split('. ')
.map(
(sentence) =>
`<p style="margin: 0"><strong>${sentence}.</strong> <a href="https://example.com/">담당자</a></p>`,
)
.join('')}</div>`;
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: [],
+202 -12
View File
@@ -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<SieveExtension>();
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 "…"] "<MIME 개체>";`. 서식이 있으면 글 + 서식 두 벌(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));
@@ -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절)
*/
+201 -16
View File
@@ -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: '<p>I am <strong>away</strong> until Nov 7.</p>',
};
it('서식 본문은 보내는 정화(그림 없음)를 거쳐 넘긴다 — 정화해서 비면 null(글만), 회사 밖 칸도 같다. 로그에는 서식 있음 · 고르기만', async () => {
echo();
const saved = await service.updateVacation(user, {
...vacation,
htmlBody:
'<p onclick="x()">11월 7일까지 <strong>휴가</strong>입니다.<script>x()</script></p><img src="https://evil.example/x.png"><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d">',
external: {
...external,
htmlBody: '<script>x()</script>',
},
});
expect(saved).toEqual({
...vacation,
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
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 = `<p>${'가'.repeat(10_000)}</p>`;
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: '<b>'.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: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
expect(provider.setVacation).toHaveBeenCalledTimes(3);
});
});
});
it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => {
@@ -1814,6 +1992,13 @@ describe('MailService', () => {
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
htmlBody: '<p>휴가입니다.</p>',
external: {
mode: 'none' as const,
subject: null,
textBody: null,
htmlBody: null,
},
isActive: false,
};
provider.getVacation.mockResolvedValue(state);
+70 -2
View File
@@ -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<MailVacationState> {
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;
}
+22 -1
View File
@@ -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단계) */
@@ -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(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+248 -3
View File
@@ -337,7 +337,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다."
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.").
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다."). — **16단계부터 내용 칸은 서식 편집기**(아래 16단계 절).
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다."
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
@@ -750,7 +750,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **꺼져 있을 때** 상자 아래:
- 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크)
- 「켜는 방법」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다.
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다."(**16단계부터 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**) · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸)
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다)
- "찍을 수 없으면 이 키를 입력해 주세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다."
@@ -894,7 +894,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다."
그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로.
- 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(**16단계부터 이 직원도 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**)(나머지 두 줄은 12단계 그대로).
- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다.
직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래).
@@ -1272,6 +1272,168 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기].
## 16단계 — 개인 설정 (2026-11-10 확정 — `Contacts16` · `Signature16` · `Settings16`)
직원이 혼자 할 수 있는 설정을 넓힌다 — 주소록 가져오기 · 내보내기, 여러 서명과 쓰기 화면 [서명 ▾], 부재중 응답의 서식 본문과 회사 밖 응답, 2단계 인증 휴대폰 바꾸기.
서버 약속은 계약 2절("서명" · "부재중 자동 응답") · 3절(주소록) · 5절("2단계 인증")이다. 글의 원본은 화면 상수(`shared/constants/contacts.ts` · `mail-signature.ts` · `mail-vacation.ts` · `account-security.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — [사내 | 회사 밖] 미리 보기 전환 36 은 서명 미리 보기의 `.segmented`(`--size-segment-item-lg`)를 다시 쓴다. 새 아이콘은 upload 하나다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
### 주소록 가져오기 · 내보내기 (`Contacts16`)
다른 메일이나 휴대폰에서 내보낸 연락처 파일을 주소록에 넣고, 내 주소록을 파일로 내려받는다. 서버 약속은 계약 3절("가져오기 · 내보내기").
새 크기 토큰 · 밀도 예외는 없다(머리 52 · 단추 44 그대로). 새 아이콘은 올리기(`upload`) 하나다.
- **자리**: 「전체 연락처」 목록 머리 오른쪽에 [가져오기] · [내보내기](도구 단추 — 그룹 머리의 [구성원 넣기]와 같은 꼴). 그룹 · 사내 목록의 머리는 그대로다(그룹 하나만 내보낼 때는 내보내기 창에서 고른다).
- 연락처가 하나도 없으면 [내보내기]를 끄고(`aria-disabled`) 까닭 "내보낼 연락처가 없습니다."(마우스를 올리면 뜨는 글 · 화면 낭독).
- 연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로 쓴다. 가져오는 동안(되돌리기 포함)은 [가져오기]가 바쁜 표시(`aria-busy`)이고 눌러도 아무 일이 없다.
- 빈 주소록 안내: [연락처 추가] 옆에 테두리 단추 [가져오기], 그 아래 "다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다." + 지금 줄("메일을 읽다가 …").
- **가져오기 창 ① 파일 고르기**(넓은 창 620): 제목 "연락처 가져오기" · "네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다."
- 놓는 자리(168 · 회색 점선): 올리기 아이콘 · [파일 고르기](처음 초점 — .vcf · .csv · .xlsx 만 보이게 걸러 둔 파일 고르는 창) · "파일을 여기로 끌어다 놓아도 됩니다."
- 창 어디에 끌어다 놓아도 받는다 — 끄는 동안 자리가 2px 청록 점선 · 옅은 청록 바탕, "놓으면 이 파일에서 연락처를 찾습니다". 창 밖 주소록 화면에 놓은 파일은 지금처럼 무시한다.
- 읽는 동안: 도는 아이콘 · "파일을 읽는 중…" · 파일 이름. 그사이 [취소]는 읽기를 멈추고 창을 닫는다.
화면 낭독기에는 창을 열 때부터 있는 상태 영역(`role=status` — 글만 바꾼다)이 같은 글을 읽는다(글과 함께 생기는 영역은 읽지 않기도 한다).
읽다가 뜻밖에 멈추면(깨진 파일 등) "읽는 중"에 머물지 않고 "열지 못했습니다" 글로 돌아온다.
- 아래 "vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다." · [취소].
- 읽지 못하면 자리 아래 빨간 글로만(알림 없음) — 다른 파일을 고르거나 놓으면 지운다:
- 넣을 수 없는 형식 "넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요."
- 옛 엑셀 "옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요."
- 20MB 넘음 "파일이 너무 큽니다. 파일 하나는 20MB까지 넣을 수 있습니다."(읽지 않고 바로)
- 연락처를 찾지 못함 "이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요."(vCard 는 앞 문장만)
- 열지 못함 "이 파일을 열지 못했습니다. 암호를 건 엑셀 파일이면 암호를 푼 뒤 넣어 주세요."
- 여러 파일 "파일은 한 번에 하나만 넣을 수 있습니다."
- 형식은 이름 끝과 파일 앞부분을 함께 본다 — 이름이 .csv 여도 안이 엑셀이면 엑셀로, 안이 옛 엑셀(.xls)이면 .xls 글.
- **가져오기 창 ② 미리 보기**: 파일 이름 + [다른 파일 고르기](파일 고르는 창을 바로 연다 — 고른 그룹과 체크 칸은 그대로) · "연락처 340명을 찾았습니다."(넣지 못하는 줄까지 센 수).
- 세 묶음(테두리 상자 — 글과 숫자로만, 뱃지 없음):
- "넣을 연락처 · 330명" — 앞 6명의 표(이름 64 · 이메일 · 전화 96 · 회사 · 그룹 76) + "그 밖에 324명".
- "이미 있는 연락처는 건너뜁니다 · 8명" — 눌러 펼친다(`aria-expanded`). 줄마다 "4번째 줄 · 박지훈 jihoon.park@example.com · 주소록에 있음"(또는 "2번째 줄과 같음").
- "넣지 못하는 줄 · 2개" — 눌러 펼친다. "57번째 줄 · 010-9034-6621 · 이름과 이메일이 모두 없습니다."(까닭은 갈색).
- 펼친 목록은 6줄 높이(186)까지 늘고 넘으면 목록 안에서 넘긴다. 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 "12번째 연락처"이고 묶음 이름이 "넣지 못하는 연락처"다.
- 그룹: "파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다."(이름이 많으면 앞 셋과 "외 N개", 없으면 "파일에 적힌 그룹이 없습니다.").
- 「가져온 연락처를 모두 넣을 그룹」: 넣지 않음(처음) · 이미 있는 그룹(고르기 칸 280×44 — "거래처 · 4명") · 새 그룹(이름 칸 280×44 · 50자) + "파일에 적힌 그룹에 더해 넣습니다."
새 그룹 이름이 이미 있는 그룹과 같으면 그 그룹에 넣는다. 이름을 비운 채 넣으려 하면 칸 아래 "그룹 이름을 넣어 주세요." 그룹이 100개면 「새 그룹」을 끄고 "그룹은 100개까지 만들 수 있습니다."
- 체크 칸 「주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기」(처음 켜짐) + "메모가 1,000자를 넘게 되면 붙이지 않습니다." —
켜 두면 견본 "예: 강민지의 메모"(원래 메모는 흐리게 · 빈 줄 · "가져온 정보" · "회사 주소: 서울 중구 세종대로 110" · "생일: 1988년 3월 14일 (음력)" · "홈페이지: …" · "별명: …").
- 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼.
- [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다."
- Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다).
- 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다).
건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다."
- [되돌리기]: "가져온 연락처를 지우는 중 · 100 / 330명" → "가져온 연락처 330명과 새로 만든 그룹 2개를 지웠습니다."(새 그룹이 없으면 "가져온 연락처 330명을 지웠습니다.").
이번에 넣은 연락처와 이번에 새로 만든 그룹만 지운다 — 원래 있던 그룹에 넣은 것은 연락처를 지우며 함께 빠진다. 되돌리다 멈추면 "가져온 연락처 330명 가운데 100명을 지우고 멈췄습니다. 다시 시도해 주세요." + [다시 시도].
- 멈춤: 흰 오류 알림 "연락처 330명 가운데 120명을 넣고 멈췄습니다. 다시 시도해 주세요." + [다시 시도](남은 연락처부터 — 줄마다 화면이 붙인 표시(uid)가 있어 두 번 들어가지 않는다),
처음부터 넣지 못했으면 "연락처를 넣지 못했습니다. 다시 시도해 주세요." 멈춘 알림에는 [되돌리기]가 없다 — 다시 시도해 끝낸 뒤의 [되돌리기]가 이번에 넣은 연락처를 모두 지운다.
알려진 한계: 멈춘 알림을 다시 시도하지 않고 닫으면 그때까지 넣은 연락처는 남는다(같은 파일을 다시 넣으면 이미 있는 연락처로 건너뛴다).
- 넣는 동안 다른 화면으로 가도 이어 넣는다. 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 끝나거나 멈추면 연락처 수 · 그룹 목록 · 보고 있는 목록을 다시 받는다.
실패 까닭(연결 · 서버)은 지금처럼 오류 사전의 글로 알리고 진행 알림은 결과로만 바뀐다.
- **내보내기 창**(400 — 새 그룹 창과 같은 폭): 제목 "연락처 내보내기".
- 「내보낼 연락처」 내 주소록 전체("연락처 342명" — 처음) · 그룹 하나("고른 그룹의 구성원만" — 고르면 그룹 고르기 칸 44, 첫 그룹이 골라진다, "거래처 · 24명") + "내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다."
- 「파일 형식」 vCard (.vcf) "다른 메일이나 휴대폰으로 옮길 때"(처음) · CSV (.csv) "엑셀에서 열어 볼 때". 고른 상자는 청록 테두리 · 옅은 청록 바탕.
- "파일 이름 주소록-2026-11-10.vcf"(그룹이면 "주소록-거래처-2026-11-10.csv" — 날짜는 이 PC 의 날짜, 파일 이름에 쓸 수 없는 글자는 _).
- 구성원이 없는 그룹을 고르면 [내보내기]를 끄고(`aria-disabled`) 그룹 칸 아래 "‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다." — 다른 그룹을 고르면 바로 돌아온다.
- [내보내기] [취소] — 누르면 창을 닫고 바로 내려받는다(첨부 받기처럼 이동 직전에 로그인을 확인, 파일은 서버가 만든다). 따로 알림은 띄우지 않는다.
- **받는 파일 · 칸 맞추기**: vCard 2.1 · 3.0 · 4.0(한 파일에 여러 명), CSV(네이버 · Google 새 · 옛 머리 · Outlook · 우리가 내보낸 꼴을 머리 줄로 알아보고, 모르는 꼴은 열 이름으로 짐작), 엑셀 .xlsx(첫 시트 · 첫 줄이 머리).
CSV 글자는 BOM, UTF-8, 아니면 EUC-KR(한국어 엑셀의 CSV) 차례. 성 + 이름은 이름 하나로(한글끼리는 붙여 쓴다 — 강 + 민지 = 강민지), 이메일과 전화는 종류대로(팩스와 직접 입력한 번호는 기타),
전화는 하이픈을 넣는다(010-1234-5678). 이미 있는 연락처는 같은 이메일(앞뒤 공백 · 대소문자 무시), 이메일이 없으면 이름과 전화번호가 모두 같을 때다.
### 설정 › 메일 › 서명 — 여러 서명 (`Signature16` 1~5)
서버 약속은 계약 2절 "서명"(16단계 — `/api/mail-signatures` · `/api/mail-signature-defaults`)이다. 글의 원본은 화면 상수(`shared/constants/mail-signature.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — 목록 줄 52 는 `--size-header-min`, 칸 · 단추 44 는 보통 크기다.
- **배치**: 머리(제목 "서명" · 설명) → 「메일에 넣을 서명」(서명이 있을 때만) → 왼쪽 「내 서명」 목록(208) · 오른쪽 고른 서명 고치기(이름 · 서명 칸 · 안내 · [저장] … [이 서명 지우기] · 가는 선 · 미리 보기). 콘텐츠 열은 다른 설정 화면과 같은 800.
목록과 고치는 칸을 담은 칸이 640보다 좁으면 목록을 위로 올려 칸 폭을 쓰게 하고 고치는 칸을 그 아래에 둔다 — 창 폭이 아니라 그 칸의 폭을 본다(컨테이너 쿼리).
설명은 "보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다."
- **목록**: 만든 차례 · 줄 52 · 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자(aria-current). 이름 아래 회색 12 는 그 서명을 기본으로 넣는 메일("새 메일" · "답장과 전달" · "새 메일, 답장과 전달" — 뱃지 없음).
목록 아래 [새 서명](글자 단추 44) · "서명 3개를 만들었고, 10개까지 만들 수 있습니다." — 10개면 [새 서명]은 누를 수 없고(aria-disabled — 초점은 받는다) 글이
"서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요."로 바뀐다(단추가 aria-describedby 로 가리킨다). 화면 낭독기는 목록을 「내 서명」으로, 고른 줄을 지금 항목으로 읽는다.
- **처음 고른 서명**: 새 메일에 넣는 서명, 새 메일이 넣지 않음이면 첫 서명.
- **메일에 넣을 서명**(지금의 두 체크를 대신한다): 칸 이름 84 · 고르기 240(서명 이름들 · 넣지 않음) · 아래 "메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다."
고르는 대로 저장하고, 끝나면 고른 칸 오른쪽에 초록 ✓ "저장했습니다"(role=status) — 다시 고르면 내린다. 저장은 한 번에 하나(보내기 · 읽기 설정과 같은 결 — 보내는 동안 고른 것은 끝난 뒤 다르면 보낸다),
끝내 저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림. 고른 것은 나가기 확인의 까닭이 아니다.
- **이름**: 칸 320(좁으면 칸 폭) · 30자에서 더 들어가지 않는다 · 아래 "메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다."
[저장]할 때 비었으면(앞뒤 공백을 뺀다) "이름을 넣어 주세요.", 다른 서명과 같으면(영문 대소문자 무시) "같은 이름의 서명이 있습니다." — 안내 대신 빨간 글 · 칸 빨간 테두리(aria-invalid) · 이름 칸에 초점, 알림은 없다.
이름을 고치면 안내로 돌아간다. 저장하면 이름은 앞뒤 공백을 뺀 글로 바뀐다.
- **[저장] 뒤 글**: 기본으로 넣는 서명이면 지금 글 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 아니면 "저장했습니다." — 무엇이든 다시 바꾸면 내린다.
- **편집기 · 그림 · 미리 보기**는 지금 서명 화면 그대로(그림 서명마다 3장 · 크기 줄 · 화면 어디에 놓은 그림은 지금 고른 서명에). 미리 보기는 고른 서명이 [새 메일 | 답장]에 들어갈 자리를 늘 보인다 —
아래 글 "새 메일은 본문 끝에 서명을 넣습니다." · "답장과 전달에는 인용한 원문 위에 서명을 넣습니다."(그 메일에 넣을지는 「메일에 넣을 서명」이 정한다).
- **서명이 없을 때**(2): 점선 상자 안 펜 아이콘 36 · "아직 서명이 없습니다" · "보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다."(최대 400) ·
[새 서명](채운 단추). 「메일에 넣을 서명」 칸과 목록은 없다(고를 서명이 없다). 지금 서명을 쓰던 직원은 서버가 그 서명을 「기본 서명」으로 옮겨 두어 1의 모습으로 시작한다.
- **새 서명**(3): 목록 끝에 "새 서명 / 저장하지 않음" 줄 · 빈 이름 칸(안 글 "예: 사내, 대외, 영문")에 초점 · 빈 서명 칸 · [저장] [취소]. 처음 [저장]에 만들고 그 뒤로는 같은 칸에서 고친다(쓰던 자리 · 실행 취소 기록이 남는다).
첫 서명이면 두 곳의 기본이 그 서명이 된다(서버가 정한다). 그 밖의 새 서명은 기본을 바꾸지 않는다. [취소]는 새 줄을 빼고 전에 고른 서명을 연다(서명이 없었으면 2의 화면). 새 서명을 연 채 [새 서명]을 누르면 이름 칸으로 간다.
- **지우기**(4): [이 서명 지우기](빨간 글자 단추 · [저장]과 떨어뜨려 오른쪽 끝) → 확인 창(폭 360 · 처음 초점 [취소] · Esc 도 [취소]) "‘대외’ 서명을 지울까요?" +
기본이던 곳마다 다른 글(새 메일 · 답장과 전달 · 둘 다 · 기본 아님 "지운 서명은 되돌릴 수 없습니다.") · [지우기](빨강 채움) [취소].
지우면 알림 "‘영문’ 서명을 지웠습니다." · 목록의 첫 서명을 연다(초점은 그 줄) — 마지막 서명이면 2의 화면(초점은 [새 서명]). 그 서명을 기본으로 넣던 곳은 넣지 않음이 되고 다른 서명이 저절로 기본이 되지 않는다.
- **저장하지 않은 것이 있을 때**(5): 다른 서명을 고르면 "다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.", [새 서명]이면 "새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다."
— 제목 "저장하지 않은 서명이 있습니다" · [계속 고치기](처음 초점 · Esc) [바꾼 내용 버리기]. 바꾼 것이 없으면 묻지 않는다.
다른 화면으로 갈 때는 지금 나가기 확인 그대로("이 화면을 나가면 바꾼 서명이 사라집니다." · [계속 고치기] [나가기] — 저장하는 중 · 세션이 끝났을 때는 묻지 않고, 탭을 닫으면 브라우저가 묻는다).
### 쓰기 화면 [서명 ▾] (`Signature16` 6)
- **자리**: 아래 단추 줄 [보내기 ▾] [임시 저장] **[서명 ▾]** — 서명은 서식이 아니라 메일 전체에 붙는 것이라 편집기 툴바(좁은 폭에서 접는 차례)에 넣지 않았다. [임시 저장]과 같은 글자 단추(44)에 ▾ 12.
화면 낭독은 툴바의 글꼴 단추처럼 지금 서명까지 — "서명: 대외" · "서명: 넣지 않음"(본문의 서명 자리가 어느 서명과도 같지 않으면 "서명"만). 편집기를 불러오는 중 · 보내는 중에는 누를 수 없다.
- **메뉴**(편집기 메뉴 — 항목 40 · 폭 220, 긴 이름은 끝을 …): 서명(만든 차례 · 지금 본문에 든 서명에 ✓ · "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리. 서명이 없으면 넣지 않음과 서명 관리만.
키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점(마우스로 열면 메뉴 자체), Esc 는 메뉴만 닫고 [서명 ▾]로. 아래 자리가 모자라면 위로 연다(아래 줄에서는 보통 위).
- **고르면** 본문의 서명 자리만 그 서명으로 바뀐다 — 쓴 글 · 인용 · 첨부는 그대로이고, 서명 안을 고쳤다면 고친 것도 새 서명으로 바뀐다. 실행 취소(Ctrl+Z)로 되돌린다. 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리를 지운 뒤 서명을 고르면 처음 넣는 자리(새 메일은 쓴 글 끝, 답장과 전달은 인용 위)에 빈 줄 하나 뒤로 넣는다.
- **처음 넣는 서명**: 새 메일은 「새 메일」 기본, 답장 · 전달은 「답장과 전달」 기본(넣지 않음이면 넣지 않는다). 이어 쓰는 임시 저장본에는 다시 넣지 않는다 — 서명 자리의 글이 저장한 서명과 같으면 그 서명에 ✓, 다르면 ✓ 없이.
- **서명 관리**: 설정 › 메일 › 서명으로 — 지금 나가기 규칙대로 먼저 임시 저장하고 저장하지 못했을 때만 묻는다.
- 받는 사람이 회사 밖이어도 저절로 바꾸지 않는다(처음 판 — 직원이 고르는 대로).
### 설정 › 메일 › 부재중 자동 응답 — 서식 본문 · 회사 밖 응답 (`Settings16` 1~4)
- 사용 · 기간 · 제목 칸 · [저장] · 저장한 뒤의 글은 4단계 그대로다.
- **내용 칸은 서명 칸과 같은 편집기**(지연 로딩 — 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도]): 툴바 52 · 굵게 · 기울임 · 밑줄 | 글자 색 · 링크(단추 40×44 — 그림 단추 없음, 붙여 넣은 그림 · 파일은 받지 않는다) ·
본문 맑은 고딕 14 · 줄 간격 1.75 · 높이 150. 툴바의 화면 낭독 이름 "자동 응답 서식". 칸 아래 안내 "굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다."
- 글자 수는 칸 이름 오른쪽 "132 / 600"(12 · 회색 — 주소록 메모와 같은 자리). 서식을 뺀 보이는 글로 센다(받는 쪽의 글자 본문과 같은 글 — 링크는 "글 (주소)"). 켜 둔 채 600자를 넘으면 굵게 빨갛게.
- 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크)을 쓴 칸만 받는 사람에게 서식 본문과 글자 본문을 함께 보내고, 글만 쓴 칸은 글자 본문만 보낸다(서식 본문 null — 사내 · 회사 밖 칸 따로, 끄면서 하는 저장도 같다).
서버는 서식 본문 · 회사 밖 응답을 쓴 직원만 새 원본 판으로 저장한다 — 글만 쓰는 직원은 옛 판 그대로라 되돌리기 계획의 전제가 지켜진다(16단계 리뷰).
16단계 전에 저장한 글만 있는 응답은 줄마다 문단으로 열린다(빈 줄은 빈 문단).
- **회사 밖에서 온 메일**(내용 칸과 [저장] 사이 · fieldset): "보낸 사람 주소가 회사 메일 주소(@arasystem.it.kr)가 아닌 메일입니다."(도메인은 내 주소의 것) + 고르기 셋 —
보내기 · 읽기 설정의 외부 이미지 칸과 같은 모양(라디오 18 · 이름 굵게 · 아래 설명 12.5 · 줄 44):
사내와 같은 응답("회사 안팎 모두 위의 제목과 내용으로 답합니다." — 처음 값) · 보내지 않기("회사 밖에서 온 메일에는 자동으로 답하지 않습니다.") · 따로 쓰기("회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.").
칸이 열리는 따로 쓰기가 맨 끝이다.
- 따로 쓰기를 고르면 바로 아래(고르기 글자 선 28 에 맞춰 들여 씀 · 사이 18)에 "회사 밖에 보낼 제목"(최대 폭 520 · 150자) · "회사 밖에 보낼 내용"(같은 편집기 · 툴바 이름 "회사 밖 자동 응답 서식" · 같은 글자 수 · 아래 안내 없음).
위의 제목 · 내용은 이때부터 사내에서 온 메일에만 간다.
- 다른 고르기로 바꾸면 두 칸을 닫지만 써 둔 글은 남겨 두고 함께 저장한다 — 다시 따로 쓰기를 고르면 그대로 나온다.
- 자동 응답을 끄면 이 묶음도 흐려진다(고르기 글 회색 · 칸은 다른 칸처럼).
- **미리 보기**: 머리 오른쪽 전환 [사내 | 회사 밖](36 · 화면 낭독 이름 "미리 볼 답장" — 처음은 사내 쪽, 사내와 같은 응답일 때도 둔다). 칸을 고친 값을 받는 사람이 보는 서식 그대로 바로 그린다.
아래 글: 사내와 같은 응답 — 사내 쪽 "받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다."(지금 글) · 회사 밖 쪽 "회사 밖에서 온 메일에도 같은 글로 답합니다. …" /
보내지 않기 · 따로 쓰기 — 사내 쪽 "사내에서 온 메일에 위 글로 답합니다. …" · 따로 쓰기의 회사 밖 쪽 "회사 밖에서 온 메일에 위 글로 답합니다. …"(제목 · 내용은 회사 밖에 보낼 것) /
보내지 않기의 회사 밖 쪽은 답장 대신 회색 상자(최소 높이 120) "회사 밖에서 온 메일에는 자동으로 답하지 않습니다." · 아래 글 없음 / 꺼 두었으면 두 쪽 모두 "꺼져 있어 자동으로 답하지 않습니다."
- **켤 때 검사**(켠 채 [저장]일 때만 — 꺼 둔 채로는 빈 칸 · 긴 글도 저장한다): 보는 차례 기간 · 제목 · 내용 · 회사 밖에 보낼 제목 · 회사 밖에 보낼 내용, 첫 칸으로 초점. 알림은 띄우지 않는다.
- 빈 칸은 사전 문구(`VAL_001`). 내용이 600자를 넘으면 칸 아래 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수(회사 밖에 보낼 내용 칸도 같은 글).
- 글은 600자 안인데 서식이 많아 서식 본문이 10,000자를 넘으면 사전 문구(`VAL_001` — 서명과 같은 한도).
- 편집기 칸은 빨간 테두리와 aria-invalid(설명은 오류 → 안내 차례). 그 칸을 고치기 시작하면 아래 글이 사라진다.
- 회사 밖 두 칸은 따로 쓰기일 때만 본다 — 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다. 서버가 거절하면 지금처럼 [저장] 옆에 사전 문구.
- 서버가 `MAIL_013`(필터 · 차단 · 부재중을 담는 메일 규칙이 한도를 넘음)으로 거절하면 같은 자리에 "자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요."(알림 없음) —
부재중은 서식 본문이 두 번 들어가 필터가 없어도 넘을 수 있어 사전 문구("필터나 차단 목록이 …") 대신 이 글이다(16단계 리뷰). 저장하는 사이 화면을 떠났으면 같은 글을 알림으로.
### 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (`Settings16` 5~8)
- **켜져 있을 때** 상태 상자의 [2단계 인증 끄기] 바로 앞에 [휴대폰 바꾸기](테두리 44 · 아이콘 없음). 회사 정책으로 끌 수 없는 직원도 "회사 정책에 따라 끌 수 없습니다" 앞 같은 자리에 있다. 꺼져 있을 때는 없다(켜기 창으로).
아래 안내의 첫 줄은 두 직원 모두 "휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다."(둘째 · 셋째 줄은 그대로).
- **바꾸기 창**(모달 620 — 켜기 창과 같은 폭) "2단계 인증 휴대폰 바꾸기" · 제목 아래 차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인"(12.5 · 글로만 — 지금 걸음 청록 굵게 · 마친 걸음 앞 초록 체크 · 남은 걸음 회색, 뱃지 · 동그라미 숫자 없음).
- **① 새 휴대폰 등록**: "새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다." →
1 "새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요"(QR 160 · 키 4자씩 두 줄 · [복사] 36 — 켜기 창과 같은 부품) + "바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요." /
2 "새 휴대폰의 앱에 표시된 6자리 코드" → [다음] [취소]. 처음 초점은 코드 칸. 키는 창을 열 때마다 새로 만들고 QR · 키는 이 창 안에만 두었다가 닫으면 지운다.
[다음]은 새 휴대폰의 코드부터 확인한다(확인하는 동안 "확인하는 중…") — 맞으면 ②. 그동안 2단계 인증은 지금 휴대폰으로 그대로 켜져 있다.
- **② 지금 휴대폰으로 확인**: "새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다." →
"지금 휴대폰의 앱에 표시된 6자리 코드"(처음 초점) · "지금 비밀번호"(최대 320 · 보기 단추) → 위에 선을 둔 문의 줄 "지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요."
(로그인 화면의 문의 줄과 같은 꼴 — 연락처가 있으면 다음 줄에) → [이전](왼쪽 · ‹) · [바꾸기] [취소]. 바꾸는 동안 "바꾸는 중…" · [바꾸기] · [이전] aria-disabled.
[이전]은 ①로(QR · 키는 그대로 · 새 휴대폰의 코드 칸은 비운다 — 같은 코드는 2분 동안 다시 받지 않으니 앱의 다음 코드를 넣는다).
- **틀렸을 때**(알림 없이 그 칸 아래 사전 문구 · 그 칸에 초점 · 틀린 코드는 지운다): ① 새 휴대폰의 코드 `AUTH_006` / ② 지금 비밀번호 `AUTH_005` · 지금 휴대폰의 코드 `AUTH_006`(서버가 비밀번호를 먼저 보고 한 칸만 알린다).
보내기 전에 6자리가 아니면 `AUTH_007` 문구, 비밀번호가 비었으면 `VAL_001` 문구(칸 차례 — 코드 → 비밀번호). 시도가 너무 많으면 `REQ_001` 알림.
- **10분이 지남 · 더 새 등록**(`AUTH_008` — ① · ② 어느 쪽이든): ①로 돌아와 창 위 빨간 상자에 사전 문구, 새 QR · 키로 다시 그리고 코드 칸을 비운다. 그사이 다른 곳에서 2단계 인증이 꺼졌으면 창을 닫고 사전 문구 알림(화면은 「꺼짐」).
- 세션이 바뀌면(로그아웃 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 창을 닫는다(켜기 창과 같다).
- **바꾼 뒤**: 창을 닫고 단추 없는 알림 "2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다." · 초점은 [휴대폰 바꾸기]. 화면 · 켠 때(「2026년 10월 31일부터 켜짐」)는 그대로다.
[취소] · Esc 로 닫아도 서버가 이미 바꿨으면 화면을 맞추고 같은 알림을 띄운다. 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
### 처음 판에서 미룬 것
주소록: 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기, 사내 목록 내보내기.
서명: 받는 사람에 따라 저절로 바꾸기, 휴대폰 앱에서 서명 고르기. 부재중: 「내 연락처에게만」, 그림 넣기. 2단계 인증: 복구 코드, 바꿀 때 알림 메일 · 계정 변경 기록.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1754,3 +1916,86 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
- 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다.
- 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다).
### 16단계 구현하며 정한 보완 — 주소록 (2026-11-10 — 캔버스에는 아직 없다)
- **파일은 이 브라우저에서 읽는다**(`frontend/src/shared/utils/contact-import/` — 순수 함수 · 새 의존성 없음): 글자 `text.ts` · CSV `csv.ts` · vCard `vcard.ts` · zip `zip.ts` · 엑셀 `xlsx.ts` · 머리 줄 → 칸 `table.ts` ·
값 다듬기 `values.ts` · 넣을 · 이미 있는 · 넣지 못하는 `records.ts` · 요청 묶음 `batches.ts` · 형식 가르기 `read-file.ts`.
모두 입력 길이 · 줄 수에 비례하게 읽는다(2026-11-10 리뷰 — 조각은 배열에 모아 한 번에 잇고, 큰 배열을 `push(...)` 로 펼쳐 넘기지 않고, 구분자 둘레 빈칸은 정규식이 아니라 나눈 뒤 trim).
- 엑셀은 zip 가운데 목록 → `DecompressionStream('deflate-raw')` → `DOMParser`(공유 문자열 · 첫 시트 · 칸 안 문자열). 첫 시트는 workbook 의 첫 `<sheet>`(관계 파일로 시트 파일을 찾는다).
푼 크기가 항목 하나 64MB · 모두 96MB 를 넘는 순간 멈춘다(압축 폭탄 — 목록에 적힌 크기는 믿지 않는다). DOCTYPE · ENTITY 가 든 XML · 암호를 건 항목 · ZIP64 는 "열지 못했습니다".
열은 XFD(16,384번째)까지만 읽고 넘는 칸은 버린다. 늘어놓는 칸 수(사이의 빈 칸 포함)의 합이 400만을 넘으면 "열지 못했습니다" — 먼 열에 칸 하나씩 둔 줄이 많으면 줄마다 빈 칸 16,384개로 부풀기 때문이다.
- 옛 오피스 파일(CFB)은 안의 목록 이름으로 가른다 — 암호를 건 .xlsx(EncryptedPackage)는 "열지 못했습니다", Workbook · Book 이 있으면 .xls 글, 그 밖은 "넣을 수 없는 파일".
- 2.1 QUOTED-PRINTABLE 은 줄 끝 "=" 로 접힌 줄을 먼저 바이트로 이은 뒤 CHARSET 대로 푼다(삼성 안드로이드는 한글 한 글자를 두 줄에 나눈다). 아이폰의 묶음 라벨(`item1.X-ABLabel`)로 종류를 정한다.
사진 · 로고 · 소리 · 열쇠(PHOTO · LOGO · SOUND · KEY)는 이어지는 줄까지 모으지 않고 건너뛴다(사진 든 vCard 는 이어지는 줄이 수만 개다).
- 'cp949' 라벨은 쓰지 않는다(RangeError) — EUC-KR 라벨이 브라우저에서 CP949 전체다. Node(ICU)의 euc-kr 은 CP949 확장 글자(똠 등)를 모르므로 시험은 KS X 1001 글자만 쓴다.
- **머리 줄 알아보기**: 머리 이름은 공백 · 기호를 빼고 소문자로 견준다("E-mail 1 - Value" → email1value, "회사·소속명" → 회사소속명).
네이버 열 이름의 띄어쓰기 · 괄호가 조금 달라도 낱말(휴대폰 · 회사 · 집 · Fax · 직접입력 · 생일 · 양력음력 · 기념일 · 우편번호 · 주소 · 메신저 …)로 알아본다.
"번호" 한 낱말 열은 전화로 보지 않는다(일련번호 열이 흔하다 — 네이버의 회사번호 · 집번호 · 기타번호는 따로 안다). Outlook 의 "Title"은 경칭(Mr. · Ms.)이라 쓰지 않고 "Job Title"이 직책이다.
네이버 CSV 처럼 값 대부분이 앞에 '를 달았으면 모두 떼고, 그 밖은 엑셀 수식 막기로 붙인 '(뒤가 = + - @)만 뗀다.
- **이름**: 성이 한글 · 한자 · 가나면 성 다음 이름(한글끼리는 붙여 쓴다), 그 밖은 이름 · 중간 이름 · 성 차례로 띄운다(영문 "John Smith").
- **종류**: CSV 의 종류를 모르는 이메일 칸은 회사(연락처 추가 창의 처음 값과 같다), vCard 의 종류가 없는 EMAIL 은 기타(우리 내보내기가 기타를 INTERNET 만으로 쓰므로 다시 가져와도 같다).
종류를 모르는 전화 칸(전화번호 · 연락처 · Google 의 빈 라벨)은 번호로 짐작한다 — 국내 휴대전화(01x)면 휴대전화, 아니면 기타. 엑셀이 숫자로 바꿔 앞의 0 이 빠진 휴대전화(1012345678)는 0 을 되살린다.
- **넣지 못하는 줄의 까닭** 차례는 이메일 형식 → 이름과 이메일이 모두 없음 → 한 줄 칸 100자 → 메모 1,000자. 한 줄 칸은 어느 칸인지를 앞에 붙인다("이름은 100자까지 쓸 수 있습니다." —
연락처 창은 칸 아래라 칸 이름이 없지만 목록에서는 가를 수 없어서). 이메일은 연락처 창과 같이 서버보다 좁게 본다. 한 연락처 안의 같은 이메일은 하나만, 이메일 · 전화는 20개까지.
- **형식에 맞지 않는 전화번호**(내선 · 글자가 든 번호 — "02-555-0147 내선 3")는 버리지 않고 가져온 정보의 "전화: …" 줄로 둔다(체크 칸을 끄면 넣지 않는다). 전각 숫자 · 여러 가지 줄표는 보통 글자로 고친다.
- **가져온 정보 줄**: "회사 주소 · 집 주소 · 기타 주소 · 주소(네이버 주소직접입력은 그 이름을 앞에) · 생일 · 기념일 · 홈페이지 · 별명 · 메신저 · 전화". 날짜는 "1988년 3월 14일"(해를 모르면 "3월 14일", 음력이면 뒤에 " (음력)"),
한글 주소는 큰 곳부터 띄어 쓰고 영문은 작은 곳부터 쉼표로, 우편번호는 뒤에 괄호로, 우리나라 이름은 쓰지 않는다.
값은 한 줄 글로 다듬는다(탭 · 줄바꿈 · 제어 문자는 띄어쓰기 — vCard 별명 · 형식이 틀린 전화 등), 붙인 메모는 연락처 창과 같은 메모 정리를 한 번 더 지난다 —
서버는 메모의 줄바꿈 밖 제어 문자를 받지 않아 하나라도 남으면 그 묶음 전체가 400 이다.
- **미리 보기**: 빈 묶음(이미 있는 연락처 0 · 넣지 못하는 줄 0)은 두지 않는다. 넣을 연락처가 없으면 표 · 그룹 · 메모 칸도 두지 않는다. 칸이 없는 정보가 든 연락처가 없으면 메모 체크 칸을 두지 않는다.
그룹 글은 이미 있는 그룹만이면 "파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.", 새로 만들 그룹만이면 "파일에 적힌 그룹 동호회, 학교는 새로 만듭니다."(보드는 섞인 꼴만).
vCard 의 넣지 못하는 수는 "N명". 그룹이 하나도 없으면 「이미 있는 그룹」을 끈다(`aria-disabled`).
- **겹침 열쇠는 서버와 같다**(짝: backend `modules/contacts/contact-import.ts` 의 `phoneDigitsKey` · `namePhonesKey`): 이메일이 없으면 이름(앞뒤 공백만 뺌) + 전화 숫자 모음 —
번호마다 숫자만 남기고, 숫자가 없는 번호는 빼고, 겹침을 없앤 뒤 차례로 잇는다(같은 번호를 두 번 적은 줄도 한 번 적은 줄과 같은 사람이다).
- **요청 묶음**: 확인 · 넣기 · 되돌리기 본문은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(`batches.ts` — 값은 서버 `json-shape.ts` 와 같은 셈, 바이트는 UTF-8 길이).
칸을 한도까지 채운 연락처(이메일 · 전화 20개씩) 100명은 값 13,000개 남짓이라 61명 · 39명으로 나뉜다 — 서버 본문 한도(값 10,000 · 1MB)를 넘지 않게.
- **이미 있는지 묻기**: 넣을 수 있는 연락처만 위 묶음으로 서버에 묻는다(`POST /contact-import-checks`). 남은 자리를 다 채우면 더 묻지 않는다(요청 수 한도를 아끼려고) —
그래서 한도에 걸린 파일이면 묻지 않은 연락처 가운데 이미 있는 것도 "넣을 연락처"와 "나머지 N명"에 세진다(넣지 않으니 결과는 같다). 묻기가 실패하면 사전 문구 알림 뒤 파일 고르기로 돌아간다.
- **넣기**: 새 그룹을 먼저 만든다 — 그사이 같은 이름이 생겼으면(`CONTACT_001`) 그 그룹을 쓰고, 100개면(`CONTACT_002`) 그 그룹 없이 넣는다(결과의 "그룹에 넣지 못했습니다"에 센다).
만들기의 응답만 잃은 그룹(응답 없음 · 5xx — 서버에서는 만들어졌을 수 있다)이 [다시 시도]에서 `CONTACT_001` 이면 이번에 만든 그룹으로 센다([되돌리기]가 지운다).
응답을 잃은 적 없이 `CONTACT_001` 인 그룹(다른 창 · 기기가 만든 그룹)은 넣기만 하고 [되돌리기]는 지우지 않는다.
넣는 사이 주소록이 찼으면(409 `CONTACT_002`) 남은 자리를 다시 물어 그만큼만 넣고 나머지는 "넣지 못했습니다", 넣을 그룹이 그사이 지워졌으면(404 `RES_001`) 그룹을 다시 받아 그 그룹을 빼고 한 번 더 보낸다.
메일 서버가 일부만 받으면 결과에 "N명은 넣지 못했습니다."를 덧붙인다(보드에 없는 글).
- **멈춘 알림도 ✕까지 남는다**: 보드는 결과([되돌리기]) 알림만 적었지만, 멈춘 오류 알림의 [다시 시도]가 남은 연락처를 넣고 [되돌리기]를 받는 유일한 길이라 같이 남긴다(되돌리다 멈춘 알림도).
알림은 넘쳐도 다른 알림부터 닫는다. 결과 알림 위에 다른 결과가 쌓이면 셋까지 보인다.
- **차례**: 한 탭에서 가져오기 · 되돌리기는 하나씩 돈다. 도는 동안 누른 [되돌리기] · [다시 시도]는 버리지 않고 끝난 뒤에 한다 — 누른 뒤 세션이 바뀌었으면 하지 않는다(다른 직원의 주소록을 건드리지 않게).
- **내보내기**: 처음 초점은 [내보내기](내 주소록 전체 · vCard 를 바로 받게), 그룹이 하나도 없으면 「그룹 하나」를 고를 수 없다(`disabled`). 화면의 파일 이름 날짜는 이 PC 의 날짜이고
서버가 붙이는 이름은 한국 시간이다 — 자정 무렵 바깥 시간대의 PC 에서는 하루 다를 수 있다(알려진 한계).
- **알림 부품**: ✕로 닫을 때까지 남는 알림은 `toast.store` 의 `sticky`(진행 알림의 `succeed` · `fail` 옵션 — `useToast`). 성공 알림에도 ✕ 를 단다(브랜드 바탕용 흐린 청록, 마우스를 올리면 흰 글자).
지금 쓰는 알림의 시간 · 모양은 그대로다.
### 16단계 구현하며 정한 보완 — 서명 (2026-11-10 — 캔버스에는 아직 없다)
- **서명 자리**(`components/mail/editor/mail-signature-block.ts`): 편집기 안에서는 블록 노드 하나(`mailSignature` — 안은 서명의 문단)이고, 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`** 로 나간다 —
앱과 같은 경계(계약 2절 "앱이 만드는 모양")라 웹 · 앱 어느 쪽 저장본을 다시 열어도 그 자리를 찾는다. 안의 문단은 다른 문단처럼 `margin: 0` 이 붙는다.
다시 열 때는 "맨 위 단계(문서 · 하나뿐인 감싸는 글꼴 div 바로 아래)에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 찾는다 — 원문 인용 안 · 꾸민 div · 빈 div(인용 경계 표시가 남은 것)는 서명 자리가 아니다.
쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다.
- **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고,
Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
한글을 조합하는 중에는 미뤘다가 다음 입력에서 넣고, 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다.
알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다.
- **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두).
- **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다).
- **글(보드에 없어 지었다)**: 넣지 않음을 고른 뒤 낭독은 같은 틀 "서명을 ‘넣지 않음’으로 바꿨습니다." — 방향 조사는 이름 끝소리로 고른다("‘영문’으로" · 영문 이름은 "(으)로").
새 서명(저장하지 않은 줄)을 고치다 다른 서명을 고르면 "다른 서명을 고르면 새 서명에 쓴 내용이 사라집니다.", 쓴 것이 있을 때의 [취소]는 "취소하면 새 서명에 쓴 내용이 사라집니다."("‘새 서명’ 서명에서"가 되지 않게).
- **서버 거절**: 저장이 400 `VAL_001` 이면 목록을 조용히 다시 받아 그사이 다른 창에서 같은 이름을 만들었으면 이름 칸의 빨간 글, 아니면 서명 칸 아래 사전 문구(본문 길이 · 그림 3장 등 — 지금과 같다).
지우려던 서명이 이미 없으면(404) 지운 것으로 본다. 고치던 서명이 다시 받은 목록에서 사라졌으면(다른 창에서 지움) 쓰던 것은 두고 새 서명으로 본다([저장]하면 새로 만든다).
한 사람이 보관하는 그림 20장을 넘는 올리기 거절(`VAL_001`)은 지금처럼 알림이다.
- **쓰기 화면이 서명을 못 받았으면** 서명 없이 열고 알리지 않는다(지금과 같다). [서명 ▾]를 열 때 다시 받아 본다.
- **공용 편집기**(`SignatureEditor` — 부재중 자동 응답의 내용 칸이 함께 쓴다): prop `bodyId`(편집 영역 id · 툴바 aria-controls) · `toolbarLabel`(툴바 이름) · `allowImages`(false 면 그림 단추 · 그림 노드가 없고 붙여 넣거나 끌어 놓은 파일은 받지 않는다) ·
`invalid`(aria-invalid · 빨간 테두리) · `disabled`(읽기만 · 툴바 끔 · 흐리게). 편집 영역 최소 높이는 CSS 변수 `--signature-editor-min-height`(기본 132), 보이는 글은 `getText()`. 기본값은 서명 칸 그대로다.
### 16단계 구현하며 정한 보완 — 부재중 · 휴대폰 바꾸기 (2026-11-10 — 캔버스에는 아직 없다)
- **휴대폰을 바꿨는지 모를 때**(서버가 503 `MAIL_001` — 엔진이 새 비밀을 가려 다시 읽어도 가를 수 없다 · 시간 초과 · 연결 끊김 · 앞단 5xx 의 `SYS_001`): 바꿨다고도 못 바꿨다고도 하지 않는다(보드에 없는 새 글).
사전 문구 알림은 끄고 창은 ② 그대로 · 지금 휴대폰의 코드 칸만 비운 채 창 위 빨간 상자에
"휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요." —
이미 바뀌었으면 지금 휴대폰의 코드는 막혀 [바꾸기]가 `AUTH_006` 으로 거절하므로, 그 상자를 남겨 둔 채 칸 아래 글이 함께 보인다.
창을 닫은 뒤에 왔으면 알림 "휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요."
- 닫은 창에 늦게 온 거절(지금 비밀번호 · 지금 코드)은 칸 대신 사전 문구 알림, 다시 연 창에는 넣지 않는다(켜기 · 끄기 창과 같다). ①의 늦은 확인 결과는 버린다(엔진을 바꾸지 않는다).
- 켜기 창과 바꾸기 창이 한 화면에 함께 있어 키 이름표 id 가 겹치지 않게 QR · 키 부품(`TwoFactorEnrollmentScan`)이 머리 글 · 닫을 때의 안내 · 키 이름표 id 를 받는다.
- 부재중 내용의 글자 수는 켜 둔 동안 600을 넘는 순간부터 빨갛게 보인다(보드는 [저장]을 누른 모습만 — 칸 아래 글은 지금처럼 [저장]을 눌렀을 때만).
꺼 둔 채로는 넘어도 회색이다(켤 때만 센다).
- 부재중 양식은 편집기와 함께 지연 로딩한다 — 청크를 받는 동안은 칸이 비어 있고, 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도].
- 회사 밖 칸을 쓴 적 없으면 제목 · 내용을 null 로 보낸다(빈 글을 남기지 않는다 — 서버도 빈 글을 null 로 저장한다).
File diff suppressed because it is too large Load Diff
+936
View File
@@ -0,0 +1,936 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>메일 16단계 · 여러 서명 · 쓰기 화면에서 서명 바꾸기</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.ghost-off{background:transparent;color:#98A2B3}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.danger-solid{background:#B42318;color:#FFFFFF}
.danger-solid:hover{background:#912018}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field::placeholder{color:#667085}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-focus{border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-error{border-color:#B42318}
.sig-row{background:#FFFFFF}
.sig-row:hover{background:#F8F9FB}
.sig-row.is-on,.sig-row.is-on:hover{background:#E3F2F3}
.tb{background:transparent;color:#344054}
.tb:hover,.tb[aria-expanded="true"]{background:#F2F4F7;color:#171C26}
.seg{background:transparent;color:#4A5363}
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
.se p{margin:0}
</style>
</helmet>
<div style="width: 1440px; height: 7955px; box-sizing: border-box; display: flex; flex-direction: column; padding-bottom: 56px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5; word-break: keep-all; overflow-wrap: anywhere">
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 6px">
<h1 style="margin: 0; max-width: 1200px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">메일 16단계 · 여러 서명 · 쓰기 화면에서 서명 바꾸기</h1>
<p style="margin: 0; max-width: 1180px; font-size: 13.5px; color: #4A5363">설정 › 메일 › 서명에서 서명을 10개까지 만들어 두고, 새 메일과 답장, 전달에 넣을 서명을 따로 고릅니다. 쓰기 화면에서는 아래 단추 줄의 [서명 ▾]로 그 메일의 서명만 바꿉니다. 1의 목록 줄과 미리 보기 전환, 6의 메뉴 항목은 눌러 볼 수 있습니다. 화면 글은 지금 화면 상수의 글을 다시 쓰고, 새로 지은 글은 7에 모았습니다. 예시 직원은 김아라이고 서명은 사내, 대외, 영문 셋입니다. 초안이며 사용자 확인 전입니다(2026-11-10).</p>
</div>
<section aria-labelledby="s1-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 18px; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s1-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">1. 설정 › 메일 › 서명 · 서명이 셋일 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">위에서 새 메일과 답장, 전달에 넣을 서명을 고르고, 아래 왼쪽 목록에서 고른 서명을 오른쪽에서 고칩니다. 대외를 고른 모습이고 목록의 줄과 미리 보기 전환을 눌러 볼 수 있습니다. 창 폭 1440, 콘텐츠 열은 지금처럼 최대 800입니다.</p>
</div>
</section>
<div role="region" aria-label="설정 › 메일 › 서명 화면" style="flex: 0 0 auto; box-sizing: border-box; display: flex; background: #FFFFFF; border-top: 1px solid #E6E8EC; border-bottom: 1px solid #E6E8EC">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="#" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>메일</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg><span>캘린더</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span>파일</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg><span>주소록</span></a></li>
</ul>
<div style="margin-top: auto"><a href="#" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; background: #155A64; color: #FFFFFF; font-weight: 600"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg><span>설정</span></a></div>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색 (준비 중)" placeholder="통합 검색" disabled style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="#" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 0 0 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg><span>비밀번호</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><polyline points="9 12 11 14 15 10"></polyline></svg><span>2단계 인증</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg><span>앱 비밀번호</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"></path><path d="M10 19v-3.96 3.15"></path><path d="M7 19h5"></path><rect x="16" y="12" width="6" height="10" rx="2"></rect></svg><span>메일 프로그램 연결</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg><span>휴대폰 앱</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg><span>로그인 기록</span></a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg><span>서명</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg><span>부재중 자동 응답</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg><span>필터</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기 · 읽기</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg><span>차단한 주소</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg><span>대용량 첨부</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span>알림</span></a></li>
</ul>
</nav>
<main style="flex: 1 1 auto; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">서명</h1>
<p style="margin: 0; color: #4A5363">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.</p>
</div>
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f1-def-new" class="field" aria-describedby="f1-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option>사내</option><option selected>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f1-def-reply" class="field" aria-describedby="f1-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>사내</option><option>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<p id="f1-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
<div style="display: flex; align-items: flex-start; gap: 24px">
<div style="flex: 0 0 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f1-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f1-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<sc-for list="{{ f1Rows }}" as="r" hint-placeholder-count="3">
<li style="border-top: {{ r.borderTop }}">
<button type="button" class="{{ r.cls }}" aria-current="{{ r.current }}" onClick="{{ r.pick }}" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer">
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.fw }}; color: {{ r.fg }}">{{ r.name }}</span>
<sc-if value="{{ r.hasUses }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; line-height: 1.4; color: {{ r.subColor }}">{{ r.uses }}</span></sc-if>
</button>
</li>
</sc-for>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="f1-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f1-name" class="field" type="text" value="{{ selName }}" maxlength="30" aria-describedby="f1-name-help" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f1-name-help" style="margin: 0; font-size: 12.5px; color: #667085">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<p id="f1-sig-label" style="margin: 0; font-weight: 600; color: #171C26">서명 내용</p>
<div style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF">
<div role="toolbar" aria-label="서명 서식" aria-controls="f1-sig-body" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색" title="글자 색" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="그림 넣기" title="그림 넣기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg></button>
</div>
<div id="f1-sig-body" class="se" role="textbox" aria-multiline="true" aria-labelledby="f1-sig-label" tabindex="0" style="min-height: 132px; box-sizing: border-box; padding: 14px 16px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<sc-if value="{{ selIn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></sc-if>
<sc-if value="{{ selOut }}" hint-placeholder-val="{{ true }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></sc-if>
<sc-if value="{{ selEn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></sc-if>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<button type="button" class="primary" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="danger" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>이 서명 지우기</span></button>
</div>
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
<section aria-labelledby="f1-prev-h" style="display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px">
<h2 id="f1-prev-h" style="margin: 0; font-size: 14px; font-weight: 700">미리 보기</h2>
<div role="group" aria-label="미리 볼 메일" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
<sc-for list="{{ f1Modes }}" as="m" hint-placeholder-count="2">
<button type="button" class="seg" aria-pressed="{{ m.pressed }}" onClick="{{ m.pick }}" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">{{ m.label }}</button>
</sc-for>
</div>
</div>
<div style="box-sizing: border-box; padding: 16px 20px 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0; color: #667085">(여기에 쓴 내용)</p>
<p style="margin: 0"><br></p>
<sc-if value="{{ selIn }}" hint-placeholder-val="{{ false }}"><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></div></sc-if>
<sc-if value="{{ selOut }}" hint-placeholder-val="{{ true }}"><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></div></sc-if>
<sc-if value="{{ selEn }}" hint-placeholder-val="{{ false }}"><div><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></div></sc-if>
<sc-if value="{{ previewReply }}" hint-placeholder-val="{{ false }}">
<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="margin: 0; font-weight: 600">원본 메일</p>
<table style="border-collapse: collapse; font-size: 12.5px; line-height: 1.6">
<tbody>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">보낸 사람</th><td style="padding: 0">(보낸 사람)</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">날짜</th><td style="padding: 0">(보낸 날짜)</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">제목</th><td style="padding: 0">(원래 제목)</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">(원래 메일 내용)</p>
</blockquote>
</sc-if>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ previewNote }}</p>
</section>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
<section style="flex: 0 0 auto; box-sizing: border-box; padding: 20px 48px 0; display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">메일에 넣을 서명을 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">답장과 전달을 대외에서 사내로 바꾼 모습 · 저장 단추 없이 바로 저장</p>
</div>
<div style="box-sizing: border-box; padding: 18px 20px 20px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1s-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f1s-def-new" class="field" aria-describedby="f1s-def-hint" style="width: 200px; height: 44px; padding: 0 10px"><option>사내</option><option selected>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1s-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f1s-def-reply" class="field" aria-describedby="f1s-def-hint" style="width: 200px; height: 44px; padding: 0 10px"><option selected>사내</option><option>대외</option><option>영문</option><option>넣지 않음</option></select>
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #067647; white-space: nowrap"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg><span>저장했습니다</span></span>
</div>
<p id="f1s-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>배치는 목록 왼쪽(208), 고치는 칸 오른쪽(504)입니다. 서명이 늘어도 고치는 칸이 아래로 밀려나지 않고, 고른 줄과 고치는 칸이 나란히 보입니다. 고치는 칸은 지금(736)보다 좁지만 서명 글, 툴바, 미리 보기가 다 들어갑니다.</li>
<li>목록과 고치는 칸을 담은 칸이 640보다 좁아지면 목록을 위로 올리고 고치는 칸을 그 아래에 둡니다. 창 폭이 아니라 그 칸의 폭을 봅니다(컨테이너 쿼리). 콘텐츠 열은 다른 설정 화면과 같은 800 그대로입니다.</li>
<li>목록은 만든 차례이고 줄 높이는 52입니다. 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕에 굵은 글자입니다(aria-current). 이름 아래 작은 회색 글은 그 서명을 기본으로 넣는 메일이고, 뱃지는 쓰지 않습니다.</li>
<li>화면을 열면 새 메일에 넣는 서명이 골라져 있습니다. 새 메일이 넣지 않음이면 첫 서명이 골라져 있습니다.</li>
<li>메일에 넣을 서명 두 칸은 고르는 대로 바로 저장합니다. 보내기 · 읽기 설정처럼 저장이 끝나면 고른 칸 오른쪽에 저장했습니다(초록 ✓)를 보이고(왼쪽 아래), 다시 고르면 내립니다. 저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림을 띄웁니다. 지금의 두 체크(새 메일, 답장과 전달)를 대신합니다.</li>
<li>이름과 서명 내용은 [저장]으로 저장합니다. 저장한 뒤의 글은 기본으로 넣는 서명이면 지금 그대로 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다."이고, 기본이 아닌 서명이면 "저장했습니다."만 둡니다.</li>
<li>편집기, 그림(서명마다 3장), 그림을 고르면 나오는 크기 줄, 미리 보기와 그 아래 글은 지금 서명 화면 그대로입니다. 화면에 끌어다 놓은 그림은 지금 고른 서명에 넣습니다.</li>
<li>[이 서명 지우기]는 [저장]과 떨어뜨려 오른쪽 끝에 둡니다(4). 화면 낭독기는 목록을 내 서명 목록으로, 고른 줄을 지금 항목으로 읽습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s2-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s2-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">2. 서명이 없을 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">처음 쓰는 직원입니다. 메일에 넣을 서명 칸과 목록 대신 안내와 [새 서명]만 둡니다. 지금 서명을 쓰던 직원은 그 서명을 기본 서명이라는 이름으로 옮겨 둔 채 시작합니다(오른쪽).</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">처음 쓰는 직원</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 화면의 콘텐츠 열만 그렸습니다</p>
</div>
<div style="box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">서명</h1>
<p style="margin: 0; color: #4A5363">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed #D0D5DD; border-radius: 10px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">아직 서명이 없습니다</p>
<p style="max-width: 400px; margin: 0; font-size: 12.5px; color: #667085">보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.</p>
<button type="button" class="primary" style="margin-top: 6px; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">지금 서명을 쓰던 직원</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">두 체크를 모두 켜 두었던 직원 · 고치는 칸은 1과 같아 뺐습니다</p>
</div>
<div style="box-sizing: border-box; padding: 20px 24px 24px; display: flex; flex-direction: column; gap: 22px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f2-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f2-def-new" class="field" aria-describedby="f2-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>기본 서명</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f2-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f2-def-reply" class="field" aria-describedby="f2-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>기본 서명</option><option>넣지 않음</option></select>
</div>
<p id="f2-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
<div style="width: 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f2-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f2-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row is-on" aria-current="true" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #0A5F67">기본 서명</span><span style="font-size: 12px; line-height: 1.4; color: #0A5F67">새 메일, 답장과 전달</span></button></li>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 1개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>서명이 하나도 없으면 메일에 넣을 서명 칸을 감춥니다. 고를 서명이 없어서입니다. 첫 서명을 저장하면 새 메일, 답장과 전달 두 곳의 기본이 모두 그 서명이 되고 1의 모습이 됩니다(지금 두 체크가 처음에 켜져 있는 것과 같습니다).</li>
<li>지금 서명이 있던 직원은 그 서명이 기본 서명이라는 이름으로 옮겨지고, 지금 두 체크에서 켠 곳의 기본이 됩니다. 끈 곳은 넣지 않음입니다. 내용이 비어 있던 서명은 옮기지 않습니다.</li>
<li>서명이 없을 때 쓰기 화면의 [서명 ▾]에는 넣지 않음과 서명 관리만 있습니다(6 오른쪽 아래).</li>
</ul>
</div>
</section>
<section aria-labelledby="s3-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s3-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">3. 새 서명</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">[새 서명]을 누르면 목록 끝에 새 줄이 생기고, 오른쪽에 빈 이름 칸과 서명 칸이 열립니다. 이름은 1~30자이고 같은 이름은 쓸 수 없습니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[새 서명]을 누른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">1의 아래쪽(목록과 고치는 칸)만 그렸습니다 · 이름 칸에 초점</p>
</div>
<div style="box-sizing: border-box; padding: 24px 32px 28px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: flex-start; gap: 24px">
<div style="flex: 0 0 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f3-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f3-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">사내</span><span style="font-size: 12px; line-height: 1.4; color: #667085">답장과 전달</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">대외</span><span style="font-size: 12px; line-height: 1.4; color: #667085">새 메일</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">영문</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row is-on" aria-current="true" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #0A5F67">새 서명</span><span style="font-size: 12px; line-height: 1.4; color: #0A5F67">저장하지 않음</span></button></li>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="f3-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3-name" class="field is-focus" type="text" value="" placeholder="예: 사내, 대외, 영문" maxlength="30" aria-describedby="f3-name-help" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3-name-help" style="margin: 0; font-size: 12.5px; color: #667085">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<p id="f3-sig-label" style="margin: 0; font-weight: 600; color: #171C26">서명 내용</p>
<div style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF">
<div role="toolbar" aria-label="서명 서식" aria-controls="f3-sig-body" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색" title="글자 색" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="그림 넣기" title="그림 넣기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg></button>
</div>
<div id="f3-sig-body" class="se" role="textbox" aria-multiline="true" aria-labelledby="f3-sig-label" tabindex="0" style="min-height: 132px; box-sizing: border-box; padding: 14px 16px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733"></div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<button type="button" class="primary" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">이름 없이 [저장]을 눌렀을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">칸 아래 안내 대신 빨간 글 · 알림은 띄우지 않습니다</p>
</div>
<div style="box-sizing: border-box; display: flex; flex-direction: column; gap: 6px; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<label for="f3a-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3a-name" class="field is-error" type="text" value="" placeholder="예: 사내, 대외, 영문" maxlength="30" aria-invalid="true" aria-describedby="f3a-err" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3a-err" style="display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 12.5px; color: #B42318"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>이름을 넣어 주세요.</span></p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">같은 이름이 있을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">대외가 이미 있습니다</p>
</div>
<div style="box-sizing: border-box; display: flex; flex-direction: column; gap: 6px; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<label for="f3b-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3b-name" class="field is-error" type="text" value="대외" maxlength="30" aria-invalid="true" aria-describedby="f3b-err" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3b-err" style="display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 12.5px; color: #B42318"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>같은 이름의 서명이 있습니다.</span></p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">10개를 다 만들었을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">목록 끝 두 줄만 · [새 서명]은 초점은 받고 누를 수 없습니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="width: 208px; display: flex; flex-direction: column; gap: 8px">
<ul aria-label="내 서명" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">행사 안내</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">견적용</span></button></li>
</ul>
<button type="button" class="ghost-off" aria-disabled="true" aria-describedby="f3c-full" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: not-allowed"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p id="f3c-full" style="margin: 0; font-size: 12.5px; color: #667085">서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.</p>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>새 줄은 처음 [저장]할 때 만들어지고, 그 전까지 이름 아래에 저장하지 않음이라고 적습니다. [취소]를 누르면 새 줄이 사라지고 전에 고른 서명이 열립니다. 쓴 것이 있으면 5처럼 묻습니다. 새 서명에는 [이 서명 지우기] 대신 [취소]가 있습니다.</li>
<li>이름은 앞뒤 공백을 빼고 1~30자이고, 31자째는 칸에 들어가지 않습니다. 같은 이름인지는 영문 대소문자를 가리지 않고 봅니다.</li>
<li>이름이 비었거나 같은 이름이 있으면 [저장]할 때 칸 아래 안내를 빨간 글로 바꾸고 이름 칸에 초점을 둡니다. 알림은 띄우지 않고, 이름을 고치면 안내로 돌아갑니다.</li>
<li>그사이 다른 창에서 같은 이름을 만들었으면 서버가 거절하고 화면은 같은 빨간 글을 보입니다. 오류 코드는 구현 때 정합니다.</li>
<li>서명이 10개면 [새 서명]은 누를 수 없고(aria-disabled, 초점은 받습니다) 목록 아래 글이 그 까닭으로 바뀝니다. 필터의 50개와 같은 꼴입니다.</li>
<li>새로 만든 서명은 메일에 넣을 서명 두 칸을 바꾸지 않습니다. 첫 서명만 두 곳의 기본이 됩니다(2).</li>
</ul>
</div>
</section>
<section aria-labelledby="s4-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s4-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">4. 서명 지우기</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">[이 서명 지우기]를 누르면 묻고 지웁니다. 그 서명을 기본으로 넣던 메일이 있으면, 지운 뒤 그 메일에는 서명을 넣지 않는다는 것을 함께 알립니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">기본으로 넣던 서명</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">대외는 새 메일에 넣는 서명 · 처음 초점은 [취소]</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f4a-title" aria-describedby="f4a-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f4a-title" style="margin: 0; font-size: 15px; font-weight: 700">‘대외’ 서명을 지울까요?</h3>
<p id="f4a-msg" style="margin: 0; color: #4A5363">새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="danger-solid" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">지우기</button><button type="button" class="ghost" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div></div>
</div>
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">기본이 아닌 서명</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">영문</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f4b-title" aria-describedby="f4b-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f4b-title" style="margin: 0; font-size: 15px; font-weight: 700">‘영문’ 서명을 지울까요?</h3>
<p id="f4b-msg" style="margin: 0; color: #4A5363">지운 서명은 되돌릴 수 없습니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="danger-solid" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">지우기</button><button type="button" class="ghost" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div></div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 12px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">지운 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">알림 · 목록의 첫 서명을 엽니다</p>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg><span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘영문’ 서명을 지웠습니다.</span></div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>처음 초점은 [취소]이고 Esc도 [취소]와 같습니다. 지금 메일함 지우기 창과 같은 모양(폭 360)입니다.</li>
<li>기본으로 넣던 곳은 넣지 않음이 됩니다. 다른 서명이 저절로 기본이 되지 않습니다.</li>
<li>두 곳 모두의 기본이면 둘째 글은 "새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다."이고, 답장과 전달만이면 "답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다."입니다.</li>
<li>지운 뒤에는 목록의 첫 서명을 엽니다. 마지막 서명을 지우면 2의 빈 화면이 됩니다.</li>
<li>이미 보냈거나 임시 저장한 메일에 들어간 서명은 그대로 남습니다. 그 서명에만 쓰던 그림은 함께 지웁니다.</li>
</ul>
</div>
</div>
</div>
</section>
<section aria-labelledby="s5-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s5-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">5. 저장하지 않고 다른 서명을 고르거나 화면을 떠날 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">지금 나가기 확인과 같은 창입니다. 다른 서명을 고를 때만 글과 둘째 단추가 다릅니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">대외를 고치다 목록에서 사내를 눌렀을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">처음 초점은 [계속 고치기]</p>
</div>
<div style="box-sizing: border-box; height: 260px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f5a-title" aria-describedby="f5a-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5a-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 서명이 있습니다</h3>
<p id="f5a-msg" style="margin: 0; color: #4A5363">다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">계속 고치기</button><button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">바꾼 내용 버리기</button>
</div>
</div></div>
</div>
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">다른 화면으로 갈 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">지금 서명 화면의 나가기 확인 그대로</p>
</div>
<div style="box-sizing: border-box; height: 260px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f5b-title" aria-describedby="f5b-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5b-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 서명이 있습니다</h3>
<p id="f5b-msg" style="margin: 0; color: #4A5363">이 화면을 나가면 바꾼 서명이 사라집니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">계속 고치기</button><button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">나가기</button>
</div>
</div></div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>처음 초점은 [계속 고치기]이고 Esc도 같습니다. [바꾼 내용 버리기]는 바꾼 것을 버리고 누른 서명을 엽니다.</li>
<li>[새 서명]을 누를 때의 글은 "새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다."이고 단추는 같습니다.</li>
<li>바꾼 것이 없으면 묻지 않습니다. 메일에 넣을 서명 두 칸은 고를 때 바로 저장되므로 묻는 까닭이 되지 않습니다.</li>
<li>탭을 닫거나 새로 고칠 때는 지금처럼 브라우저가 묻습니다. 저장하는 중이거나 세션이 끝났을 때는 묻지 않습니다(지금과 같습니다).</li>
</ul>
</div>
</div>
</div>
</section>
<section aria-labelledby="s6-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s6-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">6. 쓰기 화면 [서명 ▾]</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">아래 단추 줄의 [임시 저장] 다음에 둡니다. 고르면 본문 안의 서명 자리만 그 서명으로 바뀌고, 쓴 글과 인용은 그대로입니다. 메뉴 항목을 누르면 본문의 서명이 바뀝니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">새 메일 · [서명 ▾]를 연 모습</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">사내 동료에게 쓰는 새 메일 · 새 메일 기본인 대외가 들어 있습니다</p>
</div>
<div role="group" aria-label="새 메일 쓰기" style="position: relative; box-sizing: border-box; display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 18px 4px 32px; border-bottom: 1px solid #E6E8EC">
<h3 style="flex: 1 1 auto; margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">새 메일</h3>
<button type="button" class="ghost" aria-label="이 메일 삭제" title="삭제" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
<button type="button" class="ghost" aria-label="쓰기 닫기" title="닫기" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26">이도윤</span>
<span style="flex: 1 1 auto"></span>
<button type="button" style="min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">참조 · 숨은참조</button>
</div>
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">제목</span>
<span style="font-size: 14px; font-weight: 600">다음 주 정기 점검 일정 공유</span>
</div>
<div role="toolbar" aria-label="본문 서식" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tb" aria-label="실행 취소" title="실행 취소" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg></button>
<button type="button" class="tb" aria-label="다시 실행" title="다시 실행" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-haspopup="menu" aria-expanded="false" aria-label="글꼴: 맑은 고딕" style="width: 108px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; text-align: left">맑은 고딕</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<button type="button" class="tb" aria-haspopup="menu" aria-expanded="false" aria-label="글자 크기: 14" style="width: 56px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; text-align: left">14</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색: 기본" title="글자 색: 기본" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="배경 색: 없음" title="배경 색: 없음" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="3" fill="none"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글머리 목록" title="글머리 목록" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" class="tb" aria-label="번호 목록" title="번호 목록" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="서식 더보기" title="서식 더보기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
<div style="min-height: 300px; box-sizing: border-box; padding: 16px 32px 28px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<sc-if value="{{ cHasSig }}" hint-placeholder-val="{{ true }}">
<p style="margin: 0"><br></p>
<div>
<sc-if value="{{ cIn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></sc-if>
<sc-if value="{{ cOut }}" hint-placeholder-val="{{ true }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></sc-if>
<sc-if value="{{ cEn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></sc-if>
</div>
</sc-if>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 10px 32px 14px">
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin-left: -6px; padding: 0 10px 0 6px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg><span>파일 첨부</span></button>
<span style="font-size: 12px; color: #667085">파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다</span>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 32px; border-top: 1px solid #E6E8EC">
<div style="display: inline-flex; align-items: stretch">
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px 0 18px; border: 0; border-radius: 8px 0 0 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기</span></button>
<span aria-hidden="true" style="width: 1px; background: #0E7C86; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35)"></span>
<button type="button" class="primary" aria-label="보내기 방법 더 보기" title="보내기 방법 더 보기" aria-haspopup="menu" aria-expanded="false" style="width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 0 8px 8px 0; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
<div style="position: relative; display: inline-flex">
<button type="button" class="tb" aria-haspopup="menu" aria-expanded="true" aria-controls="f6-menu" aria-label="{{ cButtonLabel }}" style="display: inline-flex; align-items: center; gap: 4px; min-height: 44px; padding: 0 10px 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>서명</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<div id="f6-menu" role="menu" aria-label="서명" style="position: absolute; left: 0; bottom: calc(100% + 4px); z-index: 5; width: 220px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ cMenu }}" as="c" hint-placeholder-count="4">
<button type="button" role="menuitemradio" aria-checked="{{ c.checked }}" class="menu-item" onClick="{{ c.pick }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ c.label }}</span>
<span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px">
<sc-if value="{{ c.isChecked }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if>
</span>
</button>
</sc-for>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">서명 관리</button>
</div>
</div>
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #667085"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg><span>오후 3:12에 임시 저장됨</span></span>
<span style="margin-left: auto; font-size: 12px; color: #667085"><span style="margin-right: 6px">보내는 사람</span>김아라 &lt;ara.kim@arasystem.it.kr&gt;</span>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>자리를 아래 단추 줄로 고른 까닭: 편집기 툴바는 서식 단추가 좁은 폭에서 정해진 차례로 더보기에 접히는데, 서명은 서식이 아니라 메일 전체에 붙는 것이라 그 차례에 넣지 않았습니다. 아래 줄은 보내기 직전에 보는 곳이고, 메뉴는 [보내기 ▾]와 같은 부품이라 아래 자리가 모자라면 위로 엽니다(아래 줄에서는 보통 위). Gmail도 서명을 아래 줄에 둡니다.</li>
<li>단추는 [임시 저장]과 같은 글자 단추(44)에 ▾를 붙입니다. 화면 낭독은 툴바의 글꼴 단추처럼 지금 서명까지 읽습니다(서명: 대외).</li>
<li>메뉴는 편집기 메뉴(항목 40, 폭 220)입니다. 서명은 만든 차례이고 지금 본문에 든 서명에 ✓를 붙입니다. 그 아래 넣지 않음, 가는 선, 서명 관리 차례입니다.</li>
<li>서명 관리는 설정 › 메일 › 서명으로 갑니다. 쓰던 메일은 지금 나가기 규칙대로 먼저 임시 저장하고, 저장하지 못했을 때만 묻습니다.</li>
<li>바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."를 읽어 주고, 알림은 띄우지 않습니다.</li>
<li>키보드는 [보내기 ▾]와 같습니다. Enter, Space, 아래 화살표로 열면 첫 항목에 초점이 가고, Esc는 메뉴만 닫고 [서명 ▾]로 돌아갑니다.</li>
</ul>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">서명이 없을 때의 메뉴</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">2의 직원 · 넣지 않음과 서명 관리만</p>
</div>
<div role="menu" aria-label="서명" style="width: 220px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitemradio" aria-checked="true" class="menu-item" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0">넣지 않음</span><span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0">서명 관리</span><span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px"></span></button>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding-top: 18px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #0B3640">새 메일에서 바꾸기 전과 뒤</h3>
<div style="display: flex; align-items: flex-start; gap: 16px">
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">바꾸기 전</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">새 메일에 기본으로 넣은 대외</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
</div>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; align-self: center; display: inline-flex; color: #98A2B3"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg></span>
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[서명 ▾]에서 사내를 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">쓴 글은 그대로이고 서명 자리만 바뀝니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding-top: 18px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #0B3640">답장에서 바꾸기 전과 뒤</h3>
<div style="display: flex; align-items: flex-start; gap: 16px">
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">바꾸기 전</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">회사 밖 사람에게 답장 · 답장과 전달 기본인 사내가 들어 있습니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">최도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">보내 주신 견적서 잘 받았습니다. 검토한 뒤 이번 주 안에 회신드리겠습니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
<blockquote style="margin: 16px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="margin: 0; font-weight: 600">원본 메일</p>
<table style="border-collapse: collapse; font-size: 12.5px; line-height: 1.6">
<tbody>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">보낸 사람</th><td style="padding: 0">최도윤 &lt;doyun.choi@example.net&gt;</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">날짜</th><td style="padding: 0">2026. 11. 10. (화) 오전 9:20</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">제목</th><td style="padding: 0">견적서 보내 드립니다</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">김아라님, 요청하신 견적서를 첨부해 보내 드립니다.</p>
</blockquote>
</div>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; align-self: center; display: inline-flex; color: #98A2B3"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg></span>
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[서명 ▾]에서 대외를 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">인용은 그대로이고 그 위의 서명 자리만 바뀝니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">최도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">보내 주신 견적서 잘 받았습니다. 검토한 뒤 이번 주 안에 회신드리겠습니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
<blockquote style="margin: 16px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="margin: 0; font-weight: 600">원본 메일</p>
<table style="border-collapse: collapse; font-size: 12.5px; line-height: 1.6">
<tbody>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">보낸 사람</th><td style="padding: 0">최도윤 &lt;doyun.choi@example.net&gt;</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">날짜</th><td style="padding: 0">2026. 11. 10. (화) 오전 9:20</td></tr>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">제목</th><td style="padding: 0">견적서 보내 드립니다</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">김아라님, 요청하신 견적서를 첨부해 보내 드립니다.</p>
</blockquote>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">서명 자리</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>서명 자리는 빈 줄 하나 뒤의 서명 덩어리입니다. 앱이 쓰는 경계와 같아서 임시 저장한 뒤 다시 열어도 찾습니다. 주황 점선과 서명 자리 꼬리표는 보드에만 있는 표시입니다.</li>
<li>바꾸면 그 자리만 새 서명으로 바꿉니다. 쓴 글, 인용, 첨부는 그대로이고, 서명 안을 고쳤다면 고친 것도 새 서명으로 바뀝니다. 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.</li>
<li>넣지 않음을 고르면 서명 자리와 그 앞의 빈 줄을 뺍니다. 서명 자리를 지운 뒤 다른 서명을 고르면 새 메일은 본문 끝, 답장과 전달은 인용 위에 넣습니다(처음 넣는 자리와 같습니다).</li>
<li>이어 쓰는 임시 저장본은 서명 자리의 글이 저장한 서명과 같으면 그 서명에 ✓를 붙이고, 다르면(고쳤거나 지운 서명) ✓ 없이 엽니다. 이어 쓸 때 서명을 다시 넣지 않는 것은 지금과 같습니다.</li>
<li>받는 사람이 회사 밖이어도 저절로 바꾸지 않습니다. 위 답장처럼 답장 기본이 사내여도 직원이 [서명 ▾]에서 고릅니다.</li>
</ul>
</div>
</section>
<section aria-labelledby="s7-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 52px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s7-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">7. 정한 것</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">3절 추천안(2026-11-10 사용자 확정)과 이 보드에서 정한 것을 모았습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">서명 설정</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>서명은 10개까지 만들고, 이름은 앞뒤 공백을 뺀 1~30자이며 겹치지 않습니다.</li>
<li>메일에 넣을 서명은 새 메일, 답장과 전달 두 곳에 하나씩 고르거나 넣지 않음으로 둡니다. 지금의 두 체크를 대신하고, 고르는 대로 바로 저장합니다.</li>
<li>그림은 서명마다 3장 그대로이고, 한 사람이 보관하는 그림은 10장에서 20장으로 늘립니다. 여러 서명이 그림을 함께 쓰고, 저장할 때 어느 서명도 쓰지 않는 그림을 지웁니다.</li>
<li>지금 서명은 기본 서명이라는 이름으로 옮기고, 지금 두 체크대로 기본을 정합니다.</li>
<li>[저장] 뒤 글은 기본으로 넣는 서명이면 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 기본이 아닌 서명이면 "저장했습니다."입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">쓰기 화면</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>[서명 ▾]는 아래 단추 줄의 [임시 저장] 다음에 둡니다.</li>
<li>바꾸면 본문의 서명 자리만 바뀝니다. 쓴 글, 인용, 첨부는 그대로입니다.</li>
<li>받는 사람에 따라 저절로 바꾸지 않습니다(처음 판). 직원이 고르는 대로이고, 기본 둘로 어떤 서명이 들어갈지 미리 알 수 있습니다.</li>
<li>서명 관리는 설정 › 메일 › 서명으로 가고 지금 나가기 규칙을 따릅니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">휴대폰 앱 · 서버</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>앱 첫 판은 지금처럼 서명 하나만 받습니다. 서버가 그 자리에 새 메일 기본 서명을 내주어 앱은 새 메일에 그 서명을 넣고, 답장과 전달 기본이 같은 서명일 때만 답장에도 넣습니다. 다르면 앱의 답장에는 서명이 들어가지 않습니다(알려진 한계).</li>
<li>앱에서 서명 고르기는 앱 다음 판 후보입니다.</li>
<li>서버는 서명 목록, 만들기, 고치기, 지우기와 기본 둘을 다루는 새 경로를 둡니다. 경로 이름과 같은 이름을 거절하는 오류 코드는 구현 때 정합니다. 보낼 때 서명 그림을 메일에 함께 싣는 것은 지금 그대로입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">처음 판에서 미룬 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>받는 사람에 따라 서명을 저절로 바꾸기, 앱에서 서명 고르기.</li>
<li>목록 차례 바꾸기(목록은 만든 차례입니다).</li>
</ul>
</section>
</div>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">새로 지은 화면 글</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">여기 없는 글은 지금 화면 상수의 글을 그대로 씁니다. 글 속 이름(대외, 영문, 사내)은 그때의 서명 이름으로 바뀝니다.</p>
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px">
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">설정 화면 설명</span><span style="color: #171C26">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다. (앞 문장은 지금 글)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기 묶음 이름</span><span style="color: #171C26">메일에 넣을 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기의 고를 것</span><span style="color: #171C26">넣지 않음</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기 아래 글</span><span style="color: #171C26">메일을 쓰기 시작할 때 본문에 넣어 둡니다. (뒤 문장 "고르는 대로 바로 저장됩니다."는 보내기 · 읽기 설정의 글)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 이름</span><span style="color: #171C26">내 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 줄 아래 회색 글 (두 곳의 기본일 때)</span><span style="color: #171C26">새 메일, 답장과 전달</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 아래 단추</span><span style="color: #171C26">새 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 아래 글</span><span style="color: #171C26">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">10개일 때 목록 아래 글</span><span style="color: #171C26">서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">새 줄 아래 회색 글</span><span style="color: #171C26">저장하지 않음</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">이름 칸 안 글</span><span style="color: #171C26">예: 사내, 대외, 영문</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">이름 칸 아래 글</span><span style="color: #171C26">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">같은 이름일 때</span><span style="color: #171C26">같은 이름의 서명이 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">[저장] 뒤 글 (기본이 아닌 서명)</span><span style="color: #171C26">저장했습니다. (기본으로 넣는 서명은 지금 글 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다." 그대로)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 단추</span><span style="color: #171C26">이 서명 지우기</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">빈 화면 제목</span><span style="color: #171C26">아직 서명이 없습니다</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">빈 화면 글</span><span style="color: #171C26">보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 서명이 옮겨질 때의 이름</span><span style="color: #171C26">기본 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 제목</span><span style="color: #171C26">‘대외’ 서명을 지울까요?</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (새 메일 기본)</span><span style="color: #171C26">새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (답장과 전달 기본)</span><span style="color: #171C26">답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (두 곳의 기본)</span><span style="color: #171C26">새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (기본 아님)</span><span style="color: #171C26">지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지운 뒤 알림</span><span style="color: #171C26">‘영문’ 서명을 지웠습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">다른 서명을 고를 때의 확인 글</span><span style="color: #171C26">다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">[새 서명]을 누를 때의 확인 글</span><span style="color: #171C26">새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">확인 창 둘째 단추</span><span style="color: #171C26">바꾼 내용 버리기</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">쓰기 화면 단추 (화면 낭독)</span><span style="color: #171C26">서명 (서명: 대외)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">쓰기 화면 메뉴</span><span style="color: #171C26">넣지 않음, 서명 관리</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">서명을 바꾼 뒤 (화면 낭독)</span><span style="color: #171C26">서명을 ‘사내’로 바꿨습니다.</span></li>
</ul>
</section>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":7955}}'>
class Component extends DCLogic {
state = { sel: 'out', mode: 'new', csig: 'out' };
renderVals() {
const st = this.state;
const sel = st.sel || 'out';
const mode = st.mode || 'new';
const csig = st.csig || 'out';
// 서명 셋 — 만든 차례. uses 는 그 서명을 기본으로 넣는 메일(새 메일 기본 = 대외, 답장과 전달 기본 = 사내)
const SIGS = [
{ id: 'in', name: '사내', uses: '답장과 전달' },
{ id: 'out', name: '대외', uses: '새 메일' },
{ id: 'en', name: '영문', uses: '' },
];
const f1Rows = SIGS.map((s, i) => {
const on = s.id === sel;
return {
name: s.name,
uses: s.uses,
hasUses: s.uses !== '',
current: on ? 'true' : 'false',
cls: on ? 'sig-row is-on' : 'sig-row',
fg: on ? '#0A5F67' : '#171C26',
fw: on ? '600' : '500',
subColor: on ? '#0A5F67' : '#667085',
borderTop: i === 0 ? '0' : '1px solid #EEF0F3',
pick: () => this.setState({ sel: s.id }),
};
});
const selSig = SIGS.find((s) => s.id === sel) || SIGS[1];
const MODES = [
{ id: 'new', label: '새 메일' },
{ id: 'reply', label: '답장' },
];
const f1Modes = MODES.map((m) => ({
label: m.label,
pressed: m.id === mode ? 'true' : 'false',
pick: () => this.setState({ mode: m.id }),
}));
// 쓰기 화면 [서명 ▾] — 서명(만든 차례) · 넣지 않음, 지금 본문에 든 것에 체크
const MENU = [
{ id: 'in', label: '사내' },
{ id: 'out', label: '대외' },
{ id: 'en', label: '영문' },
{ id: 'none', label: '넣지 않음' },
];
const cMenu = MENU.map((m) => ({
label: m.label,
checked: m.id === csig ? 'true' : 'false',
isChecked: m.id === csig,
pick: () => this.setState({ csig: m.id }),
}));
const cName = (MENU.find((m) => m.id === csig) || MENU[1]).label;
return {
f1Rows: f1Rows,
f1Modes: f1Modes,
selName: selSig.name,
selIn: sel === 'in',
selOut: sel === 'out',
selEn: sel === 'en',
previewReply: mode === 'reply',
previewNote: mode === 'reply' ? '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.' : '새 메일은 본문 끝에 서명을 넣습니다.',
cMenu: cMenu,
cIn: csig === 'in',
cOut: csig === 'out',
cEn: csig === 'en',
cHasSig: csig !== 'none',
cButtonLabel: '서명: ' + cName,
};
}
}
</script>
</body>
</html>
+7 -3
View File
@@ -62,9 +62,12 @@
"SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true },
"Reader15.dc.html": { "x": 0, "y": 42600, "w": 1440, "h": 8043, "title": "읽기 화면 · 이동 · 지금 보내기 · 같은 대화", "is_interactive": true },
"ListDrag15.dc.html": { "x": 1520, "y": 42600, "w": 1440, "h": 3845, "title": "목록 · 끌어다 놓기 · 상단 바 좁은 폭", "is_interactive": true },
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true }
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true },
"Contacts16.dc.html": { "x": 0, "y": 51270, "w": 1440, "h": 7536, "title": "주소록 · 가져오기 · 내보내기", "is_interactive": true },
"Signature16.dc.html": { "x": 1520, "y": 51270, "w": 1440, "h": 7955, "title": "설정 · 여러 서명 · 쓰기 화면 서명 바꾸기", "is_interactive": true },
"Settings16.dc.html": { "x": 3040, "y": 51270, "w": 1440, "h": 8807, "title": "설정 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기", "is_interactive": true }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html", "Contacts16.dc.html", "Signature16.dc.html", "Settings16.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -84,7 +87,8 @@
"app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 },
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 },
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 },
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 }
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 },
"phase16": { "x": 0, "y": 50970, "text": "16단계 화면 — 개인 설정(주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기) — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 }
},
"designSystems": []
}
+224 -27
View File
@@ -415,8 +415,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`. 16단계의 `htmlBody` · `external` 은 늘 담는다) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다. 16단계의 `htmlBody` · `external` 은 보내지 않아도 된다 — 글만 · 사내와 같은 응답) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
@@ -440,9 +440,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signatures` | — | 200 `{ items: MailSignature[], pageInfo }` — 만든 차례, 10개까지 한 번에(hasNext=false · `total`) (아래 "서명", 16단계) | — |
| `POST /api/mail-signatures` | `{ name, bodyHtml, body }` | 201 `MailSignature` — 서명이 하나도 없던 직원이면 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다 | 400 `VAL_001`(이름 · 같은 이름 · 본문 한도 · 11번째) |
| `PUT /api/mail-signatures/{id}` | `{ name, bodyHtml, body }` (전체 교체 — 기본 표시는 그대로) | 200 `MailSignature` | 400 `VAL_001`, 404 `RES_001`(없는 · 남의 · 형식이 틀린 id) |
| `DELETE /api/mail-signatures/{id}` | — | 204 — 이 서명을 기본으로 쓰던 곳은 넣지 않음, 이 서명에만 쓰던 그림은 함께 지운다 | 404 `RES_001` |
| `GET /api/mail-signature-defaults` | — | 200 `MailSignatureDefaults` (「메일에 넣을 서명」 둘 — 16단계) | — |
| `PUT /api/mail-signature-defaults` | `{ newMessageId, replyId }` (두 키 모두 — null 은 넣지 않음) | 200 `MailSignatureDefaults` | 400 `VAL_001`, 404 `RES_001`(내 서명이 아닌 id — 바꾸지 않음) |
| `GET /api/mail-signature` | — | 200 `LegacyMailSignature` — 「새 메일」 기본 서명을 15단계까지의 모양으로 (옛 경로 — 앱 첫 판이 읽는다) | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `LegacyMailSignature` (옛 경로 — 16단계 전의 웹 탭) | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 20장 초과 — 16단계부터, 전에는 10장), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
@@ -726,16 +732,30 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로
임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
### 서명 (`/api/mail-signature`)
### 서명 (`/api/mail-signatures` · `/api/mail-signature-defaults` · 옛 경로 `/api/mail-signature`) — 16단계부터 여러 개
```ts
interface MailSignature {
interface MailSignature { // 16단계 — 한 사람에 10개까지
id: string // 서버가 만든 UUID
name: string // 앞뒤 공백을 뺀 1~30자(JS 문자열 길이) — 제어 문자 없음, 내 다른 서명과 겹치지 않는다(영문 대소문자 무시)
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
isForNew: boolean // 새 메일에 넣는다 (기본 true)
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
images: SignatureImage[] // 서명이 쓰는 그림
updatedAt: string | null // 저장한 적 없으면 null
images: SignatureImage[] // 서명이 쓰는 그림 (HTML 에 나온 순서)
updatedAt: string // 마지막으로 저장한 시각
}
interface MailSignatureDefaults { // 「메일에 넣을 서명」 — null 은 넣지 않음 (15단계까지의 두 체크를 대신한다)
newMessageId: string | null // 새 메일 (본문 끝)
replyId: string | null // 답장 · 전달 (인용 위)
}
interface LegacyMailSignature { // 옛 경로 GET · PUT /api/mail-signature — 15단계까지의 MailSignature 모양 (앱 첫 판이 읽는다)
bodyHtml: string | null // 「새 메일」 기본 서명의 것 — 없으면 「답장 · 전달」 기본 서명의 것
body: string | null
isForNew: boolean // 「새 메일」 기본 서명이 있다
isForReply: boolean // 「답장 · 전달」 기본이 이 서명이다
images: SignatureImage[]
updatedAt: string | null // 기본 서명이 둘 다 없으면 null
}
interface SignatureImage {
@@ -747,19 +767,42 @@ interface SignatureImage {
}
```
- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다).
- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자,
서명 그림 3장(서로 다른 그림 기준).
- 서버 DB 에 둔다(메일 엔진에 두지 않는다). 목록은 **만든 차례**이고 10개까지 한 번에 준다(`hasNext: false` · `total` = 서명 수).
- 만들기 · 고치기(`POST` · `PUT /api/mail-signatures/{id}` — 전체 교체)의 한도 — 넘으면 400 `VAL_001`(아무것도 저장하지 않는다):
이름(위 — 같은 이름은 그사이 다른 창에서 만든 것도 서버가 막는다), 11번째 만들기, 텍스트 서명 2,000자,
서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자, 서명 그림 3장(서로 다른 그림 기준).
없는 서명 · 남의 서명 · 형식이 틀린 id 는 404 `RES_001`. 같은 직원의 서명 저장 · 지우기 · 기본 바꾸기는 서버가 차례로 한다(사용자 잠금).
- **「메일에 넣을 서명」**: 서명이 하나도 없던 직원의 첫 서명(`POST`)은 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(15단계까지 두 체크가 처음에 켜져 있던 것과 같다).
그 밖의 만들기 · 고치기는 기본을 바꾸지 않는다. 지우면(`DELETE` → 204) 그 서명을 기본으로 쓰던 곳은 넣지 않음(null)이 된다 — 다른 서명이 저절로 기본이 되지 않는다.
`PUT /api/mail-signature-defaults` 는 두 키를 모두 보내고(null = 넣지 않음, 두 곳이 같은 서명이어도 된다) 바꾼 뒤의 값을 돌려준다 —
내 서명이 아닌 id 는 404 `RES_001`(아무것도 바꾸지 않는다). 기본은 한 곳에 하나다(표의 부분 유일 인덱스).
- 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 서명을 고치거나 지워도 그대로다.
- **옛 경로**(앱 첫 판 · 16단계 전의 웹 탭 — 그대로 둔다):
- `GET /api/mail-signature` — 「새 메일」 기본 서명을 위 `LegacyMailSignature` 로. 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 `isForNew: false` · `isForReply: true` 로
(옛 두 체크가 새 메일 끔 · 답장 켬이던 직원 — 옮기면 답장 기본만 있다 — 이 앱 첫 판의 답장에서 서명을 잃지 않게).
둘 다 없으면 `{ bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }`
(15단계까지는 저장한 적 없으면 두 표시가 `true` 였다 — 본문이 없으니 앱이 넣는 것은 같다). 「새 메일」 기본이 있을 때 `isForReply` 는 「답장 · 전달」 기본이 같은 서명일 때만 `true` —
두 곳이 서로 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계 — 앱에서 서명 고르기는 앱 다음 판 후보).
- `PUT /api/mail-signature` — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 그것도 없으면 만든다)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다:
`isForNew` → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), `isForReply` → 「답장 · 전달」 기본을 이 서명으로(false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로).
한도 · 정화는 위와 같다(새로 만들어야 하는데 이미 10개면 400 `VAL_001`).
- **옮기기**(마이그레이션 `MultipleMailSignatures` — 표 `mail_signatures` 를 서명마다 한 줄로): 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고,
두 체크(`isForNew` · `isForReply`)에서 켠 곳의 기본이 된다(끈 곳은 넣지 않음). **내용이 비어 있던 서명(서식 · 텍스트 모두 없거나 공백뿐)은 옮기지 않는다.**
되돌리기(down)는 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, 나머지 서명은 지운다(남긴 서명의 두 표시가 옛 두 체크가 된다).
- **쓰기 화면의 서명 자리**: 앱과 같은 경계 — 빈 문단 하나 뒤의 **속성 없는 `<div>` 하나**(위 "서식 본문"의 "앱이 만드는 모양"). 보내는 정화 · 임시 저장본을 다시 열 때의 정화 ·
받은 메일 정화를 지나도 그대로 남는다(서버 시험 — `sanitize-outgoing-html.spec.ts`). 다만 속성이 모두 지워지는 div(허용하지 않는 style 뿐 · class · data-*)도
속성 없는 div 가 되므로, 경계는 자리(빈 문단 바로 뒤)로 알아본다.
- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다),
한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다),
가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게).
서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 20장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001` — 여러 서명이 함께 쓰는 칸이라 16단계에 10 → 20).
서명 HTML 은 그림을 `<img src="/api/mail-signature/images/{id}" width="144" alt="">` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하).
- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다.
저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400`
(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).
- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
- 서명을 저장 · 지울 때(옛 경로 `PUT` 포함) **어느 서명의 HTML 도 더는 가리키지 않는 그림**(모든 서명의 합집합 밖)을 지운다 — 다른 서명이 쓰는 그림은 남고,
지운 서명에만 쓰던 그림은 함께 지워진다. 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
(그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.
- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
(`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: <sig-{그림 id}@{보내는 사람 도메인}>`, 이름 `signature-1.png` …,
@@ -1144,7 +1187,7 @@ interface MailboxBulkResult {
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계 · 16단계(서식 본문 · 회사 밖 응답)
```ts
interface MailVacation {
@@ -1152,7 +1195,15 @@ interface MailVacation {
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
textBody: string // 보이는 글(서식을 뺀 글) — 켤 때 600자까지(꺼 둔 채로는 4,000자까지), 켤 때는 공백이 아닌 글자 1자 이상. 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
htmlBody: string | null // 16단계 — 서식 본문(그림 없음). 정화해 저장한다 — 켤 때 정화 뒤 10,000자까지. null = 글만. PUT 에 없으면 null
external: MailVacationExternal // 16단계 — 회사 밖에서 온 메일에 하는 응답. PUT 에 없거나 null 이면 사내와 같은 응답(글 칸 null)
}
interface MailVacationExternal { // 16단계 — PUT 에 담으면 네 키를 모두 보낸다(글 칸은 null 가능)
mode: 'same' | 'none' | 'separate' // 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기
subject: string | null // 따로 쓰기의 제목 — 켜고 따로 쓰기면 1~150자(그 밖에는 200자까지 · 제어 문자 없음). 빈 글은 null 로 저장
textBody: string | null // 따로 쓰기의 보이는 글 — 켜고 따로 쓰기면 1~600자(그 밖에는 4,000자까지 · 탭 · LF · CR 밖의 제어 문자 없음). 빈 글은 null
htmlBody: string | null // 따로 쓰기의 서식 본문(그림 없음) — 정화해 저장, 켜고 따로 쓰기면 정화 뒤 10,000자까지
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
@@ -1166,11 +1217,12 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
이 한도는 **켤 때만** 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면
화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, external: { mode: "same", subject: null, textBody: null, htmlBody: null } }` 로 온다
(엔진의 null 은 빈 문자열로 — 엔진 VacationResponse 의 서식 본문은 가져오지 않는다). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
15단계까지 서식 본문은 없었다(글자만 — 16단계는 아래 "서식 본문 · 회사 밖 응답"). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
@@ -1181,6 +1233,45 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **서식 본문 · 회사 밖 응답 (16단계)**:
- **서식 본문**(`htmlBody`)은 서명과 같은 정화(쓰기 화면의 서식 본문 허용 목록 — **그림은 없다**)를 거쳐 저장한다. 정화 전 길이(500,000자) · 파서 비용은 켬 · 끔과 상관없이 보고,
정화 뒤 10,000자(JS 문자열 길이)는 켤 때만 본다(회사 밖 서식은 따로 쓰기이고 켤 때만) — 넘으면 400 `VAL_001`. 정화해서 남는 것이 없으면 null(글만).
보이는 글(`textBody`)은 화면이 서식을 뺀 글로 함께 보낸다 — 켤 때의 600자 한도는 이 글로 본다(지금 그대로).
- 받는 사람에게는 서식이 있으면 **글 + 서식 두 벌**(`multipart/alternative` — text/plain · text/html, 각 base64)로 간다. 서식을 읽지 못하는 메일 프로그램은 글만 보인다.
서식이 없으면 지금처럼 글만(text/plain)이다.
- **회사 밖 응답**(`external`) — 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다: `MAIL_DOMAIN` 과 같으면 사내(하위 도메인은 회사 밖).
엔진은 답장을 봉투 주소로만 보내므로 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다(2026-11-10 개발 엔진 — 실제 배달로 확인).
`same` = 지금처럼 모두에게 같은 응답(처음 값), `none` = 회사 밖에는 답하지 않는다, `separate` = 회사 밖에는 따로 쓴 제목 · 내용으로.
다른 고르기로 바꿔도 따로 써 둔 회사 밖 글은 그대로 저장한다(빈 글은 null). 회사 밖 칸은 따로 쓰기이고 켤 때만 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 보고,
사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다.
- 같은 사람에게 다시 보내는 간격은 엔진 기본 그대로이고, 사내 · 회사 밖은 따로 센다 — 회사 밖 응답에 `:handle` 을 따로 둔다(응답 내용의 지문 — 글을 바꾸면 다시 보낸다,
사내 응답은 15단계까지와 같은 모양이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다).
- 옛 요청(16단계 칸 없음)은 글만 · 사내와 같은 응답으로 저장한다 — 옛 화면이 보는 그대로. 응답(GET · PUT)에는 두 칸을 늘 담는다.
- **만드는 Sieve**(아래 "메일 필터"의 "만드는 Sieve"에 더하는 것):
```
if not header :matches "X-Spam-Score" "spam*" {
if currentdate … { ← 기간이 있으면 (지금 그대로)
if envelope :domain :is "from" "<MAIL_DOMAIN>" { ← 회사 밖 응답이 사내와 같지 않을 때만
vacation :mime :subject "<사내 제목>" "<MIME 개체>";
} else { ← 따로 쓰기만 (보내지 않기면 else 가 없다)
vacation :mime :handle "ara-external-<지문>" :subject "<회사 밖 제목>" "<MIME 개체>";
}
}
필터 규칙 …
}
```
require 에 `envelope`(가를 때) · `variables`(조각이 있을 때)를 더한다. MIME 개체가 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 `variables` 로 3,000바이트 이하 조각에 나눠 담고
(`set "part0" "…";` — 조각은 줄 끝(CRLF) 뒤에서만 끊는다) `vacation :mime "${part0}${part1}…"` 로 잇는다 — 2026-11-10 개발 엔진: 이은 답장의 글 · 서식이 원래와 바이트까지 같다.
사용자 글 · 서식은 base64 · encoded-word 안에만 있어 Sieve 글에 `${` 가 생기지 않는다(지금처럼). 스크립트 90,000바이트 한도(400 `MAIL_013`)는 그대로다 —
서식 · 회사 밖 글 · 차단 목록이 함께 크면 걸릴 수 있다(알려진 한계).
- **원본 판 3** — 머리 주석의 원본(JSON)은 **새 칸을 쓴 직원만** 판 3 으로 쓴다(서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우 — 판 3 은 차단 목록 칸을 늘 담는다).
새 칸을 쓰지 않은 직원은 판 1 · 2 그대로다. 판 3 의 서식 본문은 읽을 때 다시 정화한다(다른 메일 프로그램이 고친 원본의 HTML 이 화면에 그대로 가지 않게 — 정화할 수 없으면 깨진 원본).
**전제 — 서식이 없는 글이면 화면은 `htmlBody: null` 을 보낸다**(웹 설정 화면 — 글만 쓴 직원은 판 1 · 2 에 머문다). 서버는 서식이 있는지 가리지 않고
정화한 `htmlBody` 가 남으면(null · 빈 값이 아니면) 판 3 으로 쓴다 — 그래서 판 3 은 서식을 쓰거나 회사 밖 응답 칸을 쓴 직원뿐이고, 아래 되돌리기 계획이 막는 직원도 그들뿐이다.
- **되돌리기 계획**: 판 3 을 모르는 서버(15단계까지)로 되돌리면 **새 칸을 쓴 직원만** 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 이고 부재중 화면은 꺼짐으로 보인다 —
엔진의 응답(Sieve)은 그대로 돈다(옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 그 직원들이 저장해야 하면 16단계 서버로 다시 올린다
(판 3 을 읽기만 하는 서버를 먼저 배포할 수 있게 둔다 — 9단계 판 2 와 같은 길).
- 로그: 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 남긴다(제목 · 글 · 서식은 남기지 않는다).
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
@@ -1319,6 +1410,7 @@ interface MailFilterActions {
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
판은 쓰는 칸에 따라 가장 낮은 것으로 쓴다 — 1: 규칙 · 부재중, 2: + 차단 목록(9단계 — 아래 "보낸 사람 차단"), 3: + 부재중의 서식 본문 · 회사 밖 응답(16단계 — 위 "부재중 자동 응답").
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
@@ -1341,6 +1433,7 @@ interface MailFilterActions {
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
16단계의 서식 본문(글 + 서식 두 벌) · 회사 밖 응답(봉투 도메인으로 가름) · 긴 개체의 `variables` 조각은 위 "부재중 자동 응답"의 "만드는 Sieve".
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
@@ -1684,11 +1777,12 @@ interface MailSettings {
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
받는 사람에 따라 서명을 저절로 바꾸기 · 앱에서 서명 고르기(16단계 서명은 기본 둘과 쓰기 화면의 [서명 ▾]로 — 자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다),
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
부재중 응답의 「내 연락처에게만」(엔진 Sieve 는 주소록을 보지 못한다) · 부재중 응답의 그림(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
16단계에 한 것: 여러 서명 · 「메일에 넣을 서명」 둘(위 "서명"), 부재중 응답의 서식 본문 · 회사 밖 응답(위 "부재중 자동 응답").
---
@@ -1790,6 +1884,10 @@ interface DirectoryEntry {
| `POST /api/contact-groups/{id}/members` | `{ contactIds?, directoryIds? }` — 합쳐 1~100개 | 200 `ContactGroupMembersAdded` | 400 `VAL_001`, 404 `RES_001`(없는 그룹 · 연락처 · 사내 목록 id), 409 `CONTACT_002`, 503 `MAIL_001` |
| `GET /api/directory-entries` | `q`(선택, 1~254자) · `kind`(선택 — `person` · `shared`) · `excludeGroupId`(선택 — 그 그룹 구성원의 주소를 뺀다. 구성원 넣기 창) · `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: DirectoryEntry[], pageInfo }` — 이름 차례, `total` 포함 | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001`(사내 목록을 읽지 못함 — 아래 "사내 목록") |
| `GET /api/directory-entries/{id}` | — | 200 `DirectoryEntry` | 404 `RES_001`(없거나 빼는 계정), 503 `MAIL_001` |
| `POST /api/contact-import-checks` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 200 `ContactImportCheck` — 사람마다 `new` · `exists`, 남은 자리(아래 "가져오기 · 내보내기") | 400 `VAL_001`, 503 `MAIL_001` |
| `POST /api/contact-imports` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 201 `ContactImportResult` — 넣은 연락처(같은 uid 로 이미 있던 것 포함), 보낸 차례 | 400 `VAL_001`, 404 `RES_001`(없는 groupId), 409 `CONTACT_002`(연락처 3,000개 · 크기 — 묶음 전체로, 하나도 만들지 않음), 503 `MAIL_001` |
| `POST /api/contact-deletions` — 16단계 | `{ ids }` — 1~500개, 겹치지 않게 | 200 `ContactsDeleted` — 영구 삭제(되돌리기 표 없음) | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/contact-exports` — 16단계 | `format`(`vcard` · `csv`) · `groupId`(선택 — 그 그룹만) | 200 파일 — `text/vcard` · `text/csv`(표준 래퍼 없음, `attachment`) | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001` |
- **에러 코드(8단계에 더함)**: `CONTACT_001` 409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).
`CONTACT_002` 409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다
@@ -1875,6 +1973,62 @@ interface DirectoryEntry {
- **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
### 가져오기 · 내보내기 — 16단계 (2026-11-10)
화면은 디자인 원본 `Contacts16`(디자인 README "16단계"). **파일은 화면(브라우저)이 읽는다** — vCard(2.1 · 3.0 · 4.0, 여러 명) · CSV(네이버 · Google · Outlook · 우리가 내보낸 꼴) · 엑셀 .xlsx 를
화면이 풀어 미리 보기까지 하고, 넣기로 한 연락처만 100명씩 서버로 보낸다(파일은 서버로 가지 않는다 — 개인 정보 · 새 서버 파서 없음). 서버는 연락처 추가와 같은 검사 · 같은 한도로 한 요청에 엔진 요청 하나로 만든다.
```ts
interface ContactImportItem extends ContactInput { // 연락처 추가 본문(ContactInput)의 칸 · 검사 그대로 +
uid: string // 'urn:uuid:' + 소문자 UUID — 화면이 사람마다 만든다(엔진 카드의 uid 가 된다)
}
interface ContactImportCheck { // POST /api/contact-import-checks → 200
items: { uid: string; status: 'new' | 'exists' }[] // 보낸 차례
remaining: number // 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다)
}
interface ContactImportResult { // POST /api/contact-imports → 201
items: { uid: string; id: string; skippedGroupIds: string[] }[] // 넣은 연락처만, 보낸 차례 — 빠진 uid 는 넣지 못한 것
}
interface ContactsDeleted { deletedIds: string[]; notFoundIds: string[] } // POST /api/contact-deletions → 200 (보낸 차례)
```
- **확인**(`POST /api/contact-import-checks`): 만들지 않는다. `groupIds` 는 보지 않는다(새 그룹은 넣기 직전에 만든다). 사람마다 아래 가운데 하나면 `exists`, 아니면 `new`:
- 이메일이 있는 사람 — 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자로 견준다) 내 주소록의 어느 연락처에 있다.
- 이메일이 없는 사람 — 이름(앞뒤 공백만 뺀 글 — 가운데 공백 · 대소문자는 그대로)이 같고 **전화번호의 숫자 모음**이 같은 연락처가 있다.
숫자 모음은 번호마다 숫자만 남긴 글의 모음이다(겹침 · 차례는 보지 않는다, 숫자가 없는 번호는 뺀다 — 번호 하나만 같으면 다른 사람이다). 주소록 쪽 연락처는 이메일이 있어도 된다.
둘 다 전화가 없으면 이름만 같아도 `exists`.
- 같은 uid 의 연락처가 이미 있다(다시 보낸 묶음).
**같은 파일 안의 겹침은 화면이 같은 규칙으로 본다**(파일 앞쪽에 같은 사람이 있으면 건너뛴다) — 짝: backend `modules/contacts/contact-import.ts` · frontend `shared/utils/contact-import`(한쪽을 고치면 둘 다).
- **넣기**(`POST /api/contact-imports`): 엔진 `ContactCard/set` 한 번(300명에 90ms — 2026-11-10 확인).
- 사람마다 보낸 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(응답을 잃고 다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다 — 빠진 그룹만 마저 넣는다. 두 번 들어가지 않는다.
- 연락처 3,000개 · 크기(800만 — 위 "크기") 한도는 **묶음 전체로** 미리 본다 — 넘으면 409 `CONTACT_002`, 하나도 만들지 않는다(화면은 `remaining` 으로 미리 줄인다).
- 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고 만들고 `skippedGroupIds` 에 적는다 — 실패가 아니다(보낸 차례대로 넣다가 찬 그룹부터 뺀다). 없는 그룹은 404 `RES_001`(연락처 추가와 같다).
- 검사(연락처 추가와 같은 칸 규칙 · 이름이나 이메일 · 같은 연락처 안의 같은 주소 · uid 모양 · 묶음 안의 같은 uid)에 걸리는 사람이 하나라도 있으면 400 `VAL_001` — 묶음 전체다(화면이 같은 규칙으로 미리 거른다). 겹침은 보지 않는다(확인의 몫).
- 메일 서버가 일부만 받으면 받은 것만 `items` 에 — **보낸 uid 가 빠져 있으면 넣지 못한 것이다**(화면은 "넣지 못함"으로). 던지지 않는다. 그룹 카드가 이미 쓰는 uid 도 만들지 않는다(엔진이 같은 주소록의 같은 uid 를 받지 않는다).
- **새 그룹**은 화면이 넣기 전에 지금 `POST /api/contact-groups` 로 만든다(같은 이름 409 `CONTACT_001` 이면 목록에서 그 그룹을 쓰고, 100개 한도 409 `CONTACT_002` 면 그 그룹 없이). 되돌릴 때 그 그룹은 `DELETE /api/contact-groups/{id}`.
- **한꺼번에 지우기**(`POST /api/contact-deletions`): 가져오기 [되돌리기] 몫이다. 영구 삭제이고 되돌리기 표는 주지 않는다. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로).
내 주소록에 없는 id(이미 지움 · 그룹 id)는 `notFoundIds` — 실패가 아니다. 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 엔진 상한(`maxObjectsInSet` 500)을 넘으면 서버가 앞에서부터 나눠 지운다 —
중간에 503 이면 앞 몫은 지워진 채다(같은 목록을 다시 보내면 그 id 는 `notFoundIds`). 지울 카드는 id 만 읽는다(사진이 든 카드를 통째로 읽지 않는다).
- **요청 수 한도**는 지금 기본(경로 · IP 1분 100회) — 100명씩이면 3,000명도 30번이다. 사무실이 IP 하나로 나가면 그동안 다른 직원의 같은 경로 요청과 나눠 쓴다.
- **본문 한도**는 앱 공통(JSON 1MB · 값 10,000개). 보통 100명이 50KB 남짓이고, 칸이 많은 연락처(네이버 꼴 — 전화 20개 · 이메일 4개 · 그룹 4개)로 채워도 100명이 값 8,600개 남짓이다.
칸을 한도까지 채운 연락처가 몰려 400 `VAL_001`(본문 모양) · 413 `FILE_002` 가 나면 화면은 묶음을 줄여 보낸다(권장).
- **내보내기**(`GET /api/contact-exports`): 파일 응답(표준 래퍼 없음 — 실패는 표준 실패 JSON). 화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 `<a href download>` 로 받는다.
- 내 주소록 전체 또는 그룹 하나(`groupId`), 이름 차례. 사내 목록은 넣지 않는다(회사 직원 명단을 파일로 밖에 내보내는 길을 만들지 않는다). 우리 칸만 — 엔진 카드의 다른 칸(주소 · 생일 · 사진 등)은 넣지 않는다.
- 머리글: `Content-Disposition: attachment` — 이름 `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv`(날짜는 한국 날짜, 그룹 이름의 `\ / : * ? " < > |` 는 `_`, 앞뒤 공백 · 점은 떼고 50글자까지) ·
`Cache-Control: private, no-store` · `X-Content-Type-Options: nosniff` · `Content-Security-Policy: default-src 'none'; sandbox`.
- **vCard 3.0**(`text/vcard; charset=utf-8`): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않는다 — 이어지는 줄은 공백 하나로 시작) · 글 값의 `\` `;` `,` 줄바꿈은 이스케이프.
`UID`(카드에 있으면) · `FN`(이름 — 없으면 첫 이메일, 그것도 없으면 첫 전화번호 — vCard 3.0 은 FN 을 꼭 담는다) · `N`(이름 전체를 성 칸에 — `N:김아라;;;;`, 이름이 없으면 `N:;;;;` — 다시 가져올 때 주소를 이름으로 읽지 않게) · `EMAIL;TYPE=INTERNET,WORK`(회사) / `INTERNET,HOME`(개인) / `INTERNET`(기타) ·
`TEL;TYPE=CELL`(휴대전화) / `WORK` / `HOME` / `VOICE`(기타) · `ORG:회사;부서` · `TITLE` · `NOTE` · `CATEGORIES`(그룹 이름, 쉼표로). 한 파일에 여러 명이다.
- **CSV**(`text/csv; charset=utf-8`): UTF-8 BOM · CRLF · 머리 `이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹` — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection, 다시 가져올 때 떼어 읽는다, 네이버 CSV 와 같은 꼴). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180 — 메모의 줄바꿈은 LF 그대로).
우리 가져오기가 이 머리 줄을 알아본다 — 머리 이름 · 차례를 바꾸면 frontend `shared/utils/contact-import` 도 함께.
- 없는 그룹 404 `RES_001`, 모르는 `format`(없음 포함) · 모르는 쿼리 400 `VAL_001`.
- **로그**: 개수 · 형식 · 그룹 id 만 — 연락처 내용 · 파일 내용 · uid 는 남기지 않는다.
- **알려진 한계**: 한 파일에 여러 명이 든 vCard 를 Outlook 클래식은 첫 사람만 읽는다. 그룹 이름에 `;` 가 있으면 CSV 의 그룹 칸이 둘로 읽힌다. 넣기의 일부를 엔진이 받지 않으면 그 사람의 uid 가 그룹 구성원에 남는다
(그룹 구성원 수는 주소록에 있는 연락처만 세므로 보이지 않는다 — 같은 uid 로 다시 넣으면 그 그룹에 들어간다).
- **이 단계에서 하지 않는 것**: 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진 — 화면이 메모 끝 "가져온 정보" 줄로 붙인다), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 .xls,
서버에서 파일 읽기, 사내 목록 내보내기, 여러 연락처를 골라 지우는 화면(서버 길은 한꺼번에 지우기로 있다), 앱의 주소록.
---
## 4. 대용량 첨부 — 11단계 (2026-10-30 확정)
@@ -2650,7 +2804,7 @@ interface FileUploadCreate {
// GET /api/two-factor → 200
{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null
// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 (꺼져 있으면 켜기용 · 16단계부터 켜져 있으면 휴대폰 바꾸기용 — 아래 "휴대폰 바꾸기", Cache-Control: no-store)
{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)",
"otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30",
"expiresAt": "2026-10-31T00:22:00.000Z" }
@@ -2672,20 +2826,62 @@ interface FileUploadCreate {
3. 대리 접근으로 `currentSecret` + `otpAuth.otpUrl` 을 보낸다. 켠 코드는 2분 기억하고 등록의 비밀은 바로 지운다(`enabledAt` 기록이 실패해도 성공 — 엔진이 기준).
- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로 `currentSecret` + `otpAuth: { otpCode, otpUrl: null }`. 이미 꺼져 있으면 200 이다(같은 결과). 엔진에서 꺼진 뒤 `enabledAt` 기록 지우기가 실패해도 200 이다(error 로그 — 엔진이 기준).
- 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기 · 16단계 휴대폰 바꾸기의 확인 · 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 16단계부터 켠 채 바꾼다 — 아래 "휴대폰 바꾸기 (16단계)". 등록은 켜져 있어도 받는다(엔진에 묻지 않는다) — 켜진 채 받은 등록으로 켜기를 보내면 비밀번호 확인이 402 라 409 `AUTH_008` 이다.
- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README).
| 실패 | 코드 |
| --- | --- |
| 코드 틀림 · 다시 쓴 코드 | 400 `AUTH_006` |
| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(켜기 — 16단계부터 등록은 켜져 있어도 201) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 형식 오류 | 400 `VAL_001` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 웹메일로 다루지 않는 계정 | 401 `AUTH_001` |
| 엔진 장애 · 결과를 모르는 실패 | 503 `MAIL_001` — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) |
### 휴대폰 바꾸기 (16단계 — 2026-11-10)
2단계 인증을 **켠 채** 새 휴대폰으로 옮긴다 — 2단계가 꺼지는 틈이 없어 필수 정책인 직원도 바꾼다(화면은 [끄기]를 숨긴 직원에게도 [휴대폰 바꾸기]를 보인다). 화면은 디자인 원본 `Settings16`(창 하나 · 두 걸음).
```jsonc
// 0) 등록 — 켜져 있어도 받는다 (응답은 켜기와 같은 꼴, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 { "enrollmentId": "…", "secret": "…", "otpauthUrl": "otpauth://…", "expiresAt": "…" }
// ① 새 휴대폰의 코드 — POST /api/two-factor/enrollments/{enrollmentId}/verification → 200
{ "otpCode": "123456" }
// → { "verified": true }
// ② 지금 휴대폰의 코드 + 지금 비밀번호 — POST /api/two-factor/replacements → 200 (GET /api/two-factor 와 같은 모양)
{ "enrollmentId": "…", "currentOtpCode": "654321", "currentPassword": "…" }
```
- **①** 새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 한 단계) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 엔진에는 아직 아무것도 바꾸지 않는다.
코드 확인은 켜기 · 끄기와 같은 코드 가드다(코드 실패 한도 · 받은 코드는 2분 동안 다시 받지 않는다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다(같은 코드로 다시 확인하면 2분 안에는 `AUTH_006`).
- **②** 서버의 차례
1. 등록이 "확인됨"이 아니면 409 `AUTH_008` — 그 등록은 버린다(처음부터).
2. 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 400 `AUTH_005`(세지 않는다). 지금 비밀번호를 본인 Basic 으로 본다 — 402(켜짐 · 비밀번호 맞음)여야 한다.
401 이면 400 `AUTH_005`(로그인 실패 한도로 센다), 200 이면 그사이 다른 곳에서 꺼진 것이라 409 `AUTH_008`(등록을 버린다 — 화면은 상태를 다시 받는다).
엔진은 비밀번호 틀림과 지금 코드 틀림을 가르지 않으므로(둘 다 `forbidden` "Current secret is incorrect.") 비밀번호를 먼저 본다.
3. 다시 쓴 코드인지 보고 코드 실패 한 번을 차지한다(끄기와 같은 결) → 대리 접근 `x:AccountPassword/set` 에 `currentSecret` + `otpAuth: { otpCode: <지금 코드>, otpUrl: <새 otpauth 주소> }` 한 요청
(2026-11-10 개발 엔진 — `updated`, 옛 비밀은 바로 죽고 거듭 바꿀 수 있다).
4. `forbidden` 이면 지금 코드 틀림 400 `AUTH_006`(코드 실패로 센다 — 등록은 남아 맞는 코드로 다시 보낸다). 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 503 `MAIL_001` —
엔진이 `otpUrl` 을 가려(`****`) 다시 읽어도 바뀌었는지 가를 수 없다. 그 코드는 쓰지 않은 것으로 두고 등록도 남긴다.
5. 바뀌면 등록의 비밀을 지운다(새 비밀은 엔진에만). 두 코드는 2분 동안 다시 받지 않는다. 켠 때(`enabledAt`)는 그대로 — 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
- 제한된 세션은 ① · ② 모두 403 `AUTH_010`(등록은 지금처럼 제한된 세션도 부른다). 같은 직원의 2단계 바꾸기와 함께 돌지 않는다(처리 중 429 `REQ_001`).
- 지금 휴대폰을 잃었으면 지금처럼 운영자의 「2단계 해제」 뒤 다시 켠다(필수 정책이면 다음 로그인의 켜기 단계 — 화면은 창 ②에 문의 줄).
- 로그 · 오류 context 에 코드 · 비밀번호 · otpauth 주소 · 엔진 설명 문장은 없다(가린 주소 · 사유만). 바꾸면 info "2단계 인증 휴대폰을 바꿨습니다: a***@…".
| 실패 | 코드 |
| --- | --- |
| 코드 · 등록 id 형식(경로의 id 가 UUID 가 아님 포함) | 400 `VAL_001` |
| ① 새 휴대폰의 코드 틀림 · ② 지금 휴대폰의 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 `AUTH_006` |
| ② 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록 · ②에서 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 | 409 `AUTH_008` — 화면은 처음부터(새 등록) |
| 제한된 세션 | 403 `AUTH_010` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 엔진 장애 · ②의 결과를 모름 | 503 `MAIL_001` |
### 앱 비밀번호 (`/api/app-passwords`)
```jsonc
@@ -2859,10 +3055,11 @@ interface FileUploadCreate {
- 끊은 세션 `DELETE /api/auth/sessions/{id}` 의 모양이 틀린 id 는 404 `RES_001`(없는 것과 같다).
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
- 휴대폰 바꾸기(16단계)의 ②가 503 `MAIL_001`(결과를 모름)로 끝난 뒤 같은 등록으로 다시 보내면, 그사이 바뀌었다면 옛 휴대폰의 코드가 죽어 `AUTH_006` 이다 — 새 휴대폰의 코드로 로그인되는지로 안다.
### 이 단계에서 하지 않는 것
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기,
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기(16단계에서 했다 — 위 "휴대폰 바꾸기"),
앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱.
### 정한 것 (2026-10-31 추천대로)
+9 -7
View File
@@ -3,14 +3,14 @@
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
마지막 정리: 2026-11-09
마지막 정리: 2026-11-10
## 지금
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
- **배포를 기다림**: 메일 14단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 메일 15단계(메일 · 메일함 다루기).
- **다음**: 메일 16단계(개인 설정).
- **배포를 기다림**: 메일 14 · 15단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 메일 16단계(개인 설정).
- **다음**: 16단계가 끝날 즈음 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
## 1부 — 메일
@@ -30,8 +30,8 @@
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 개발 중 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 다음 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 개발 중 |
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
@@ -60,9 +60,11 @@
7일 넘는 예약(메일 엔진 한도라 어렵다).
- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리.
- **개인 설정**(16단계에서 미룸): 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기,
받는 사람에 따라 서명 저절로 바꾸기, 앱에서 서명 고르기, 부재중 응답을 내 연락처에게만 · 그림 넣기.
- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록,
처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기.
- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
- **계정 · 보안**(12 · 13 · 16단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 2단계 휴대폰을 바꿀 때 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면.
- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다).
@@ -0,0 +1,368 @@
<script setup lang="ts">
// 연락처 내보내기 창 (디자인 원본 Contacts16 "5" — 칸 하나 창 400, 새 그룹 창과 같은 폭)
// 「내보낼 연락처」 내 주소록 전체(처음 — "연락처 342명") · 그룹 하나(고르면 그룹 고르기 칸 44 — 첫 그룹이 골라진다, "거래처 · 24명") +
// "내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다." → 「파일 형식」 vCard (.vcf)(처음) · CSV (.csv) → 파일 이름 → [내보내기] [취소]
// - 파일 이름은 주소록-날짜, 그룹이면 주소록-그룹 이름-날짜. 날짜는 이 PC 의 날짜이고 파일 이름에 쓸 수 없는 글자는 _ 로 (서버가 붙이는 이름과 같은 꼴)
// - 구성원이 없는 그룹을 고르면 [내보내기]를 끄고(aria-disabled) 까닭을 그룹 칸 아래에 — 다른 그룹을 고르면 바로 돌아온다
// - 그룹이 하나도 없으면 「그룹 하나」를 고를 수 없다(disabled)
// [내보내기]: 창을 닫고 바로 내려받는다 — 첨부 받기처럼 이동 직전에 로그인을 확인하고(confirmSession), 파일은 서버가 만든다. 따로 알림은 띄우지 않는다
// 고정 id — #contact-export-dialog · #contact-export-title · #contact-export-empty
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useClock } from '@/composables/useClock'
import { useContacts } from '@/composables/useContacts'
import { CONTACT_EXPORT_EXTENSIONS, CONTACT_EXPORT_TEXT } from '@/shared/constants/contacts'
import type { ContactExportFormat } from '@/shared/types/contacts'
import { formatCount, formatDate } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const contactsStore = useContactsStore()
const contactsApi = useContacts()
const clock = useClock()
const EMPTY_ID = 'contact-export-empty'
/** 파일 이름에 쓸 수 없는 글자 (Windows · macOS 공통) */
const UNSAFE_FILE_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g
const scope = ref<'all' | 'group'>('all')
const groupId = ref<string>('')
const format = ref<ContactExportFormat>('vcard')
const groups = computed(() => contactsStore.groups)
const group = computed(() => groups.value.find((item) => item.id === groupId.value) ?? null)
const allHint = computed(() => {
const total = contactsStore.contactTotal
return total === null ? '' : CONTACT_EXPORT_TEXT.allCount(formatCount(total))
})
/** 고른 그룹에 구성원이 없다 — [내보내기]를 끈다 */
const groupEmpty = computed(() => scope.value === 'group' && group.value !== null && group.value.memberCount <= 0)
const unavailable = computed(() => groupEmpty.value || (scope.value === 'group' && group.value === null))
const fileName = computed(() => {
const date = formatDate(new Date(clock.now()))
const extension = CONTACT_EXPORT_EXTENSIONS[format.value]
const parts =
scope.value === 'group' && group.value
? [CONTACT_EXPORT_TEXT.fileBase, group.value.name.replace(UNSAFE_FILE_CHARS, '_'), date]
: [CONTACT_EXPORT_TEXT.fileBase, date]
return `${parts.join('-')}.${extension}`
})
const scopes = computed(() => [
{ value: 'all' as const, label: CONTACT_EXPORT_TEXT.all, hint: allHint.value, disabled: false },
{ value: 'group' as const, label: CONTACT_EXPORT_TEXT.group, hint: CONTACT_EXPORT_TEXT.groupHint, disabled: groups.value.length === 0 },
])
const formats = [
{ value: 'vcard' as const, label: CONTACT_EXPORT_TEXT.vcard, hint: CONTACT_EXPORT_TEXT.vcardHint },
{ value: 'csv' as const, label: CONTACT_EXPORT_TEXT.csv, hint: CONTACT_EXPORT_TEXT.csvHint },
]
function chooseScope(value: 'all' | 'group') {
scope.value = value
if (value === 'group' && !group.value) groupId.value = groups.value[0]?.id ?? ''
}
function submit() {
if (unavailable.value) return
const query = scope.value === 'group' && group.value ? { format: format.value, groupId: group.value.id } : { format: format.value }
emit('close')
void contactsApi.downloadContactExport(query)
}
// 열 때마다 처음 값 — 내 주소록 전체 · vCard
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'all'
groupId.value = ''
format.value = 'vcard'
contactsStore.ensureGroups()
},
{ immediate: true },
)
</script>
<template>
<ModalDialog
id="contact-export-dialog"
:open="open"
:title="CONTACT_EXPORT_TEXT.title"
title-id="contact-export-title"
medium
data-contact-export
@cancel="emit('close')"
>
<fieldset class="export-dialog__fieldset">
<legend class="export-dialog__legend">
{{ CONTACT_EXPORT_TEXT.scopeLegend }}
</legend>
<div
v-for="item in scopes"
:key="item.value"
:class="['export-dialog__card', { 'is-checked': scope === item.value, 'is-disabled': item.disabled }]"
>
<label class="export-dialog__option">
<input
type="radio"
name="contact-export-scope"
class="export-dialog__radio"
:value="item.value"
:checked="scope === item.value"
:disabled="item.disabled"
:data-export-scope="item.value"
@change="chooseScope(item.value)"
>
<span class="export-dialog__text">
<span class="export-dialog__label">{{ item.label }}</span>
<span
v-if="item.hint"
class="export-dialog__hint"
>{{ item.hint }}</span>
</span>
</label>
<template v-if="item.value === 'group' && scope === 'group'">
<select
class="export-dialog__select"
:aria-label="CONTACT_EXPORT_TEXT.groupSelect"
:aria-describedby="groupEmpty ? EMPTY_ID : undefined"
:value="groupId"
data-export-group
@change="groupId = ($event.target as HTMLSelectElement).value"
>
<option
v-for="option in groups"
:key="option.id"
:value="option.id"
>
{{ CONTACT_EXPORT_TEXT.groupOption(option.name, formatCount(option.memberCount)) }}
</option>
</select>
<p
v-if="groupEmpty && group"
:id="EMPTY_ID"
role="status"
class="export-dialog__empty"
>
{{ CONTACT_EXPORT_TEXT.groupEmpty(group.name) }}
</p>
</template>
</div>
<p class="export-dialog__note">
{{ CONTACT_EXPORT_TEXT.directoryNote }}
</p>
</fieldset>
<fieldset class="export-dialog__fieldset export-dialog__fieldset--format">
<legend class="export-dialog__legend">
{{ CONTACT_EXPORT_TEXT.formatLegend }}
</legend>
<label
v-for="item in formats"
:key="item.value"
:class="['export-dialog__card', 'export-dialog__option', { 'is-checked': format === item.value }]"
>
<input
type="radio"
name="contact-export-format"
class="export-dialog__radio"
:value="item.value"
:checked="format === item.value"
:data-export-format="item.value"
@change="format = item.value"
>
<span class="export-dialog__text">
<span class="export-dialog__label">{{ item.label }}</span>
<span class="export-dialog__hint">{{ item.hint }}</span>
</span>
</label>
</fieldset>
<p
v-if="!unavailable"
class="export-dialog__file"
>
<span class="export-dialog__file-label">{{ CONTACT_EXPORT_TEXT.fileName }}</span>
<span
class="export-dialog__file-name"
data-export-file-name
>{{ fileName }}</span>
</p>
<template #actions>
<button
type="button"
class="modal-dialog__action export-dialog__submit"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="groupEmpty ? EMPTY_ID : undefined"
autofocus
data-export-submit
@click="submit"
>
{{ CONTACT_EXPORT_TEXT.submit }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-export-cancel
@click="emit('close')"
>
{{ CONTACT_EXPORT_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.export-dialog__fieldset {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
margin: var(--space-4) 0 0;
padding: 0;
border: 0;
}
.export-dialog__fieldset--format {
margin-top: var(--space-8);
}
.export-dialog__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 고르기 상자 — 테두리 상자, 고른 상자는 청록 테두리 + 옅은 바탕 (차단 창과 같은 결) */
.export-dialog__card {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-height: var(--size-touch);
padding: var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.export-dialog__card.is-checked {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.export-dialog__card.is-disabled {
color: var(--color-icon-muted);
}
.export-dialog__option {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-10);
cursor: pointer;
}
.export-dialog__card.is-disabled .export-dialog__option {
cursor: default;
}
.export-dialog__radio {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.export-dialog__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: var(--line-height-heading);
}
.export-dialog__label {
font-weight: 600;
}
.export-dialog__hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.export-dialog__card.is-disabled .export-dialog__hint {
color: var(--color-icon-muted);
}
/* 그룹 고르기 칸 44 — 고르기 글 선(28)에 맞춰 들여 쓴다 */
.export-dialog__select {
width: calc(100% - var(--space-28));
height: var(--size-touch);
margin-left: var(--space-28);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.export-dialog__select:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.export-dialog__empty {
margin: 0 0 0 var(--space-28);
font-size: var(--font-size-label);
color: var(--color-warning-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
.export-dialog__note {
margin: var(--space-2) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.export-dialog__file {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-8);
margin: var(--space-6) 0 0;
font-size: var(--font-size-label);
}
.export-dialog__file-label {
color: var(--color-text-muted);
}
.export-dialog__file-name {
font-weight: 600;
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
/* 누를 수 없는 [내보내기] — 끄지 않고 흐리게 (초점은 받는다) */
.modal-dialog__action.export-dialog__submit[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,450 @@
<script setup lang="ts">
// 연락처 가져오기 창 (디자인 원본 Contacts16 "2" · "3" — 넓은 창 620, 필터 창 · 연락처 창과 같은 틀)
// ① 파일 고르기(ContactImportPicker): [파일 고르기]는 .vcf · .csv · .xlsx 만 보이게 걸러 둔 파일 고르는 창, 창 어디에 끌어다 놓아도 받는다
// (끄는 동안 놓는 자리가 청록 점선). 파일은 이 브라우저에서 읽고 회사 서버로 올리지 않는다(useContactImport 의 read).
// 읽지 못하면 자리 아래 빨간 글(알림 없음) — 다른 파일을 고르거나 놓으면 지운다. 읽는 동안 [취소]는 읽기를 멈추고 창을 닫는다
// ② 미리 보기: 파일 이름 + [다른 파일 고르기](파일 고르는 창을 바로 연다 — 고른 그룹과 체크 칸은 그대로) · "연락처 340명을 찾았습니다." ·
// 세 묶음(ContactImportSummary) · 그룹(ContactImportGroupFields) · 메모 체크 칸(ContactImportInfoField — 칸이 없는 정보가 있을 때만) ·
// 한도 안내(노란 상자) · [연락처 N명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 단추를 끄고(aria-disabled) 까닭을 단추 옆에.
// 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다)
// [연락처 N명 넣기]: 새 그룹 이름을 비웠으면 칸 아래 "그룹 이름을 넣어 주세요." — 아니면 창을 닫고 넣기를 시작한다(진행 알림 — useContactImport 의 start)
// Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다)
// 고정 id — #contact-import-dialog · #contact-import-title · #contact-import-message · #contact-import-found · #contact-import-nothing
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ContactImportGroupFields from '@/components/contacts/ContactImportGroupFields.vue'
import ContactImportInfoField from '@/components/contacts/ContactImportInfoField.vue'
import ContactImportPicker from '@/components/contacts/ContactImportPicker.vue'
import ContactImportSummary from '@/components/contacts/ContactImportSummary.vue'
import type { ImportGroupMode } from '@/components/contacts/contacts-ui'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useContactImport } from '@/composables/useContactImport'
import {
CONTACT_IMPORT_ACCEPT,
CONTACT_IMPORT_ERROR_TEXT,
CONTACT_IMPORT_TEXT,
CONTACTS_PANEL_TEXT,
} from '@/shared/constants/contacts'
import {
extraGroupName,
fileGroupsOf,
fileGroupsText,
hasImportedInfo,
importPlanOf,
infoSampleOf,
type ImportExtraGroup,
type ImportPreview,
} from '@/shared/utils/contact-import/records'
import type { ContactFileProblem, ImportedFileKind } from '@/shared/utils/contact-import/types'
import { isFileDrag } from '@/shared/utils/editor-files'
import { formatCount } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const contactsStore = useContactsStore()
const importer = useContactImport()
const fileInputRef = ref<HTMLInputElement | null>(null)
const pickerRef = ref<InstanceType<typeof ContactImportPicker> | null>(null)
const groupFieldsRef = ref<InstanceType<typeof ContactImportGroupFields> | null>(null)
const submitRef = ref<HTMLButtonElement | null>(null)
const step = ref<'pick' | 'reading' | 'preview'>('pick')
const error = ref<string | null>(null)
const fileName = ref<string>('')
/** 파일을 끌어 창 위에 있는 동안 — 들어온 요소 수 (자식 사이를 지날 때 깜빡이지 않게) */
const dragDepth = ref<number>(0)
const preview = shallowRef<ImportPreview | null>(null)
const groupMode = ref<ImportGroupMode>('none')
const existingGroupId = ref<string>('')
const newGroupName = ref<string>('')
const newGroupError = ref<string | null>(null)
const appendInfo = ref<boolean>(true)
/** 지금 읽는 파일 — 끊으면(창을 닫음 · 다른 파일) 그 결과는 버린다 */
let reading: AbortController | null = null
const dragging = computed(() => dragDepth.value > 0)
const groups = computed(() => contactsStore.groups)
const insertCount = computed(() => preview.value?.toInsert.length ?? 0)
const foundText = computed(() => CONTACT_IMPORT_TEXT.found(formatCount(preview.value?.found ?? 0)))
const groupsText = computed(() =>
preview.value ? fileGroupsText(fileGroupsOf(preview.value.toInsert, groups.value)) : '',
)
const showInfo = computed(() => preview.value !== null && hasImportedInfo(preview.value.toInsert))
const infoSample = computed(() => (preview.value ? infoSampleOf(preview.value.toInsert) : null))
/** 한도 안내 — 남은 자리가 하나도 없으면(주소록이 가득 참) 패널의 3,000개 안내 */
const limitText = computed(() => {
const overLimit = preview.value?.overLimit ?? 0
if (overLimit <= 0) return null
if (insertCount.value === 0) return CONTACTS_PANEL_TEXT.fullNote
return CONTACT_IMPORT_TEXT.limit(formatCount(insertCount.value), formatCount(overLimit))
})
const submitLabel = computed(() =>
insertCount.value > 0 ? CONTACT_IMPORT_TEXT.submit(formatCount(insertCount.value)) : CONTACT_IMPORT_TEXT.submitNone,
)
function problemText(problem: ContactFileProblem, kind: ImportedFileKind | null): string {
if (problem === 'notFound' && kind === 'vcard') return CONTACT_IMPORT_ERROR_TEXT.notFoundCards
return CONTACT_IMPORT_ERROR_TEXT[problem]
}
function stopReading() {
reading?.abort()
reading = null
}
function close() {
stopReading()
dragDepth.value = 0
emit('close')
}
function openPicker() {
fileInputRef.value?.click()
}
async function readFile(file: File) {
stopReading()
const current = new AbortController()
reading = current
step.value = 'reading'
fileName.value = file.name
error.value = null
const outcome = await importer.read(file, current.signal)
if (reading !== current || current.signal.aborted || outcome.status === 'canceled') return
reading = null
if (outcome.status === 'ready') {
preview.value = outcome.preview
step.value = 'preview'
await nextTick()
submitRef.value?.focus()
return
}
preview.value = null
step.value = 'pick'
// 서버에 묻지 못했으면(error) 인터셉터가 사전 문구로 알렸다 — 칸 아래에는 쓰지 않는다
error.value = outcome.status === 'failed' ? problemText(outcome.problem, outcome.kind) : null
await nextTick()
pickerRef.value?.focusPick()
}
/** 고르거나 놓은 파일 — 하나만 받는다 */
function takeFiles(files: readonly File[]) {
const [file] = files
if (!file) return
if (files.length > 1) {
stopReading()
preview.value = null
step.value = 'pick'
error.value = CONTACT_IMPORT_ERROR_TEXT.multiple
void nextTick(() => pickerRef.value?.focusPick())
return
}
void readFile(file)
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
// 같은 파일을 다시 골라도 change 가 오게 비운다
input.value = ''
takeFiles(files)
}
function onDragEnter(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
dragDepth.value += 1
}
function onDragOver(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = step.value === 'reading' ? 'none' : 'copy'
}
function onDragLeave(event: DragEvent) {
if (!isFileDrag(event)) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
/** 창 어디에 놓아도 받는다 — 읽는 동안은 받지 않는다(브라우저가 파일을 열지 않게 막기만) */
function onDrop(event: DragEvent) {
dragDepth.value = 0
if (!isFileDrag(event)) return
event.preventDefault()
if (step.value === 'reading') return
takeFiles(Array.from(event.dataTransfer?.files ?? []))
}
function extraGroup(): ImportExtraGroup | null {
if (groupMode.value === 'existing' && existingGroupId.value) return { kind: 'existing', id: existingGroupId.value }
if (groupMode.value !== 'new') return { kind: 'none' }
const name = extraGroupName(newGroupName.value)
return name ? { kind: 'new', name } : null
}
function submit() {
const current = preview.value
if (!current || insertCount.value === 0) return
const extra = extraGroup()
if (!extra) {
newGroupError.value = CONTACT_IMPORT_TEXT.extraNameRequired
void nextTick(() => groupFieldsRef.value?.focusNewName())
return
}
const plan = importPlanOf(current, groups.value, extra, appendInfo.value && showInfo.value)
emit('close')
void importer.start(plan)
}
function resetForm() {
stopReading()
step.value = 'pick'
error.value = null
fileName.value = ''
dragDepth.value = 0
preview.value = null
groupMode.value = 'none'
existingGroupId.value = ''
newGroupName.value = ''
newGroupError.value = null
appendInfo.value = true
}
watch(
() => props.open,
(open) => {
if (open) {
resetForm()
contactsStore.ensureGroups()
} else {
stopReading()
}
},
{ immediate: true },
)
watch([newGroupName, groupMode], () => {
newGroupError.value = null
})
onBeforeUnmount(stopReading)
</script>
<template>
<ModalDialog
id="contact-import-dialog"
:open="open"
:title="CONTACT_IMPORT_TEXT.title"
:message="step === 'preview' ? '' : CONTACT_IMPORT_TEXT.message"
title-id="contact-import-title"
message-id="contact-import-message"
:described-by="step === 'preview' ? 'contact-import-found' : undefined"
wide
data-contact-import
@cancel="close"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<ContactImportPicker
v-if="step !== 'preview' || !preview"
ref="pickerRef"
:reading="step === 'reading'"
:dragging="dragging"
:file-name="fileName"
:error="error"
@pick="openPicker"
/>
<template v-else>
<div class="import-dialog__file">
<AppIcon
name="file-text"
:size="16"
class="import-dialog__file-icon"
/>
<span
class="import-dialog__file-name"
data-import-file-name
>{{ fileName }}</span>
<button
type="button"
class="import-dialog__text-button"
data-import-pick-another
@click="openPicker"
>
{{ CONTACT_IMPORT_TEXT.pickAnother }}
</button>
</div>
<p
id="contact-import-found"
class="import-dialog__found"
data-import-found
>
{{ foundText }}
</p>
<ContactImportSummary :preview="preview" />
<template v-if="insertCount > 0">
<ContactImportGroupFields
ref="groupFieldsRef"
v-model:mode="groupMode"
v-model:existing-id="existingGroupId"
v-model:new-name="newGroupName"
:groups-text="groupsText"
:new-name-error="newGroupError"
:groups="groups"
:groups-full="contactsStore.isGroupsFull"
/>
<ContactImportInfoField
v-if="showInfo"
v-model="appendInfo"
:sample="infoSample"
/>
</template>
<p
v-if="limitText"
role="status"
class="import-dialog__limit"
data-import-limit
>
<AppIcon
name="alert-triangle"
:size="15"
:stroke-width="1.9"
class="import-dialog__limit-icon"
/>
<span>{{ limitText }}</span>
</p>
</template>
<input
ref="fileInputRef"
type="file"
class="visually-hidden"
:accept="CONTACT_IMPORT_ACCEPT"
tabindex="-1"
aria-hidden="true"
data-import-file
@change="onFileChange"
>
<template #actions>
<span
v-if="step === 'preview' && preview && insertCount === 0"
id="contact-import-nothing"
role="status"
class="import-dialog__nothing"
>{{ CONTACT_IMPORT_TEXT.nothing }}</span>
<button
v-if="step === 'preview' && preview"
ref="submitRef"
type="button"
class="modal-dialog__action import-dialog__submit"
:aria-disabled="insertCount === 0 ? 'true' : undefined"
:aria-describedby="insertCount === 0 ? 'contact-import-nothing' : undefined"
data-import-submit
@click="submit"
>
{{ submitLabel }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-import-cancel
@click="close"
>
{{ CONTACT_IMPORT_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 파일 줄 — 아이콘 · 이름(줄임) · [다른 파일 고르기] (위 글과 붙여 둔다) */
.import-dialog__file {
display: flex;
align-items: center;
gap: var(--space-8);
min-width: 0;
margin-top: calc(var(--space-4) * -1);
}
.import-dialog__file-icon {
color: var(--color-text-muted);
}
.import-dialog__file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-dialog__text-button {
flex: 0 0 auto;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.import-dialog__text-button:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.import-dialog__found {
margin: calc(var(--space-6) * -1) 0 var(--space-4);
font-size: var(--font-size-body);
font-weight: 600;
}
/* 한도 안내 — 노란 상자 12.5 */
.import-dialog__limit {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: var(--space-6) 0 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-dialog__limit-icon {
margin-top: var(--space-2);
}
/* 넣을 연락처가 없을 때 — 단추 옆(왼쪽)의 까닭 */
.import-dialog__nothing {
align-self: center;
margin-right: auto;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 누를 수 없는 [연락처 넣기] — 끄지 않고 흐리게 (초점은 받는다) */
.modal-dialog__action.import-dialog__submit[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,262 @@
<script setup lang="ts">
// 가져오기 창 ② 의 그룹 (디자인 원본 Contacts16 "3")
// - 파일에 적힌 그룹 글: "파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다." (records.ts 의 fileGroupsText)
// - 「가져온 연락처를 모두 넣을 그룹」: 넣지 않음(처음) · 이미 있는 그룹(고르기 칸 44 — "거래처 · 4명") · 새 그룹(이름 칸 44 · 50자)
// 그룹이 100개면 「새 그룹」을 끄고(aria-disabled — 초점은 받는다) 아래에 "그룹은 100개까지 만들 수 있습니다.", 그룹이 하나도 없으면 「이미 있는 그룹」을 끈다
// 새 그룹 이름을 비운 채 넣으려 하면 칸 아래 "그룹 이름을 넣어 주세요."(창이 정한다). 새 그룹 이름이 이미 있는 그룹과 같으면 그 그룹에 넣는다
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { ImportGroupMode } from '@/components/contacts/contacts-ui'
import {
CONTACT_GROUP_NAME_MAX_LENGTH,
CONTACT_IMPORT_TEXT,
CONTACTS_PANEL_TEXT,
} from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import { formatCount } from '@/shared/utils/format'
interface Props {
groupsText: string
mode: ImportGroupMode
existingId: string
newName: string
newNameError: string | null
groups: readonly ContactGroup[]
groupsFull: boolean
}
interface Emits {
(e: 'update:mode', value: ImportGroupMode): void
(e: 'update:existingId', value: string): void
(e: 'update:newName', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const NEW_NOTE_ID = 'contact-import-new-group-note'
const NEW_ERROR_ID = 'contact-import-new-group-error'
const nameRef = ref<HTMLInputElement | null>(null)
const modes = computed(() => [
{ value: 'none' as const, label: CONTACT_IMPORT_TEXT.extraNone, unavailable: false },
{ value: 'existing' as const, label: CONTACT_IMPORT_TEXT.extraExisting, unavailable: props.groups.length === 0 },
{ value: 'new' as const, label: CONTACT_IMPORT_TEXT.extraNew, unavailable: props.groupsFull },
])
/** 고를 수 없는 줄은 눌러도 바꾸지 않는다 (aria-disabled — 화살표로 와도 앞 고르기가 남는다) */
function onModeClick(event: MouseEvent, unavailable: boolean) {
if (unavailable) event.preventDefault()
}
function onModeChange(value: ImportGroupMode) {
emit('update:mode', value)
if (value === 'existing' && !props.groups.some((group) => group.id === props.existingId)) {
emit('update:existingId', props.groups[0]?.id ?? '')
}
if (value === 'new') void nextTick(() => nameRef.value?.focus())
}
defineExpose({
focusNewName: () => nameRef.value?.focus(),
})
</script>
<template>
<div class="import-groups">
<div class="import-groups__file">
<p class="import-groups__label">
{{ CONTACT_IMPORT_TEXT.groupsLabel }}
</p>
<p
class="import-groups__text"
data-import-groups-text
>
{{ groupsText }}
</p>
</div>
<fieldset class="import-groups__fieldset">
<legend class="import-groups__label">
{{ CONTACT_IMPORT_TEXT.extraLegend }}
</legend>
<div class="import-groups__modes">
<label
v-for="item in modes"
:key="item.value"
:class="['import-groups__mode', { 'is-unavailable': item.unavailable }]"
>
<input
type="radio"
name="contact-import-group-mode"
class="import-groups__radio"
:value="item.value"
:checked="mode === item.value"
:aria-disabled="item.unavailable ? 'true' : undefined"
:aria-describedby="item.value === 'new' && item.unavailable ? NEW_NOTE_ID : undefined"
:data-import-group-mode="item.value"
@click="onModeClick($event, item.unavailable)"
@change="onModeChange(item.value)"
>
<span>{{ item.label }}</span>
</label>
</div>
<p
v-if="groupsFull"
:id="NEW_NOTE_ID"
class="import-groups__note"
>
{{ CONTACTS_PANEL_TEXT.groupsFullNote }}
</p>
<select
v-if="mode === 'existing'"
class="import-groups__control"
:aria-label="CONTACT_IMPORT_TEXT.extraLegend"
:value="existingId"
data-import-group-select
@change="emit('update:existingId', ($event.target as HTMLSelectElement).value)"
>
<option
v-for="group in groups"
:key="group.id"
:value="group.id"
>
{{ CONTACT_IMPORT_TEXT.groupOption(group.name, formatCount(group.memberCount)) }}
</option>
</select>
<template v-if="mode === 'new'">
<input
ref="nameRef"
class="import-groups__control"
type="text"
:value="newName"
:aria-label="CONTACT_IMPORT_TEXT.extraNewName"
:placeholder="CONTACT_IMPORT_TEXT.extraNewName"
:maxlength="CONTACT_GROUP_NAME_MAX_LENGTH"
autocomplete="off"
:aria-invalid="newNameError ? 'true' : 'false'"
:aria-describedby="newNameError ? NEW_ERROR_ID : undefined"
data-import-group-name
@input="emit('update:newName', ($event.target as HTMLInputElement).value)"
>
<p
v-if="newNameError"
:id="NEW_ERROR_ID"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ newNameError }}</span>
</p>
</template>
<p class="import-groups__hint">
{{ CONTACT_IMPORT_TEXT.extraHint }}
</p>
</fieldset>
</div>
</template>
<style scoped>
.import-groups {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-top: var(--space-6);
}
.import-groups__file {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.import-groups__label {
margin: 0;
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.import-groups__text {
margin: 0;
color: var(--color-text-strong);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-groups__fieldset {
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.import-groups__modes {
display: flex;
flex-wrap: wrap;
gap: 0 var(--space-22);
}
/* 고르기 줄 44 */
.import-groups__mode {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.import-groups__mode.is-unavailable {
opacity: 0.5;
cursor: default;
}
.import-groups__radio {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
.import-groups__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 고르기 칸 · 이름 칸 280 × 44 */
.import-groups__control {
width: min(17.5rem, 100%);
height: var(--size-touch);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.import-groups__control:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.import-groups__control[aria-invalid='true'] {
border-color: var(--color-danger);
}
.import-groups__hint {
margin: var(--space-4) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
// 가져오기 창 ② 의 메모 체크 칸 (디자인 원본 Contacts16 "3")
// 「주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기」(처음 켜짐) + "메모가 1,000자를 넘게 되면 붙이지 않습니다."
// 켜 두면 아래에 견본 — "예: 강민지의 메모": 원래 메모(흐리게) · 빈 줄 · "가져온 정보" · "회사 주소: …" 줄들 (records.ts 의 infoSampleOf)
import { CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
import type { ImportInfoSample } from '@/shared/utils/contact-import/records'
interface Props {
modelValue: boolean
sample: ImportInfoSample | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const HELP_ID = 'contact-import-info-help'
</script>
<template>
<div class="import-info">
<label class="import-info__check">
<input
type="checkbox"
class="import-info__input"
:checked="modelValue"
:aria-describedby="HELP_ID"
data-import-info
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
>
<span>{{ CONTACT_IMPORT_TEXT.info }}</span>
</label>
<p
:id="HELP_ID"
class="import-info__help"
>
{{ CONTACT_IMPORT_TEXT.infoHint }}
</p>
<div
v-if="modelValue && sample"
class="import-info__sample"
data-import-info-sample
>
<span class="import-info__caption">{{ CONTACT_IMPORT_TEXT.infoSample(sample.name) }}</span>
<span
v-for="(line, index) in sample.original"
:key="`o${index}`"
class="import-info__line import-info__line--original"
>{{ line }}</span>
<span
v-if="sample.original.length > 0"
class="import-info__gap"
aria-hidden="true"
/>
<span
v-for="(line, index) in sample.added"
:key="`a${index}`"
class="import-info__line"
>{{ line }}</span>
</div>
</div>
</template>
<style scoped>
.import-info {
display: flex;
flex-direction: column;
}
/* 체크 줄 44 — 위 12, 글이 두 줄이면 상자를 첫 줄에 맞춘다 */
.import-info__check {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding-top: var(--space-12);
cursor: pointer;
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-info__input {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.import-info__help {
margin: var(--space-2) 0 0 var(--space-28);
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 견본 — 옅은 회색 상자, 원래 메모는 흐리게 */
.import-info__sample {
display: flex;
flex-direction: column;
margin: var(--space-8) 0 0 var(--space-28);
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
}
.import-info__caption {
margin-bottom: var(--space-4);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.import-info__line {
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.import-info__line--original {
color: var(--color-text-muted);
}
.import-info__gap {
min-height: var(--space-8);
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
// 가져오기 창 ① 파일 고르기 (디자인 원본 Contacts16 "2") — 놓는 자리 · 읽지 못한 까닭 · 받는 형식 안내
// - 처음: 올리기 아이콘 + [파일 고르기](채운 단추 — 창의 처음 초점) + "파일을 여기로 끌어다 놓아도 됩니다."
// - 끄는 동안(dragging — 창 어디에 끌어 와도): 자리가 2px 청록 점선 · 옅은 청록 바탕, "놓으면 이 파일에서 연락처를 찾습니다"
// - 읽는 동안: 도는 아이콘 + "파일을 읽는 중…" + 파일 이름. 화면 낭독기에는 늘 있는 상태 영역(role=status — 글만 바꾼다)이 같은 글을 읽는다
// - 읽지 못했으면 자리 아래 빨간 글(role=alert — 알림은 띄우지 않는다). 다른 파일을 고르거나 놓으면 창이 지운다
// 끌어다 놓기 처리는 창(ContactImportDialog)이 한다 — 창 어디에 놓아도 받는다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
interface Props {
reading: boolean
dragging: boolean
fileName: string
error: string | null
}
interface Emits {
(e: 'pick'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const pickRef = ref<HTMLButtonElement | null>(null)
defineExpose({
/** [파일 고르기]에 초점을 둔다 — 읽지 못한 뒤 */
focusPick: () => pickRef.value?.focus(),
})
</script>
<template>
<div class="import-picker">
<div
:class="['import-picker__zone', { 'is-dragging': dragging && !reading }]"
data-import-zone
>
<template v-if="reading">
<AppIcon
name="loader"
:size="24"
:stroke-width="2"
class="import-picker__spinner"
/>
<!-- 같은 글을 화면 낭독기는 아래 상태 영역(늘 있다)에서 읽는다 — 두 번 읽지 않게 숨긴다 -->
<p
class="import-picker__reading"
aria-hidden="true"
>
{{ CONTACT_IMPORT_TEXT.reading }}
</p>
<p class="import-picker__file">
{{ fileName }}
</p>
</template>
<template v-else-if="dragging">
<AppIcon
name="upload"
:size="28"
class="import-picker__icon import-picker__icon--active"
/>
<p class="import-picker__dropping">
{{ CONTACT_IMPORT_TEXT.dropping }}
</p>
</template>
<template v-else>
<AppIcon
name="upload"
:size="28"
:stroke-width="1.5"
class="import-picker__icon"
/>
<button
ref="pickRef"
type="button"
class="import-picker__pick"
autofocus
data-import-pick
@click="emit('pick')"
>
{{ CONTACT_IMPORT_TEXT.pick }}
</button>
<p class="import-picker__hint">
{{ CONTACT_IMPORT_TEXT.dropHint }}
</p>
</template>
</div>
<!-- 상태 영역은 늘 두고 글만 바꾼다 — 글과 함께 생기는 영역은 화면 낭독기가 읽지 않기도 한다 -->
<p
class="visually-hidden"
role="status"
data-import-status
>
{{ reading ? CONTACT_IMPORT_TEXT.reading : '' }}
</p>
<p
v-if="error"
role="alert"
class="import-picker__error"
data-import-error
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
class="import-picker__error-icon"
/>
<span>{{ error }}</span>
</p>
<p class="import-picker__formats">
{{ CONTACT_IMPORT_TEXT.formats }}
</p>
</div>
</template>
<style scoped>
.import-picker {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin-top: var(--space-6);
}
/* 놓는 자리 168 — 1px 회색 점선 · 옅은 회색 바탕 (디자인 원본 Contacts16 "2") */
.import-picker__zone {
min-height: 10.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) dashed var(--color-icon-muted);
border-radius: var(--radius-lg);
background: var(--color-surface-panel);
text-align: center;
}
/* 끄는 동안 — 2px 청록 점선 · 옅은 청록 바탕 (테두리가 1px 굵어진 만큼 안쪽을 줄여 자리가 그대로) */
.import-picker__zone.is-dragging {
padding: calc(var(--space-20) - var(--border-width));
border: calc(var(--border-width) * 2) dashed var(--color-brand);
background: var(--color-brand-tint);
}
.import-picker__icon {
color: var(--color-icon-muted);
}
.import-picker__icon--active {
color: var(--color-brand-strong);
}
.import-picker__pick {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.import-picker__pick:hover {
background: var(--color-brand-strong);
}
.import-picker__hint,
.import-picker__formats,
.import-picker__file {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.import-picker__file {
max-width: 100%;
overflow-wrap: anywhere;
}
.import-picker__dropping {
margin: 0;
font-weight: 600;
color: var(--color-brand-strong);
}
.import-picker__reading {
margin: 0;
font-weight: 600;
color: var(--color-text-strong);
}
.import-picker__spinner {
color: var(--color-brand);
animation: import-picker-spin 0.9s linear infinite;
}
@keyframes import-picker-spin {
to {
transform: rotate(360deg);
}
}
/* 읽지 못한 까닭 — 칸 아래 빨간 글 12.5 */
.import-picker__error {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-picker__error-icon {
margin-top: var(--space-2);
}
@media (prefers-reduced-motion: reduce) {
.import-picker__spinner {
animation: none;
}
}
</style>
@@ -0,0 +1,378 @@
<script setup lang="ts">
// 가져오기 창 ② 미리 보기의 세 묶음 (디자인 원본 Contacts16 "3") — 글과 숫자로만 나눈다(뱃지 없음)
// - 넣을 연락처: 앞 6명의 표(이름 · 이메일 · 전화 · 회사 · 그룹) + "그 밖에 N명"
// - 이미 있는 연락처는 건너뜁니다 · 넣지 못하는 줄(vCard 는 연락처): 눌러 펼친다(aria-expanded). 펼친 목록은 6줄 높이까지 늘고 넘으면 목록 안에서 넘긴다
// 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 "12번째 연락처". 빈 묶음은 두지 않는다
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { CONTACT_IMPORT_PREVIEW_ROWS, CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
import {
duplicateReasonText,
importPositionText,
previewRowsOf,
type ImportPreview,
} from '@/shared/utils/contact-import/records'
import { formatCount } from '@/shared/utils/format'
interface Props {
preview: ImportPreview
}
const props = defineProps<Props>()
const duplicatesOpen = ref<boolean>(false)
const problemsOpen = ref<boolean>(false)
const rows = computed(() => previewRowsOf(props.preview))
const others = computed(() => Math.max(0, props.preview.toInsert.length - CONTACT_IMPORT_PREVIEW_ROWS))
const vcard = computed(() => props.preview.kind === 'vcard')
const problemLabel = computed(() =>
vcard.value ? CONTACT_IMPORT_TEXT.problemCards : CONTACT_IMPORT_TEXT.problemLines,
)
const problemCount = computed(() => {
const count = formatCount(props.preview.problems.length)
return vcard.value ? CONTACT_IMPORT_TEXT.people(count) : CONTACT_IMPORT_TEXT.lineCount(count)
})
const duplicates = computed(() =>
props.preview.duplicates.map((duplicate) => ({
key: `${duplicate.position}`,
position: importPositionText(props.preview.kind, duplicate.position),
name: duplicate.name,
detail: duplicate.detail,
reason: duplicateReasonText(props.preview.kind, duplicate),
})),
)
const problems = computed(() =>
props.preview.problems.map((problem) => ({
key: `${problem.position}`,
position: importPositionText(props.preview.kind, problem.position),
what: problem.what,
reason: problem.reason,
})),
)
</script>
<template>
<div
class="import-summary"
data-import-summary
>
<div
v-if="preview.toInsert.length > 0"
class="import-summary__insert"
>
<div class="import-summary__head">
<span class="import-summary__label">{{ CONTACT_IMPORT_TEXT.insertSection }}</span>
<span
class="import-summary__count"
data-import-insert-count
>{{ CONTACT_IMPORT_TEXT.people(formatCount(preview.toInsert.length)) }}</span>
</div>
<div
role="table"
class="import-summary__table"
:aria-label="CONTACT_IMPORT_TEXT.insertTable(formatCount(rows.length))"
>
<div
role="row"
class="import-summary__row import-summary__row--head"
>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.name }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.email }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.phone }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.company }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.groups }}</span>
</div>
<div
v-for="row in rows"
:key="row.key"
role="row"
class="import-summary__row"
data-import-row
>
<span
role="cell"
class="import-summary__cell import-summary__cell--name"
>{{ row.name }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.email }}</span>
<span
role="cell"
class="import-summary__cell import-summary__cell--number"
>{{ row.phone }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.company }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.groups }}</span>
</div>
</div>
<p
v-if="others > 0"
class="import-summary__others"
>
{{ CONTACT_IMPORT_TEXT.others(formatCount(others)) }}
</p>
</div>
<div
v-if="duplicates.length > 0"
class="import-summary__section"
>
<button
type="button"
class="import-summary__toggle"
:aria-expanded="duplicatesOpen ? 'true' : 'false'"
aria-controls="contact-import-duplicates"
data-import-duplicates-toggle
@click="duplicatesOpen = !duplicatesOpen"
>
<AppIcon
:name="duplicatesOpen ? 'chevron-down' : 'chevron-right'"
:size="14"
:stroke-width="2"
class="import-summary__chevron"
/>
<span class="import-summary__label">{{ CONTACT_IMPORT_TEXT.duplicateSection }}</span>
<span class="import-summary__count">{{ CONTACT_IMPORT_TEXT.people(formatCount(duplicates.length)) }}</span>
</button>
<ul
v-show="duplicatesOpen"
id="contact-import-duplicates"
class="import-summary__list"
:aria-label="CONTACT_IMPORT_TEXT.duplicateSection"
data-import-duplicates
>
<li
v-for="item in duplicates"
:key="item.key"
class="import-summary__item"
>
<span class="import-summary__position">{{ item.position }}</span>
<span class="import-summary__who"><span class="import-summary__who-name">{{ item.name }}</span>{{ item.detail }}</span>
<span class="import-summary__reason">{{ item.reason }}</span>
</li>
</ul>
</div>
<div
v-if="problems.length > 0"
class="import-summary__section"
>
<button
type="button"
class="import-summary__toggle"
:aria-expanded="problemsOpen ? 'true' : 'false'"
aria-controls="contact-import-problems"
data-import-problems-toggle
@click="problemsOpen = !problemsOpen"
>
<AppIcon
:name="problemsOpen ? 'chevron-down' : 'chevron-right'"
:size="14"
:stroke-width="2"
class="import-summary__chevron"
/>
<span class="import-summary__label">{{ problemLabel }}</span>
<span class="import-summary__count">{{ problemCount }}</span>
</button>
<ul
v-show="problemsOpen"
id="contact-import-problems"
class="import-summary__list"
:aria-label="problemLabel"
data-import-problems
>
<li
v-for="item in problems"
:key="item.key"
class="import-summary__item import-summary__item--problem"
>
<span class="import-summary__position">{{ item.position }}</span>
<span class="import-summary__problem">
<span class="import-summary__what">{{ item.what }}</span>
<span class="import-summary__problem-reason">{{ item.reason }}</span>
</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.import-summary {
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.import-summary__insert {
display: flex;
flex-direction: column;
padding: var(--space-10) var(--space-12);
}
.import-summary__head {
display: flex;
align-items: baseline;
gap: var(--space-8);
padding-bottom: var(--space-6);
}
.import-summary__label {
font-weight: 600;
}
.import-summary__count {
margin-left: auto;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.import-summary__table {
display: flex;
flex-direction: column;
font-size: var(--font-size-label);
}
/* 표 칸 — 이름 64 · 이메일 1.7fr · 전화 96 · 회사 1fr · 그룹 76 (디자인 원본 Contacts16 "3") */
.import-summary__row {
display: grid;
grid-template-columns: 4rem minmax(0, 1.7fr) 6rem minmax(0, 1fr) 4.75rem;
column-gap: var(--space-10);
align-items: center;
min-height: 1.875rem;
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.import-summary__row--head {
min-height: 1.75rem;
border-bottom-color: var(--color-border);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.import-summary__cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__cell--name {
font-weight: 600;
color: var(--color-text);
}
.import-summary__cell--number {
font-variant-numeric: tabular-nums;
}
.import-summary__others {
margin: var(--space-6) 0 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.import-summary__section {
border-top: var(--border-width) solid var(--color-border);
}
.import-summary__section:first-child {
border-top: 0;
}
/* 펼치기 줄 44 */
.import-summary__toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
background: var(--color-surface);
color: var(--color-text);
text-align: left;
}
.import-summary__toggle:hover {
background: var(--color-surface-panel);
}
.import-summary__chevron {
color: var(--color-text-muted);
}
/* 펼친 목록 — 6줄 높이(186)까지, 넘으면 목록 안에서 넘긴다 */
.import-summary__list {
max-height: 11.625rem;
margin: 0;
padding: 0 var(--space-16) var(--space-6) 2.125rem;
overflow-y: auto;
list-style: none;
}
.import-summary__item {
display: grid;
grid-template-columns: 4.75rem minmax(0, 1fr) auto;
column-gap: var(--space-10);
align-items: center;
min-height: 1.875rem;
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-label);
}
.import-summary__item--problem {
grid-template-columns: 4.75rem minmax(0, 1fr);
padding: var(--space-6) 0;
}
.import-summary__position {
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.import-summary__who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__who-name {
margin-right: var(--space-6);
font-weight: 600;
color: var(--color-text);
}
.import-summary__reason {
color: var(--color-text-muted);
}
.import-summary__problem {
display: flex;
flex-direction: column;
min-width: 0;
}
.import-summary__what {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__problem-reason {
color: var(--color-warning-text);
}
</style>
@@ -1,13 +1,16 @@
<script setup lang="ts">
// 주소록 화면의 창 묶음 — 패널 · 목록 · 상세가 연 창을 그린다 (useContactsDialogs · 메일함 패널의 MailboxDialogs 와 같은 결)
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog)
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog) ·
// 가져오기(ContactImportDialog) · 내보내기(ContactExportDialog — 16단계)
// - 창은 한 번 그리면 두고 열고 닫기만 한다 — 닫을 때 초점이 연 단추로 돌아가게(ModalDialog). 닫히는 동안 글이 바뀌지 않게 마지막 요청을 쥐고 있는다
// - 닫은 뒤 연 단추가 사라져(지운 연락처의 [고치기] 등) 초점을 잃었으면 목록 칸으로 옮긴다
import { computed, nextTick, shallowRef, watch } from 'vue'
import ContactDialog from '@/components/contacts/ContactDialog.vue'
import ContactExportDialog from '@/components/contacts/ContactExportDialog.vue'
import ContactGroupMembersDialog from '@/components/contacts/ContactGroupMembersDialog.vue'
import ContactGroupNameDialog from '@/components/contacts/ContactGroupNameDialog.vue'
import ContactImportDialog from '@/components/contacts/ContactImportDialog.vue'
import type { ContactsDialogRequest } from '@/composables/useContactsDialogs'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { ContactPrefill } from '@/shared/utils/contacts'
@@ -75,4 +78,12 @@ watch(
:group="membersTarget"
@close="close"
/>
<ContactImportDialog
:open="kind === 'import'"
@close="close"
/>
<ContactExportDialog
:open="kind === 'export'"
@close="close"
/>
</template>
+123 -13
View File
@@ -6,6 +6,9 @@
// - 줄(ContactsListItem) — 이름 차례(서버), 100명씩 받고 끝에 [더 보기]
// - 상태: 불러오는 중(뼈대) · 못 받음(사내 목록은 "사내 목록을 불러오지 못했습니다" + [다시 시도], 없는 그룹은 [전체 연락처로]) ·
// 찾은 것 없음 · 처음(연락처 없음 — [연락처 추가]) · 빈 그룹([구성원 넣기]) · 빈 사내 목록 (디자인 원본 ContactsEdit "상태")
// - 16단계(디자인 원본 Contacts16 "1"): 「전체 연락처」 머리 오른쪽에 [가져오기] · [내보내기](도구 단추 — 그룹 머리와 같은 꼴).
// 연락처가 하나도 없으면 [내보내기]를 끄고(aria-disabled) 까닭 "내보낼 연락처가 없습니다."(title · 숨긴 글), 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로,
// 가져오는 동안(되돌리기 포함)은 [가져오기]가 바쁜 표시(aria-busy — 눌러도 아무 일이 없다). 빈 주소록 안내에는 테두리 단추 [가져오기]와 안내 한 줄을 더한다
// data-contacts-list — 지운 뒤 · 창을 닫은 뒤 초점을 잃으면 여기로 (useContactActions · ContactsDialogs)
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -21,11 +24,13 @@ import {
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_PANEL_TEXT,
CONTACTS_TRANSFER_TEXT,
DIRECTORY_KIND_TEXT,
} from '@/shared/constants/contacts'
import { contactDisplayName, contactListMeta, primaryEmail } from '@/shared/utils/contacts'
import { contactsItemLocation, contactsListLocation, type ContactsRouteState } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useContactImportStore } from '@/stores/contact-import.store'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
@@ -35,10 +40,16 @@ interface Props {
const props = defineProps<Props>()
const store = useContactsStore()
const importStore = useContactImportStore()
const dialogs = useContactsDialogs()
const actions = useContactActions()
const router = useRouter()
/** 「전체 연락처」 머리의 [가져오기] · [내보내기]가 꺼진 까닭 (숨긴 글 — aria-describedby) */
const EXPORT_NOTE_ID = 'contacts-export-note'
/** 패널의 3,000개 안내 (ContactsPanel) — [가져오기]가 꺼진 까닭으로 함께 쓴다 */
const FULL_NOTE_ID = 'contacts-full-note'
/** 목록 줄 — 연락처 · 사내 항목을 같은 모양으로 */
interface ListRow {
id: string
@@ -93,6 +104,10 @@ const groupEmpty = computed(() => group.value !== null && group.value.memberCoun
const composing = computed(() => group.value !== null && store.composingGroupId === group.value.id)
/** 없는 그룹 — 다른 곳에서 지웠거나 주소에 남은 옛 id */
const missingGroup = computed(() => source.value.kind === 'group' && store.listError?.code === 'RES_001')
/** 연락처가 하나도 없다 — [내보내기]를 끈다 (수를 모르면 끄지 않는다) */
const nothingToExport = computed(() => store.contactTotal === 0)
/** 이 탭에서 가져오기 · 되돌리기가 도는 중 — [가져오기]의 바쁜 표시 */
const importing = computed(() => importStore.busy !== null)
function itemLocation(id: string) {
return contactsItemLocation(source.value, id, props.state.q)
@@ -117,6 +132,15 @@ function leaveToAll() {
function addContact() {
if (!store.isFull) dialogs.openContact(null)
}
/** [가져오기] — 3,000개 · 가져오는 중이면 아무 일이 없다 */
function openImport() {
if (!store.isFull && !importing.value) dialogs.openImport()
}
function openExport() {
if (!nothingToExport.value) dialogs.openExport()
}
</script>
<template>
@@ -166,6 +190,35 @@ function addContact() {
class="visually-hidden"
>{{ CONTACT_GROUP_MENU_TEXT.composeEmptyNote }}</span>
</div>
<div
v-else-if="source.kind === 'all'"
class="contacts-list__actions"
>
<MailToolButton
icon="upload"
:label="CONTACTS_TRANSFER_TEXT.import"
:unavailable="store.isFull"
:busy="importing"
:describedby="store.isFull ? FULL_NOTE_ID : undefined"
:title="store.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined"
data-contacts-import
@click="openImport"
/>
<MailToolButton
icon="download"
:label="CONTACTS_TRANSFER_TEXT.export"
:unavailable="nothingToExport"
:describedby="nothingToExport ? EXPORT_NOTE_ID : undefined"
:title="nothingToExport ? CONTACTS_TRANSFER_TEXT.exportEmptyNote : undefined"
data-contacts-export
@click="openExport"
/>
<span
v-if="nothingToExport"
:id="EXPORT_NOTE_ID"
class="visually-hidden"
>{{ CONTACTS_TRANSFER_TEXT.exportEmptyNote }}</span>
</div>
</div>
<p
v-if="directoryNote"
@@ -295,19 +348,39 @@ function addContact() {
:hint="CONTACTS_EMPTY_TEXT.firstHint"
data-contacts-empty="first"
>
<button
type="button"
class="contacts-list__primary-button"
:aria-disabled="store.isFull ? 'true' : undefined"
@click="addContact"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<div class="contacts-list__first-actions">
<button
type="button"
class="contacts-list__primary-button"
:aria-disabled="store.isFull ? 'true' : undefined"
@click="addContact"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<button
type="button"
class="contacts-list__outline-button contacts-list__outline-button--icon"
:aria-disabled="store.isFull ? 'true' : undefined"
:aria-busy="importing ? 'true' : undefined"
data-contacts-first-import
@click="openImport"
>
<AppIcon
name="upload"
:size="15"
:stroke-width="1.9"
/>
<span>{{ CONTACTS_TRANSFER_TEXT.import }}</span>
</button>
</div>
<p class="contacts-list__first-note contacts-list__first-note--import">
{{ CONTACTS_TRANSFER_TEXT.firstImportNote }}
</p>
<p class="contacts-list__first-note">
{{ CONTACTS_EMPTY_TEXT.firstNote }}
</p>
@@ -514,6 +587,43 @@ function addContact() {
color: var(--color-text-muted);
}
/* 빈 주소록의 단추 둘 — [연락처 추가] · 테두리 [가져오기] (디자인 원본 Contacts16 "1") */
.contacts-list__first-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-8);
margin-top: var(--space-6);
}
.contacts-list__first-actions .contacts-list__primary-button,
.contacts-list__first-actions .contacts-list__outline-button {
margin-top: 0;
}
.contacts-list__outline-button--icon {
display: inline-flex;
align-items: center;
gap: var(--space-8);
color: var(--color-text-strong);
}
.contacts-list__outline-button--icon[aria-disabled='true'],
.contacts-list__outline-button--icon[aria-disabled='true']:hover {
background: transparent;
color: var(--color-text-strong);
opacity: 0.5;
cursor: not-allowed;
}
.contacts-list__outline-button--icon[aria-busy='true'] {
cursor: progress;
}
.contacts-list__first-note--import {
margin-top: var(--space-6);
}
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.contacts-list {
@@ -1,4 +1,5 @@
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog)
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog),
// 가져오기 창의 그룹 칸(ContactImportGroupFields) ↔ 창(ContactImportDialog)
/** 그룹 줄 ⋯ 메뉴가 할 수 있는 일 */
export type ContactGroupAction = 'rename' | 'compose' | 'delete'
@@ -24,6 +25,9 @@ export interface ContactEntryPatch {
value?: string
}
/** 가져오기 창 — 가져온 연락처를 모두 넣을 그룹 (넣지 않음 · 이미 있는 그룹 · 새 그룹) */
export type ImportGroupMode = 'none' | 'existing' | 'new'
/** 키 하나를 뺀 기록 — 같은 주소 안내를 지울 때 */
export function withoutKey<T>(record: Readonly<Record<number, T>>, key: number): Record<number, T> {
const next: Record<number, T> = { ...record }
+18 -2
View File
@@ -8,6 +8,7 @@
// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 —
// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰)
// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction)
// ✕로 닫을 때까지 남는 알림(sticky — 16단계 주소록 가져오기 결과)은 성공 알림에도 ✕ 를 단다(브랜드 바탕용 색 — 디자인 원본 Contacts16 "4")
import { ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -150,10 +151,11 @@ watch(
{{ toast.action.label }}
</button>
<button
v-if="toast.kind === 'error'"
v-if="toast.kind === 'error' || toast.sticky"
type="button"
class="toast__close"
:class="['toast__close', { 'toast__close--on-brand': toast.kind !== 'error' }]"
aria-label="알림 닫기"
data-toast-close
@click="toastStore.dismiss(toast.id)"
>
<AppIcon
@@ -342,6 +344,20 @@ watch(
background: var(--color-surface-muted);
}
/* 브랜드 바탕(성공 알림) 위의 ✕ — 흐린 청록 글자, 마우스를 올리면 흰 글자 (디자인 원본 Contacts16 "4") */
.toast__close--on-brand {
color: var(--color-on-brand-muted);
}
.toast__close--on-brand:hover {
background: var(--color-on-brand-track);
color: var(--color-on-brand);
}
.toast__close--on-brand:focus-visible {
outline-color: var(--color-on-brand-link);
}
/* 인쇄하지 않는다 — 고정 위치라 남아 있으면 쪽마다 찍힌다 (9단계 인쇄 화면) */
@media print {
.toast-host {
@@ -9,6 +9,7 @@
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
// [임시 저장] 다음 자리(signature 슬롯)에 쓰기 화면이 [서명 ▾]를 둔다 (16단계 — ComposeSignatureMenu, 디자인 원본 Signature16 6)
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 까닭 #compose-send-why · 줄 .compose__footer
import { computed, nextTick, ref, watch } from 'vue'
@@ -191,6 +192,8 @@ watch(unavailable, (off) => {
>
임시 저장
</button>
<!-- [서명 ▾] (16단계) — 쓰기 화면이 넣는다 -->
<slot name="signature" />
<p
v-if="blockedReason"
:id="REASON_ID"
@@ -0,0 +1,237 @@
<script setup lang="ts">
// 쓰기 화면 [서명 ▾] (디자인 원본 Signature16 6 · README "16단계 — 여러 서명") — 아래 단추 줄의 [임시 저장] 다음.
// 단추는 [임시 저장]과 같은 글자 단추(44)에 ▾. 화면 낭독은 툴바의 글꼴 단추처럼 지금 본문에 든 서명까지 읽는다("서명: 대외" · "서명: 넣지 않음") —
// 서명 자리가 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) "서명"만.
// 메뉴는 편집기 메뉴(EditorMenu — 항목 40 · 폭 220): 서명(만든 차례 — 지금 본문에 든 서명에 ✓, "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리.
// 서명이 없으면 넣지 않음과 서명 관리만. 키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점, 마우스로 열면 메뉴 자체에,
// Esc 는 메뉴만 닫고 [서명 ▾]로 돌아간다. 아래 자리가 모자라면 위로 연다(usePopupPlacement — 아래 단추 줄에서는 보통 위).
// 고르면 select(서명 id · 넣지 않음은 null), 서명 관리는 manage — 본문의 서명 자리를 바꾸는 일과 낭독 글(announcement)은 쓰기 화면이 정한다.
// 고정 id · 이름 — 단추 #compose-signature · 메뉴 .compose-signature__menu · 낭독 [data-signature-status]
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
import type { ComposeSignatureState } from '@/shared/types/mail-signature'
interface SignatureChoice {
id: string
name: string
}
interface Props {
/** 내 서명 — 만든 차례 */
signatures: readonly SignatureChoice[]
/** 지금 본문에 든 서명 */
state: ComposeSignatureState
/** 누를 수 없다 — 편집기를 불러오는 중 · 보내는 중 등 */
disabled?: boolean
/** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 (role=status) */
announcement?: string
}
interface Emits {
/** 서명을 골랐다 — 넣지 않음은 null */
(e: 'select', id: string | null): void
/** 서명 관리 — 설정 › 메일 › 서명으로 */
(e: 'manage'): void
/** 메뉴를 열었다 — 서명을 아직 받지 못했으면 다시 받아 본다 */
(e: 'open'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
announcement: '',
})
const emit = defineEmits<Emits>()
/** 메뉴 항목 id — 서명 id(서버가 만든 값)와 겹치지 않게 앞에 붙인다 */
const SIGNATURE_ITEM_PREFIX = 'signature:'
const NONE_ITEM = 'none'
const MANAGE_ITEM = 'manage'
/** 키보드로 열면 첫 항목부터 — 켜진(✓) 항목이 있어도 */
const FIRST_ITEM = '[role^="menuitem"]'
const buttonRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const menuOpen = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 위로 연다 — 단추 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
const { upward } = usePopupPlacement({
open: menuOpen,
trigger: () => buttonRef.value,
popup: () => {
const menu: unknown = menuRef.value?.$el
return menu instanceof HTMLElement ? menu : null
},
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
/** 단추의 화면 낭독 이름 — "서명: 대외" · "서명: 넣지 않음" · 고친 서명이면 "서명" */
const buttonLabel = computed(() => {
const { state } = props
if (state.kind === 'none') return COMPOSE_SIGNATURE_TEXT.buttonLabel(COMPOSE_SIGNATURE_TEXT.none)
if (state.kind === 'signature') {
const current = props.signatures.find((signature) => signature.id === state.id)
if (current) return COMPOSE_SIGNATURE_TEXT.buttonLabel(current.name)
}
return COMPOSE_SIGNATURE_TEXT.button
})
const items = computed<EditorMenuItem[]>(() => {
const { state } = props
return [
...props.signatures.map((signature) => ({
id: `${SIGNATURE_ITEM_PREFIX}${signature.id}`,
role: 'menuitemradio' as const,
label: signature.name,
checked: state.kind === 'signature' && state.id === signature.id,
})),
{
id: NONE_ITEM,
role: 'menuitemradio' as const,
label: COMPOSE_SIGNATURE_TEXT.none,
checked: state.kind === 'none',
},
menuSeparator('separator'),
{ id: MANAGE_ITEM, label: COMPOSE_SIGNATURE_TEXT.manage },
]
})
function openMenu(keyboard: boolean) {
if (props.disabled) return
byKeyboard.value = keyboard
menuOpen.value = true
emit('open')
}
function onClick(event: MouseEvent) {
if (menuOpen.value) {
menuOpen.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || menuOpen.value) return
event.preventDefault()
openMenu(true)
}
function onMenuClose(restoreFocus: boolean) {
menuOpen.value = false
if (restoreFocus) buttonRef.value?.focus()
}
function onSelect(id: string) {
menuOpen.value = false
buttonRef.value?.focus()
if (id === MANAGE_ITEM) emit('manage')
else if (id === NONE_ITEM) emit('select', null)
else if (id.startsWith(SIGNATURE_ITEM_PREFIX)) emit('select', id.slice(SIGNATURE_ITEM_PREFIX.length))
}
// 보내는 중 · 양식을 새로 채움 등으로 꺼지면 메뉴도 닫는다
watch(
() => props.disabled,
(off) => {
if (off) menuOpen.value = false
},
)
</script>
<template>
<div class="compose-signature">
<button
id="compose-signature"
ref="buttonRef"
type="button"
class="compose-signature__button"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:aria-label="buttonLabel"
:disabled="disabled"
@click="onClick"
@keydown="onKeydown"
>
<span>{{ COMPOSE_SIGNATURE_TEXT.button }}</span>
<AppIcon
name="chevron-down"
:size="12"
:stroke-width="2.25"
/>
</button>
<EditorMenu
v-if="menuOpen"
ref="menuRef"
:class="['compose-signature__menu', { 'compose-signature__menu--up': upward }]"
:label="COMPOSE_SIGNATURE_TEXT.menuLabel"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
:initial-focus="FIRST_ITEM"
@select="onSelect"
@close="onMenuClose"
/>
<span
class="visually-hidden"
role="status"
data-signature-status
>{{ announcement }}</span>
</div>
</template>
<style scoped>
/* 메뉴가 이 단추 아래(모자라면 위)에서 열린다 */
.compose-signature {
position: relative;
display: inline-flex;
}
/* [임시 저장]과 같은 글자 단추 44 + ▾ (디자인 원본 Signature16 6) */
.compose-signature__button {
display: inline-flex;
align-items: center;
gap: var(--space-4);
min-height: var(--size-touch);
padding: 0 var(--space-10) 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.compose-signature__button:hover:not(:disabled),
.compose-signature__button[aria-expanded='true'] {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose-signature__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 메뉴 폭 220 — 긴 이름은 한 줄로 줄인다 */
.compose-signature__menu {
width: 13.75rem;
box-sizing: border-box;
}
.compose-signature__menu :deep(.menu__label) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
.compose-signature .compose-signature__menu--up {
top: auto;
bottom: calc(100% + var(--space-4));
}
</style>
@@ -19,6 +19,11 @@ interface Props {
align?: 'start' | 'end'
/** 마우스로 열었다 — 항목 대신 메뉴 자체에 초점 (EditorPopup) */
focusContent?: boolean
/**
* 키보드로 열 때 초점을 줄 항목 (선택자) — 주지 않으면 켜진 항목, 그다음 첫 항목 (EditorPopup).
* 메뉴 단추(쓰기 화면 [서명 ▾] — 16단계)는 켜진 항목이 있어도 첫 항목에서 시작한다
*/
initialFocus?: string
}
interface Emits {
@@ -30,6 +35,7 @@ interface Emits {
withDefaults(defineProps<Props>(), {
align: 'start',
focusContent: false,
initialFocus: '',
})
const emit = defineEmits<Emits>()
@@ -66,6 +72,7 @@ function select(item: EditorMenuItem, event: MouseEvent) {
:trigger="trigger"
:align="align"
:focus-content="focusContent"
:initial-focus="initialFocus"
@close="(restore) => emit('close', restore)"
>
<template
@@ -10,6 +10,8 @@
// 맞춘 내용으로 편집기를 만들므로 실행 취소 기록은 맞춘 내용에서 시작한다.
// 보내기 직전에 다시 재어(getContentForSend) 넘으면 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않는다.
// - 붙여 넣거나 끌어다 놓은 파일은 본문에 넣지 않고 files 로 넘긴다 — 쓰기 화면이 첨부로 올린다.
// - 서명 자리(16단계): 쓰기 화면의 [서명 ▾]가 replaceSignature 로 그 자리만 바꾸고, 어느 서명이 들었는지는
// signaturePlace · signatureFingerprint 로 견준다 (mail-signature-block.ts — 앱과 같은 경계라 저장본에서도 다시 찾는다).
// - 편집 영역(.compose-field)의 아래 선이 포커스 선이다 (styles/base.css). 첨부 영역의 위 선은 없다.
import {
Editor,
@@ -34,6 +36,13 @@ import type {
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToTextWithQuoteStart, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
findSignatureBlock,
replaceSignatureTransaction,
signatureFingerprintOf,
signatureNodeOf,
withSignatureBoundary,
} from '@/components/mail/editor/mail-signature-block'
import { hasSignatureImage } from '@/components/mail/editor/signature-image'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
@@ -150,7 +159,9 @@ const editor = markRaw(
* 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트. 원문 인용이 있으면 서식 본문에 인용 경계 표시를 두고
* 텍스트에서 인용이 시작하는 자리를 함께 준다 (11단계 — 대용량 첨부 덩이 자리)
*/
function contentOf(doc: ProseMirrorNode): MailEditorContent {
function contentOf(edited: ProseMirrorNode): MailEditorContent {
// 서명 자리 앞의 빈 줄을 지킨다 — 한글 조합 중에 빠진 채 저장 · 보내도 다시 열 때 서명 자리를 찾게 (mail-signature-block.ts)
const doc = withSignatureBoundary(edited)
const { text, quoteStart } = docToTextWithQuoteStart(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
@@ -235,6 +246,26 @@ function markSaved(savePoint: EditorSavePoint) {
opened = { doc, settled: doc }
}
/** 본문 안 서명 자리의 비교 값 — 없으면 null (16단계 [서명 ▾], mail-signature-block.ts) */
function signaturePlace(): string | null {
const found = findSignatureBlock(editor.state.doc)
return found ? signatureFingerprintOf(found.node) : null
}
/** 서명 HTML 의 비교 값 — 편집기 스키마로 읽은 모양으로 (읽을 것이 없으면 빈 글) */
function signatureFingerprint(html: string): string {
const node = signatureNodeOf(editor.schema, html)
return node ? signatureFingerprintOf(node) : ''
}
/** 서명 자리만 바꾼다 — 실행 취소로 되돌린다. 서명 글의 주소를 자동 링크로 바꾸지 않는다 (서명에 있는 그대로) */
function replaceSignature(html: string | null) {
if (!mounted) return
const node = html === null ? null : signatureNodeOf(editor.schema, html)
const tr = replaceSignatureTransaction(editor.state, node)
if (tr?.docChanged) editor.view.dispatch(tr.setMeta('preventAutolink', true))
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -273,7 +304,17 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus, isChanged, captureForSave, markSaved })
defineExpose({
getContent,
getContentForSend,
focus,
isChanged,
captureForSave,
markSaved,
signaturePlace,
signatureFingerprint,
replaceSignature,
})
</script>
<template>
@@ -59,6 +59,18 @@ export interface MailEditorHandle {
captureForSave: () => MailEditorSnapshot | null
/** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
markSaved: (savePoint: EditorSavePoint) => void
/**
* 본문 안 서명 자리의 비교 값 — 서명 자리가 없으면 null (16단계 [서명 ▾] — 어느 서명이 들었는지 signatureFingerprint 와 견준다).
* 글(공백을 하나로)과 그림으로 만든 값이라 서명 안을 고쳤으면 어느 서명과도 같지 않다
*/
signaturePlace: () => string | null
/** 서명 HTML 의 비교 값 — signaturePlace 와 같은 규칙(편집기 스키마로 읽은 모양) */
signatureFingerprint: (html: string) => string
/**
* 서명 자리만 바꾼다 — 쓴 글 · 인용은 그대로이고 실행 취소로 되돌린다. html 이 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다.
* 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다
*/
replaceSignature: (html: string | null) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -3,6 +3,7 @@
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// 서명 자리(16단계 — [서명 ▾] 가 바꾸는 자리)는 블록 노드 하나다 — 빈 줄 뒤의 속성 없는 div 로 나가고 들어온다 (mail-signature-block.ts).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -23,6 +24,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
import { MailSignatureBlock } from '@/components/mail/editor/mail-signature-block'
import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
@@ -266,6 +268,7 @@ export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {})
MailTableCell,
MailTableHeader,
SignatureImage,
MailSignatureBlock,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
@@ -0,0 +1,266 @@
import { Editor, createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { EditorState, TextSelection } from '@tiptap/pm/state'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
MAIL_SIGNATURE_NODE,
findSignatureBlock,
replaceSignatureTransaction,
signatureFingerprintOf,
signatureNodeOf,
withSignatureBoundary,
} from '@/components/mail/editor/mail-signature-block'
import { buildQuoteHtml } from '@/shared/utils/mail-quote'
import { newMailHtmlWithSignature, signatureBlockHtml } from '@/shared/utils/mail-signature'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
// 쓰기 본문의 서명 자리 (16단계 — 디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
const schema = getSchema(createMailEditorExtensions())
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
/** 서버가 정화해 준 서명 HTML 모양 — 글꼴 div 로 감쌌다 */
const OUTSIDE =
'<div style="font-family: \'Malgun Gothic\'; font-size: 14px; line-height: 1.75">' +
'<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<p style="margin: 0">ARA SYSTEM · <img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`
const INSIDE = '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>'
/** 보낸 본문의 감싸개 (toMailHtml · 앱 kMailBodyWrapperStyle) */
const WRAPPER_STYLE = "font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75"
function doc(html: string): ProseMirrorNode {
return createDocument(normalizeIncomingHtml(html), schema)
}
/** 맨 위 단계 블록의 모양 — 서명 자리는 [서명:글], 원문 인용은 [인용], 빈 문단은 '' */
function blocks(node: ProseMirrorNode): string[] {
const out: string[] = []
node.forEach((child) => {
if (child.type.name === MAIL_SIGNATURE_NODE) out.push(`[서명:${child.textContent}]`)
else if (child.type.name === 'blockquote') out.push('[인용]')
else out.push(child.textContent)
})
return out
}
/** 보내는 모양 — 쓰기 화면과 같은 길 (getHTML → toMailHtml) */
function mailHtml(node: ProseMirrorNode): string {
return toMailHtml(getHTMLFromFragment(node.content, schema))
}
/** 서버의 보내는 정화가 하는 일 중 경계에 닿는 것 — data-* 를 지운다 (sanitize-outgoing-html.ts) */
function sanitizeLikeServer(html: string): string {
return html.replace(/ data-[a-z-]+="[^"]*"/g, '')
}
function apply(node: ProseMirrorNode, signatureHtml: string | null): ProseMirrorNode {
const state = EditorState.create({ doc: node, schema })
const signature = signatureHtml === null ? null : signatureNodeOf(schema, signatureHtml)
const tr = replaceSignatureTransaction(state, signature)
return tr ? state.apply(tr).doc : node
}
describe('서명 자리 — 읽기', () => {
it('새 메일은 쓸 줄 · 빈 줄 하나 · 서명 자리 — 서명 안의 글꼴 div 는 풀고 문단만 둔다', () => {
const node = doc(newMailHtmlWithSignature(OUTSIDE))
expect(blocks(node)).toEqual(['', '', '[서명:김아라 · 인프라팀ARA SYSTEM · ]'])
expect(findSignatureBlock(node)?.index).toBe(2)
})
it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 자리 · 인용', () => {
const html = buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(INSIDE))
expect(blocks(doc(html))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
})
it('보낸(저장한) 본문은 빈 문단 뒤 속성 없는 div 로 나가고, 서버가 정화한 뒤 다시 열어도 그 자리를 찾는다', () => {
const node = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(INSIDE)))
const sent = mailHtml(node)
// 앱과 같은 경계 — 빈 문단(<br>) 바로 뒤의 속성 없는 div, 안의 문단은 margin: 0
expect(sent).toContain(
'<p style="margin: 0"><br></p><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p></div>',
)
const reopened = doc(sanitizeLikeServer(sent))
expect(blocks(reopened)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
const before = findSignatureBlock(node)
const after = findSignatureBlock(reopened)
expect(before && after && signatureFingerprintOf(after.node)).toBe(before && signatureFingerprintOf(before.node))
})
it('앱이 만든 본문(compose_body.dart)도 서명 자리로 읽는다 — 감싸개 · 빈 문단 · 서명 div · 원문 인용', () => {
const app =
`<div style="${WRAPPER_STYLE}">` +
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br></p>' +
'<div><p style="margin: 0">김아라 · 인프라팀</p></div>' +
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px"><p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>' +
'<table style="margin: 0 0 12px"><tbody><tr><td>제목</td><td>일정</td></tr></tbody></table><p style="margin: 0">원문</p></blockquote></div>'
expect(blocks(doc(app))).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', '[인용]'])
})
it.each([
['빈 문단 뒤가 아니다', '<p>글</p><div><p>서명 같은 글</p></div>'],
['속성이 있다', '<p>글</p><p></p><div style="color: #344054"><p>꾸민 div</p></div>'],
['글도 그림도 없다(인용 경계 표시가 남은 빈 div)', '<p>글</p><p></p><div></div><p>다음</p>'],
['원문 인용 안이다', '<p>글</p><blockquote><p>원본 메일</p><p></p><div><p>남의 서명</p></div></blockquote>'],
])('속성 없는 div 라도 서명 자리가 아니다 — %s', (_label, html) => {
expect(findSignatureBlock(doc(html))).toBeNull()
})
})
describe('서명 자리 — 바꾸기 (replaceSignatureTransaction)', () => {
const NEW_MAIL = newMailHtmlWithSignature(OUTSIDE)
it('다른 서명으로 — 그 자리만 바뀌고 쓴 글 · 인용은 그대로', () => {
const reply = buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(OUTSIDE))
const written = doc(reply.replace('<p></p>', '<p>보내 주신 견적서 잘 받았습니다.</p>'))
const changed = apply(written, INSIDE)
expect(blocks(changed)).toEqual(['보내 주신 견적서 잘 받았습니다.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
// 텍스트 대체 본문 — 내 글 · 빈 줄 · 서명 · 인용 줄 (서명 뒤에는 빈 줄이 없다 — 앱과 같다)
expect(docToText(changed)).toBe(
'보내 주신 견적서 잘 받았습니다.\n\n김아라 · 인프라팀\n내선 1234\n> 원본 메일\n> 제목: 일정\n>\n> 원문',
)
})
it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 뺀다', () => {
expect(blocks(apply(doc(NEW_MAIL.replace('<p></p>', '<p>안녕하세요.</p>')), null))).toEqual(['안녕하세요.'])
})
it('서명 자리가 없으면 처음 넣는 자리에 — 새 메일은 쓴 글 끝에 빈 줄 하나 뒤로 (있는 빈 줄을 쓴다)', () => {
expect(blocks(apply(doc('<p>안녕하세요.</p>'), INSIDE))).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]'])
expect(blocks(apply(doc('<p>안녕하세요.</p><p></p><p></p>'), INSIDE))).toEqual([
'안녕하세요.',
'',
'[서명:김아라 · 인프라팀내선 1234]',
'',
])
// 아무것도 쓰지 않았다 — 쓸 줄 · 빈 줄 · 서명 (처음 넣는 모양과 같다)
expect(blocks(apply(doc('<p></p>'), INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]'])
})
it('서명 자리가 없으면 처음 넣는 자리에 — 답장 · 전달은 인용 위', () => {
const reply = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>'))
expect(blocks(apply(reply, INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
const written = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>').replace('<p></p>', '<p>네.</p>'))
expect(blocks(apply(written, INSIDE))).toEqual(['네.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
})
it('서명 자리도 없고 넣지 않음이면 바꿀 것이 없다', () => {
const state = EditorState.create({ doc: doc('<p>글</p>'), schema })
expect(replaceSignatureTransaction(state, null)).toBeNull()
})
})
describe('어느 서명이 들었나 (signatureFingerprintOf)', () => {
it('같은 서명은 같은 값 — 서명 안을 고치면 달라진다, 공백 · nbsp 는 하나로 본다', () => {
const inserted = findSignatureBlock(doc(newMailHtmlWithSignature(INSIDE)))
const fresh = signatureNodeOf(schema, INSIDE)
expect(inserted && fresh && signatureFingerprintOf(inserted.node)).toBe(fresh && signatureFingerprintOf(fresh))
const spaced = signatureNodeOf(schema, '<p>김아라 \u00a0· 인프라팀</p>')
const plain = signatureNodeOf(schema, '<p>김아라 · 인프라팀</p>')
expect(spaced && signatureFingerprintOf(spaced)).toBe(plain && signatureFingerprintOf(plain))
const edited = signatureNodeOf(schema, INSIDE.replace('1234', '5678'))
expect(edited && signatureFingerprintOf(edited)).not.toBe(fresh && signatureFingerprintOf(fresh))
})
it('그림도 견준다 — 글이 같아도 그림이 다르면 다른 서명', () => {
const withLogo = signatureNodeOf(schema, OUTSIDE)
const withoutLogo = signatureNodeOf(schema, OUTSIDE.replace(/<img[^>]*>/, ''))
expect(withLogo && signatureFingerprintOf(withLogo)).not.toBe(withoutLogo && signatureFingerprintOf(withoutLogo))
})
})
describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-11-10 리뷰 M3)', () => {
const editors: Editor[] = []
beforeAll(installProseMirrorDom)
afterEach(() => {
for (const editor of editors.splice(0)) editor.destroy()
})
/** 쓰기 화면의 편집기와 같게 — 플러그인(서명 자리 지키기)은 화면에 붙일 때 켜진다 */
function mountedEditor(content: string): Editor {
const editor = new Editor({ element: null, extensions: createMailEditorExtensions(), content })
editors.push(editor)
editor.mount(document.createElement('div'))
return editor
}
/** 서명 앞 빈 줄 안에 커서 — 맨 위 단계 index 번째 문단의 처음 */
function cursorInto(editor: Editor, index: number) {
let pos = 0
for (let at = 0; at < index; at += 1) pos += editor.state.doc.child(at).nodeSize
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 1)))
}
/** 저장하고 다시 연 모양 — 보내는 모양(빈 줄을 지킨 문서 → toMailHtml)에서 서버 정화처럼 data-* 를 지우고 다시 읽는다 */
function reopened(editor: Editor): ProseMirrorNode {
const html = toMailHtml(getHTMLFromFragment(withSignatureBoundary(editor.state.doc).content, schema))
return doc(sanitizeLikeServer(html))
}
const WRITTEN = newMailHtmlWithSignature(INSIDE).replace('<p></p>', '<p>안녕하세요.</p>')
const SIGNED = '[서명:김아라 · 인프라팀내선 1234]'
it('서명 자리가 하나 더 들어오면(서명 안에서 복사해 붙여 넣기 등) 처음부터 있던 것만 두고 나머지는 문단으로 푼다', () => {
const editor = mountedEditor(newMailHtmlWithSignature(INSIDE).replace('<p></p>', '<p>첫 줄</p>'))
editor.commands.insertContentAt(0, signatureBlockHtml('<p>복사한 서명</p>'))
const signatures: string[] = []
editor.state.doc.forEach((child) => {
if (child.type.name === MAIL_SIGNATURE_NODE) signatures.push(child.textContent)
})
expect(signatures).toEqual(['김아라 · 인프라팀내선 1234'])
expect(editor.state.doc.textContent).toContain('복사한 서명')
})
it('서명 위 빈 줄에 글을 쓰면 그 아래 · 서명 위에 빈 줄을 다시 둔다 — 커서는 쓰던 자리, 실행 취소는 입력과 함께, 다시 열어도 서명 자리', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.commands.insertContent('감사합니다.')
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
expect(editor.state.selection.$from.parent.textContent).toBe('감사합니다.')
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
editor.commands.undo()
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 둔다 — 쓴 글은 그대로, 커서는 윗줄 끝, 다시 열어도 서명 자리', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.commands.keyboardShortcut('Backspace')
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
expect(editor.state.selection.$from.parent.textContent).toBe('안녕하세요.')
expect(editor.state.selection.$from.parentOffset).toBe('안녕하세요.'.length)
expect(blocks(reopened(editor)).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 조합이 끝난 다음 입력에서 편집기도 채운다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.view.dispatch(editor.state.tr.insertText('감').setMeta('composition', 1))
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '감', SIGNED])
expect(blocks(withSignatureBoundary(editor.state.doc)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
editor.commands.insertContent(' ')
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감 ', '', SIGNED])
})
it('빈 줄이 있으면 그대로 — 앱이 만든 저장본 모양도 바꾸지 않는다', () => {
const app = doc(
`<div style="${WRAPPER_STYLE}">` +
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br></p>' +
'<div><p style="margin: 0">김아라 · 인프라팀</p></div></div>',
)
expect(withSignatureBoundary(app)).toBe(app)
expect(mailHtml(app)).toContain('<p style="margin: 0"><br></p><div><p style="margin: 0">김아라 · 인프라팀</p></div>')
})
})
@@ -0,0 +1,269 @@
// 쓰기 본문의 서명 자리 — 16단계 [서명 ▾] (디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
// 서명 자리는 빈 줄 하나 뒤의 서명 덩어리다. 편집기 안에서는 블록 노드 하나(mailSignature — 안은 서명의 문단들)이고,
// 보낼 때는 속성 없는 <div> 로 나간다 — 앱과 같은 경계라 임시 저장본을 다시 열어도(웹 · 앱 모두) 그 자리를 다시 찾는다.
// - 읽기: 쓰기 화면이 처음 넣는 서명은 표시를 단 div(data-mail-signature — mail-signature.ts 의 signatureBlockHtml),
// 저장본 · 보내기 취소로 되돌린 메일은 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"(서버 정화가 data-* 를 지운다).
// 맨 위 단계 = 문서 바로 아래 · 또는 문서에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래. 원문 인용 안의 div 는 서명 자리가 아니다.
// - 서명 자리는 하나다 — 붙여 넣기 · 끌어 놓기로 하나 더 생기면 처음부터 있던 것만 두고 나머지는 문단으로 푼다(appendTransaction).
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미룬다),
// 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf). 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
// - 바꾸기(replaceSignatureTransaction): 그 자리만 새 서명으로 — 쓴 글 · 인용 · 첨부는 그대로이고 실행 취소로 되돌린다.
// 넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다.
// - 어느 서명이 들었는지(signatureFingerprintOf)는 글(공백을 하나로 줄여)과 그림 주소로 견준다 — 고쳤으면 어느 서명과도 같지 않다.
import { Node, createDocument } from '@tiptap/core'
import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
import { blockLines, normalizeIncomingHtml } from '@/components/mail/editor/mail-serialize'
import { SIGNATURE_IMAGE_NODE } from '@/components/mail/editor/signature-image'
import { MAIL_SIGNATURE_MARK_ATTRIBUTE } from '@/shared/constants/mail-signature'
import { signatureBlockHtml } from '@/shared/utils/mail-signature'
import { normalizeSignatureImageSrc } from '@/shared/utils/outgoing-html-rules'
export const MAIL_SIGNATURE_NODE = 'mailSignature'
/** 빈 줄로 보는 공백 — JS 의 \s 는 nbsp(U+00A0)도 포함한다 */
const BLANK = /^\s*$/
/** 글도 그림도 없는 문단인가 (DOM) — <p></p> · <p><br></p> · 공백뿐 */
function isEmptyParagraphElement(element: Element | null): boolean {
return (
element !== null &&
element.tagName === 'P' &&
BLANK.test(element.textContent ?? '') &&
element.querySelector('img') === null
)
}
/** 맨 위 단계인가 — 문서(body) 바로 아래, 또는 body 에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래 */
function isTopLevelElement(element: HTMLElement): boolean {
const parent = element.parentElement
if (!parent) return false
if (parent.tagName === 'BODY') return true
const grand = parent.parentElement
return (
parent.tagName === 'DIV' &&
!parent.hasAttribute(MAIL_SIGNATURE_MARK_ATTRIBUTE) &&
grand?.tagName === 'BODY' &&
grand.children.length === 1
)
}
/**
* 저장본의 서명 경계인가 — 맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다 (계약 2절 "앱이 만드는 모양").
* 인용 경계 표시(대용량 첨부)가 남은 빈 div 는 서명이 아니다
*/
export function isSignatureBoundaryElement(element: HTMLElement): boolean {
if (element.tagName !== 'DIV' || element.attributes.length > 0) return false
if (!isTopLevelElement(element)) return false
if (!isEmptyParagraphElement(element.previousElementSibling)) return false
return !BLANK.test(element.textContent ?? '') || element.querySelector('img') !== null
}
/** 글도 그림도 없는 문단인가 (편집기 문서) */
export function isEmptyParagraphNode(node: ProseMirrorNode): boolean {
if (node.type.name !== 'paragraph') return false
let empty = BLANK.test(node.textContent)
if (empty) {
node.descendants((child) => {
if (child.type.name === SIGNATURE_IMAGE_NODE) empty = false
return empty
})
}
return empty
}
/** 문서 맨 위 단계의 서명 자리 — 없으면 null */
export function findSignatureBlock(doc: ProseMirrorNode): { node: ProseMirrorNode; pos: number; index: number } | null {
let pos = 0
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (child.type.name === MAIL_SIGNATURE_NODE) return { node: child, pos, index }
pos += child.nodeSize
}
return null
}
/** 서명 비교 값 — 글(공백을 하나로)과 그림 주소. 같은 서명을 넣은 자리 · 저장본에서 되찾은 자리가 같은 값이 된다 */
export function signatureFingerprintOf(node: ProseMirrorNode): string {
const text = blockLines(node).join('\n').replace(/\s+/g, ' ').trim()
const images: string[] = []
node.descendants((child) => {
if (child.type.name === SIGNATURE_IMAGE_NODE) images.push(normalizeSignatureImageSrc(child.attrs.src) ?? '')
return true
})
return `${text}\n${images.join(' ')}`
}
/** 서명 HTML → 서명 자리 노드 (편집기 스키마로 읽는다 — 스키마에 없는 것은 빠진다). 읽을 것이 없으면 null */
export function signatureNodeOf(schema: Schema, html: string): ProseMirrorNode | null {
const doc = createDocument(signatureBlockHtml(normalizeIncomingHtml(html)), schema)
const first = doc.firstChild
return first?.type.name === MAIL_SIGNATURE_NODE ? first : null
}
/** 맨 위 단계의 첫 원문 인용 자리 — 없으면 null */
function mailQuoteIndex(doc: ProseMirrorNode): number | null {
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (child.type.name === 'blockquote' && child.attrs.mailQuote) return index
}
return null
}
/** 맨 위 단계 index 번째 블록이 시작하는 자리 */
function positionOfIndex(doc: ProseMirrorNode, index: number): number {
let pos = 0
for (let at = 0; at < index && at < doc.childCount; at += 1) pos += doc.child(at).nodeSize
return pos
}
/**
* 서명 자리가 없을 때 넣을 자리와 넣을 것 — 처음 넣는 자리와 같다: 새 메일은 본문 끝, 답장 · 전달은 원문 인용 위.
* 쓴 글의 마지막 블록 뒤에 빈 줄 하나 · 서명 (그 자리에 이미 빈 줄이 있으면 그것을 쓴다). 쓴 글이 없으면 쓸 줄 · 빈 줄 · 서명
*/
function insertionOf(doc: ProseMirrorNode): { index: number; blankLines: number } {
const limit = mailQuoteIndex(doc) ?? doc.childCount
let last = -1
for (let index = 0; index < limit; index += 1) {
if (!isEmptyParagraphNode(doc.child(index))) last = index
}
if (last < 0) {
// 쓴 글이 없다 — 쓸 줄과 빈 줄 하나를 둔다 (있는 빈 줄을 먼저 쓴다)
const present = Math.min(limit, 2)
return { index: present, blankLines: 2 - present }
}
const after = last + 1
const reuse = after < limit && isEmptyParagraphNode(doc.child(after))
return reuse ? { index: after + 1, blankLines: 0 } : { index: after, blankLines: 1 }
}
/**
* 서명 자리를 바꾸는 거래 — signature 가 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다(없으면 바꿀 것이 없다 — null).
* 서명 자리가 없으면 처음 넣는 자리에 넣는다
*/
export function replaceSignatureTransaction(state: EditorState, signature: ProseMirrorNode | null): Transaction | null {
const { doc, schema } = state
const found = findSignatureBlock(doc)
const tr = state.tr
if (found && signature) {
tr.replaceWith(found.pos, found.pos + found.node.nodeSize, signature)
return tr
}
if (found) {
const before = found.index > 0 ? doc.child(found.index - 1) : null
const start = before && isEmptyParagraphNode(before) ? found.pos - before.nodeSize : found.pos
tr.delete(start, found.pos + found.node.nodeSize)
return tr
}
const paragraph = schema.nodes.paragraph
if (!signature || !paragraph) return null
const { index, blankLines } = insertionOf(doc)
const blanks = Array.from({ length: blankLines }, () => paragraph.create())
tr.insert(positionOfIndex(doc, index), Fragment.fromArray([...blanks, signature]))
return tr
}
/** 서명 자리 바로 앞이 빈 줄이 아니면 그 서명 자리 — 그 앞에 빈 줄을 넣어야 경계가 선다. 서명 자리가 없거나 이미 빈 줄이면 null */
function blockMissingBlankLine(doc: ProseMirrorNode): { pos: number; index: number } | null {
const found = findSignatureBlock(doc)
if (!found) return null
const before = found.index > 0 ? doc.child(found.index - 1) : null
return before && isEmptyParagraphNode(before) ? null : found
}
/**
* 보내고 저장할 문서 — 서명 자리 바로 앞에 빈 줄이 없으면 하나 넣은 문서 (있으면 그대로).
* 편집기는 늘 빈 줄을 지키지만(아래 appendTransaction) 한글 조합 중에는 미루므로, 보내는 · 저장하는 모양에서 한 번 더 지킨다 —
* 빈 줄이 빠지면 다시 열 때 서명 자리를 찾지 못한다(계약 2절 "앱이 만드는 모양"의 경계). 쓴 글은 지우지 않는다
*/
export function withSignatureBoundary(doc: ProseMirrorNode): ProseMirrorNode {
const missing = blockMissingBlankLine(doc)
const paragraph = doc.type.schema.nodes.paragraph
if (!missing || !paragraph) return doc
const children: ProseMirrorNode[] = []
doc.forEach((child, _offset, index) => {
if (index === missing.index) children.push(paragraph.create())
children.push(child)
})
return doc.copy(Fragment.fromArray(children))
}
const signatureBlockKey = new PluginKey('mailSignatureBlock')
/**
* 서명 자리는 하나 — 붙여 넣기(서명 안에서 복사한 글은 서명 자리째 붙는다) 등으로 둘 이상이 되면 처음부터 있던 것만 두고 나머지는 문단으로 푼다.
* 처음부터 있던 것을 모르면(없었거나 지워졌다) 첫 것을 둔다
*/
function unwrapExtraSignatures(
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
tr: Transaction,
) {
const blocks: { node: ProseMirrorNode; pos: number }[] = []
newState.doc.forEach((child, offset) => {
if (child.type.name === MAIL_SIGNATURE_NODE) blocks.push({ node: child, pos: offset })
})
if (blocks.length < 2) return
const previous = findSignatureBlock(oldState.doc)
let kept = 0
if (previous) {
let mapped: number | null = previous.pos
for (const transaction of transactions) {
const result = transaction.mapping.mapResult(mapped)
mapped = result.deleted ? null : result.pos
if (mapped === null) break
}
const index = blocks.findIndex((block) => block.pos === mapped)
if (index >= 0) kept = index
}
// 뒤에서부터 풀어 앞 블록의 자리가 바뀌지 않게
for (let index = blocks.length - 1; index >= 0; index -= 1) {
if (index === kept) continue
const block = blocks[index]
if (block) tr.replaceWith(block.pos, block.pos + block.node.nodeSize, block.node.content)
}
}
/**
* 서명 자리 지키기 — 문서가 바뀐 거래 뒤에 (1) 서명 자리를 하나로 (2) 서명 자리 바로 앞에 빈 줄을 둔다.
* (2)는 그 빈 줄에 글을 쓰거나(Backspace · Delete 로) 빈 줄을 지웠을 때 빈 줄을 다시 넣는 것이다 — 쓴 글은 그대로 두고 그 아래 · 서명 위에 새 빈 줄,
* 커서는 쓰던 자리 그대로, 실행 취소는 그 입력과 한 번에 되돌린다(덧붙인 거래는 입력과 같은 기록 묶음).
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 다음 거래 · 보내고 저장하는 모양(withSignatureBoundary)이 채운다
*/
function guardSignatureBlock(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
const tr = newState.tr
unwrapExtraSignatures(transactions, oldState, newState, tr)
const composing = transactions.some((transaction) => Boolean(transaction.getMeta('composition')))
const paragraph = newState.schema.nodes.paragraph
const missing = composing ? null : blockMissingBlankLine(tr.doc)
if (missing && paragraph) tr.insert(missing.pos, paragraph.create())
return tr.docChanged ? tr : null
}
/** 서명 자리 노드 — 쓰기 본문 스키마(mail-extensions.ts)에 든다. 서명 칸(SignatureEditor) 스키마에는 없다 */
export const MailSignatureBlock = Node.create({
name: MAIL_SIGNATURE_NODE,
group: 'block',
content: 'block+',
parseHTML() {
return [
// 쓰기 화면이 처음 넣는 서명 — 표시로 안다 (속성 없는 div 규칙보다 먼저)
{ tag: `div[${MAIL_SIGNATURE_MARK_ATTRIBUTE}]`, priority: 60 },
// 저장본 · 앱이 만든 본문 — 빈 문단 뒤의 속성 없는 div
{ tag: 'div', getAttrs: (element: HTMLElement) => (isSignatureBoundaryElement(element) ? null : false) },
]
},
// 보낼 때 · 저장할 때 속성 없는 div — 앱과 같은 경계 (표시는 서버 정화가 어차피 지운다)
renderHTML() {
return ['div', 0]
},
addProseMirrorPlugins() {
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock })]
},
})
@@ -0,0 +1,155 @@
<script setup lang="ts">
// 메일에 넣을 서명 — 새 메일 · 답장과 전달 (디자인 원본 Signature16 1). 지금의 두 체크(새 메일 · 답장과 전달)를 대신한다.
// 고를 것은 서명(만든 차례) · 넣지 않음. 고르는 대로 바로 저장한다(mail-signature.store 의 chooseDefault — 한 번에 하나,
// 끝내 실패하면 서버가 확인한 값으로 되돌린다 · 오류 알림은 인터셉터). 저장이 끝나면 고른 칸 오른쪽에 "저장했습니다"(초록 ✓ · role=status),
// 다시 고르면 내린다. 서명이 하나도 없으면 이 칸을 감춘다(서명 화면이 그리지 않는다).
// 고정 id — 새 메일 #signature-default-new · 답장과 전달 #signature-default-reply · 안내 #signature-defaults-hint · 저장 글 [data-default-saved]
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_DEFAULTS_TEXT } from '@/shared/constants/mail-signature'
import type { SignatureUse } from '@/shared/types/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
const store = useMailSignatureStore()
/** "넣지 않음" 의 값 — select 는 글만 받는다 */
const NONE_VALUE = ''
const HINT_ID = 'signature-defaults-hint'
const FIELDS: readonly { use: SignatureUse; id: string; label: string }[] = [
{ use: 'new', id: 'signature-default-new', label: SIGNATURE_DEFAULTS_TEXT.newMessage },
{ use: 'reply', id: 'signature-default-reply', label: SIGNATURE_DEFAULTS_TEXT.reply },
]
/** "저장했습니다"를 보일 칸 — 마지막으로 고른 칸의 저장이 다 끝났고 성공했을 때 */
const savedField = ref<SignatureUse | null>(null)
let choiceSeq = 0
function valueOf(use: SignatureUse): string {
const id = use === 'new' ? store.defaults.newMessageId : store.defaults.replyId
return id ?? NONE_VALUE
}
async function choose(use: SignatureUse, event: Event) {
const value = (event.target as HTMLSelectElement).value
choiceSeq += 1
const mine = choiceSeq
savedField.value = null
const saved = await store.chooseDefault(use, value === NONE_VALUE ? null : value)
if (mine === choiceSeq && saved) savedField.value = use
}
</script>
<template>
<fieldset class="signature-defaults">
<legend class="signature-defaults__legend">
{{ SIGNATURE_DEFAULTS_TEXT.legend }}
</legend>
<div
v-for="field in FIELDS"
:key="field.use"
class="signature-defaults__row"
>
<label
:for="field.id"
class="signature-defaults__label"
>{{ field.label }}</label>
<select
:id="field.id"
class="form-input signature-defaults__select"
:value="valueOf(field.use)"
:aria-describedby="HINT_ID"
@change="(event) => choose(field.use, event)"
>
<option
v-for="signature in store.signatures"
:key="signature.id"
:value="signature.id"
>
{{ signature.name }}
</option>
<option :value="NONE_VALUE">
{{ SIGNATURE_DEFAULTS_TEXT.none }}
</option>
</select>
<span
class="signature-defaults__saved"
role="status"
:data-default-saved="field.use"
>
<template v-if="savedField === field.use">
<AppIcon
name="check"
:size="14"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_DEFAULTS_TEXT.saved }}</span>
</template>
</span>
</div>
<p
:id="HINT_ID"
class="signature-defaults__hint"
>
{{ SIGNATURE_DEFAULTS_TEXT.hint }}
</p>
</fieldset>
</template>
<style scoped>
.signature-defaults {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin: 0;
padding: 0;
border: 0;
}
.signature-defaults__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-defaults__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
}
/* 칸 이름 — 폭 84 (디자인 원본 Signature16) */
.signature-defaults__label {
flex: 0 0 5.25rem;
color: var(--color-text-strong);
}
/* 고르기 — 폭 240, 좁으면 줄어든다 */
.signature-defaults__select {
width: 15rem;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 0 var(--space-10);
font: inherit;
}
/* "저장했습니다" — 초록 12.5 + ✓ (보내기 · 읽기 설정과 같은 색) */
.signature-defaults__saved {
display: inline-flex;
align-items: center;
gap: var(--space-6);
font-size: var(--font-size-label);
color: var(--color-success-text);
white-space: nowrap;
}
.signature-defaults__hint {
margin: var(--space-2) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
// 서명 지우기 확인 (디자인 원본 Signature16 4, 공용 모양 ModalDialog — role=alertdialog, 메일함 지우기 창과 같은 폭 360)
// "‘대외’ 서명을 지울까요?" · 기본으로 넣던 곳마다 다른 글(새 메일 · 답장과 전달 · 둘 다 · 기본 아님) · [지우기](빨강 채움) [취소].
// 처음 초점은 [취소]이고 Esc 도 [취소]와 같다. 지우는 동안은 [지우기]를 누를 수 없다.
// 고정 id — #signature-delete-dialog · #signature-delete-title · #signature-delete-message · #signature-delete-confirm · #signature-delete-cancel
import { computed } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { SIGNATURE_DELETE_TEXT } from '@/shared/constants/mail-signature'
import { signatureDeleteMessage, type SignatureUses } from '@/shared/utils/mail-signature'
interface Props {
open: boolean
/** 지울 서명의 이름 */
name: string
/** 그 서명을 기본으로 넣는 메일 */
uses: SignatureUses
/** 지우는 중 */
busy?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
const title = computed(() => SIGNATURE_DELETE_TEXT.title(props.name))
const message = computed(() => signatureDeleteMessage(props.uses))
</script>
<template>
<ModalDialog
id="signature-delete-dialog"
:open="open"
role="alertdialog"
:title="title"
:message="message"
title-id="signature-delete-title"
message-id="signature-delete-message"
@cancel="emit('cancel')"
>
<template #actions>
<button
id="signature-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="busy"
:aria-busy="busy"
@click="emit('confirm')"
>
{{ SIGNATURE_DELETE_TEXT.confirm }}
</button>
<!-- 처음 초점 — 실수로 Enter 를 눌러도 지워지지 않게 -->
<button
id="signature-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ SIGNATURE_DELETE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,99 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { editorOf, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import SignatureEditor from './SignatureEditor.vue'
// 공용 편집기 — 서명 칸 · 부재중 자동 응답 내용 칸(16단계)이 함께 쓴다 (prop 은 phase16 shared-editor.md)
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
describe('SignatureEditor — 공용 편집기 (16단계)', () => {
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountEditor(props: Record<string, unknown> = {}) {
const wrapper = mount(SignatureEditor, {
attachTo: document.body,
props: { labelledBy: 'field-label', ...props },
})
mounted.push(wrapper)
return wrapper
}
const toolNames = (wrapper: VueWrapper) =>
wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
it('기본값은 서명 칸 그대로 — 편집 영역 #signature-body · 툴바 "서명 서식" · 그림 단추', () => {
const wrapper = mountEditor()
expect(wrapper.find('#signature-body').exists()).toBe(true)
expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
'aria-label': '서명 서식',
'aria-controls': 'signature-body',
})
expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크', '그림 넣기'])
})
it('부재중 내용 칸처럼 — id · 툴바 이름을 바꾸고 그림을 끄면 그림 단추 · 그림 노드가 없고 파일을 받지 않는다', async () => {
const wrapper = mountEditor({
bodyId: 'vacation-body',
toolbarLabel: '자동 응답 서식',
allowImages: false,
initialHtml: `<p>휴가 중입니다.<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p>`,
})
const body = wrapper.get('#vacation-body')
expect(body.attributes('aria-labelledby')).toBe('field-label')
expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
'aria-label': '자동 응답 서식',
'aria-controls': 'vacation-body',
})
expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크'])
// 저장한 글에 그림이 섞여 와도 스키마가 받지 않아 빠진다
expect(body.find('img').exists()).toBe(false)
body.element.dispatchEvent(pasteEvent({ files: [new File(['x'], '사진.png', { type: 'image/png' })] }))
expect(wrapper.emitted('files')).toBeUndefined()
})
it('getText — 서식을 뺀 글(글자 수를 셀 때), getContent — 쓰기 본문과 같은 서식 본문 · 글', async () => {
const wrapper = mountEditor({ allowImages: false, initialHtml: '<p><strong>안녕하세요</strong>, 김아라입니다.</p><p>복귀는 11월 23일입니다.</p>' })
const exposed = wrapper.vm as unknown as {
getText: () => string
getContent: () => { text: string; html: string | null }
}
expect(exposed.getText()).toBe('안녕하세요, 김아라입니다.\n복귀는 11월 23일입니다.')
const content = exposed.getContent()
expect(content.text).toBe(exposed.getText())
expect(content.html).toMatch(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75"><p style="margin: 0"><strong>/)
editorOf(wrapper.element).commands.clearContent(true)
expect(exposed.getText()).toBe('')
expect(exposed.getContent()).toEqual({ text: '', html: null, quoteStart: null })
})
it('invalid — 편집 영역 aria-invalid · 빨간 테두리, disabled — 읽기만 · 툴바 단추를 끈다(바꿈으로 알리지 않는다)', async () => {
const wrapper = mountEditor({ allowImages: false, initialHtml: '<p>내용</p>' })
const body = () => wrapper.get('#signature-body')
await wrapper.setProps({ invalid: true, describedBy: 'field-error' })
expect(body().attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'field-error' })
expect(wrapper.classes()).toContain('signature-editor--invalid')
await wrapper.setProps({ invalid: false, disabled: true })
expect(body().attributes('aria-invalid')).toBeUndefined()
expect(body().attributes('contenteditable')).toBe('false')
expect(body().attributes('aria-disabled')).toBe('true')
expect(wrapper.findAll('[role="toolbar"] button').every((button) => button.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.classes()).toContain('signature-editor--disabled')
await wrapper.setProps({ disabled: false })
expect(body().attributes('contenteditable')).toBe('true')
expect(wrapper.emitted('change')).toBeUndefined()
})
})
@@ -6,10 +6,14 @@
// - 내용이 바뀌면 change 로 문서를 넘긴다 — 미리 보기가 그 문서를 그린다
// - 바꿨는가(isChanged)는 처음 불러온 서명, 저장한 뒤에는 저장한 서명(markSaved)과 견준다 — 서명 화면의 나가기 확인
// TipTap 은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 서명 화면이 지연 로딩한다.
// 16단계 — 공용 편집기: 부재중 자동 응답의 내용 칸도 이것을 쓴다(쓰는 법은 prop 머리말). 기본값은 서명 칸 그대로다.
// - bodyId: 편집 영역 id(툴바의 aria-controls) · toolbarLabel: 툴바 이름 · allowImages: false 면 그림 단추 · 그림 노드가 없고
// 붙여 넣거나 끌어다 놓은 파일은 받지 않는다(무시) · invalid: aria-invalid + 빨간 테두리 · disabled: 고칠 수 없게(흐리게, 툴바 끔)
// - 편집 영역의 최소 높이는 CSS 변수 --signature-editor-min-height(기본 8.25rem = 132) — 쓰는 쪽 감싸개에서 바꾼다
import { Editor, getHTMLFromFragment } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import { computed, markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, markRaw, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { EditorSavePoint, MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
@@ -21,7 +25,12 @@ import {
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import SignatureToolbar from '@/components/mail/editor/signature/SignatureToolbar.vue'
import { SIGNATURE_TEXT, type SignatureImageSize } from '@/shared/constants/mail-signature'
import {
SIGNATURE_BODY_ID,
SIGNATURE_TEXT,
SIGNATURE_TOOLBAR_LABEL,
type SignatureImageSize,
} from '@/shared/constants/mail-signature'
import type { SignatureImage } from '@/shared/types/mail'
import { pastedFiles } from '@/shared/utils/editor-files'
import { imageSizeOf, imageWidthFor, insertedImageWidth } from '@/shared/utils/mail-signature'
@@ -34,19 +43,29 @@ interface Props {
labelledBy: string
/** 아래 안내 · 오류 id (aria-describedby) */
describedBy?: string | null
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림) */
images: ReadonlyMap<string, SignatureImage>
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림). 그림을 쓰지 않는 칸은 주지 않는다 */
images?: ReadonlyMap<string, SignatureImage>
/** 그림을 더 넣을 수 없다 (올리는 중) */
imageDisabled?: boolean
/** 파일을 끌고 있다 — 칸 위에 덮개를 보인다 */
dragging?: boolean
/** 편집 영역의 id — 툴바가 aria-controls 로 가리킨다. 한 화면에 칸이 둘이면 다르게 준다 (기본 "signature-body") */
bodyId?: string
/** 툴바의 화면 낭독 이름 (기본 "서명 서식") */
toolbarLabel?: string
/** 그림을 쓴다 — false 면 그림 단추가 없고, 붙여 넣은 그림은 빠지며, 붙여 넣거나 끌어다 놓은 파일은 받지 않는다 */
allowImages?: boolean
/** 잘못된 입력 — 편집 영역이 aria-invalid 이고 테두리가 빨갛다 (오류 글은 describedBy 로 잇는다) */
invalid?: boolean
/** 고칠 수 없다 — 편집 영역을 읽기만 하고(흐리게) 툴바 단추를 끈다 */
disabled?: boolean
}
interface Emits {
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 */
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 (allowImages 가 false 면 보내지 않는다) */
(e: 'files', files: File[]): void
/** 그림 단추 — 파일 고르기를 연다 */
(e: 'pick-image'): void
@@ -55,14 +74,20 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
initialHtml: '',
describedBy: null,
images: () => new Map<string, SignatureImage>(),
imageDisabled: false,
dragging: false,
bodyId: SIGNATURE_BODY_ID,
toolbarLabel: SIGNATURE_TOOLBAR_LABEL,
allowImages: true,
invalid: false,
disabled: false,
})
const emit = defineEmits<Emits>()
/** 화면에 하나뿐인 서명 칸 */
const SIGNATURE_BODY_ID = 'signature-body'
const IMAGE_SIZES: readonly SignatureImageSize[] = ['small', 'medium', 'original']
/** 그림 크기 줄의 이름표 id — 한 화면에 칸이 여럿이어도 겹치지 않게 */
const imageSizeLabelId = `${useId()}-image-size-label`
const bodyRef = ref<HTMLElement | null>(null)
const toolbarRef = ref<InstanceType<typeof SignatureToolbar> | null>(null)
@@ -74,36 +99,44 @@ let mounted = false
*/
let baseline: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
const extensions = createSignatureEditorExtensions({
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
})
const extensions = createSignatureEditorExtensions(
{
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
},
{ images: props.allowImages },
)
/**
* 붙여 넣거나 끌어다 놓은 파일 — 그림을 쓰는 칸이면 files 로 넘기고, 아니면 받지 않는다(무시 — 글이 함께 오면 글은 그대로 붙는다).
* 넘길 파일이 없으면 false (편집기가 글을 붙인다)
*/
function takeFiles(files: File[]): boolean {
if (files.length === 0) return false
if (props.allowImages) emit('files', files)
return true
}
const editor = markRaw(
new Editor({
element: null,
extensions,
editable: !props.disabled,
content: props.initialHtml ? normalizeIncomingHtml(props.initialHtml) : '',
editorProps: {
attributes: () => ({
id: SIGNATURE_BODY_ID,
id: props.bodyId,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': props.labelledBy,
...(props.describedBy ? { 'aria-describedby': props.describedBy } : {}),
...(props.invalid ? { 'aria-invalid': 'true' } : {}),
...(props.disabled ? { 'aria-disabled': 'true' } : {}),
}),
handlePaste: (_view, event) => {
const files = pastedFiles(event.clipboardData)
if (files.length === 0) return false
emit('files', files)
return true
},
handlePaste: (_view, event) => takeFiles(pastedFiles(event.clipboardData)),
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
emit('files', files)
return true
return takeFiles(Array.from(event.dataTransfer?.files ?? []))
},
transformPastedHTML: normalizeIncomingHtml,
},
@@ -180,6 +213,11 @@ function getContent(): MailEditorContent {
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)), quoteStart: null }
}
/** 보이는 글 — 서식을 뺀 텍스트(getContent 의 text 와 같다, 끝의 빈 줄은 뺀다). 글자 수를 셀 때 */
function getText(): string {
return docToText(editor.state.doc)
}
/** 서명이 쓰는 그림 id */
function imageIds(): Set<string> {
return signatureImageIdsOf(editor.state.doc)
@@ -224,23 +262,39 @@ onBeforeUnmount(() => {
// 오류 안내가 생기거나 사라지면 서명 칸의 aria 속성을 다시 그린다
watch(
() => props.describedBy,
() => [props.describedBy, props.invalid],
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
// 고칠 수 없게 되면 읽기만 — 바꿈(change)으로 알리지 않는다
watch(
() => props.disabled,
(disabled) => {
if (!editor.isDestroyed) editor.setEditable(!disabled, false)
},
)
defineExpose({ getContent, getText, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
</script>
<template>
<div :class="['signature-editor', { 'is-dragging': dragging }]">
<div
:class="[
'signature-editor',
{ 'is-dragging': dragging, 'signature-editor--invalid': invalid, 'signature-editor--disabled': disabled },
]"
>
<SignatureToolbar
ref="toolbarRef"
:editor="editor"
:revision="revision"
:controls="SIGNATURE_BODY_ID"
:controls="bodyId"
:label="toolbarLabel"
:allow-images="allowImages"
:image-disabled="imageDisabled"
:disabled="disabled"
@pick-image="emit('pick-image')"
/>
<div class="signature-editor__frame">
@@ -257,16 +311,16 @@ defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSaveP
</div>
</div>
<div
v-if="selectedImage"
v-if="selectedImage && allowImages"
class="signature-editor__sizes"
>
<span
id="signature-image-size-label"
:id="imageSizeLabelId"
class="signature-editor__sizes-label"
>{{ SIGNATURE_TEXT.imageSizeLabel }}</span>
<div
role="group"
aria-labelledby="signature-image-size-label"
:aria-labelledby="imageSizeLabelId"
class="segmented"
>
<button
@@ -310,12 +364,32 @@ defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSaveP
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
/* 잘못된 입력 — 빨간 테두리, 초점이 있으면 빨간 고리 (테두리 입력칸 .form-input 과 같은 색) */
.signature-editor--invalid,
.signature-editor--invalid:has(.ProseMirror:focus) {
border-color: var(--color-danger);
}
.signature-editor--invalid:has(.ProseMirror:focus) {
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
}
/* 고칠 수 없다 — 다른 꺼진 칸처럼 흐리게 */
.signature-editor--disabled {
opacity: 0.6;
}
.signature-editor--disabled .signature-editor__body :deep(.ProseMirror) {
cursor: not-allowed;
}
.signature-editor__frame {
position: relative;
}
/* 편집 영역 최소 높이 132 — 쓰는 쪽이 --signature-editor-min-height 로 바꿀 수 있다 (부재중 내용 칸 150) */
.signature-editor__body :deep(.ProseMirror) {
min-height: 8.25rem;
min-height: var(--signature-editor-min-height, 8.25rem);
padding: var(--space-14) var(--space-16);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
@@ -0,0 +1,94 @@
<script setup lang="ts">
// 서명이 없을 때 (디자인 원본 Signature16 2) — 점선 상자 안에 펜 아이콘 · "아직 서명이 없습니다" · 안내 · [새 서명](채운 단추).
// 메일에 넣을 서명 칸과 목록은 그리지 않는다(고를 서명이 없다 — 서명 화면이 정한다).
// 고정 id — [새 서명] #signature-empty-new
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
interface Emits {
(e: 'create'): void
}
const emit = defineEmits<Emits>()
</script>
<template>
<div class="signature-empty">
<AppIcon
name="edit"
:size="36"
:stroke-width="1.5"
class="signature-empty__icon"
/>
<p class="signature-empty__title">
{{ SIGNATURE_TEXT.emptyTitle }}
</p>
<p class="signature-empty__body">
{{ SIGNATURE_TEXT.emptyBody }}
</p>
<button
id="signature-empty-new"
type="button"
class="signature-empty__new"
@click="emit('create')"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ SIGNATURE_TEXT.newSignature }}</span>
</button>
</div>
</template>
<style scoped>
/* 점선 상자 — 위아래 40 · 좌우 24 (디자인 원본 Signature16 2) */
.signature-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
padding: var(--space-40) var(--space-24);
border: var(--border-width) dashed var(--color-border-strong);
border-radius: var(--radius-lg);
text-align: center;
word-break: keep-all;
overflow-wrap: anywhere;
}
.signature-empty__icon {
color: var(--color-icon-muted);
}
.signature-empty__title {
margin: var(--space-4) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.signature-empty__body {
max-width: 25rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.signature-empty__new {
margin-top: var(--space-6);
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.signature-empty__new:hover {
background: var(--color-brand-strong);
}
</style>
@@ -1,109 +1,129 @@
<script setup lang="ts">
// 메일 서명 설정 (디자인 원본 Settings · README "2단계 › 서명") — 설정 › 메일 › 서명 화면이 지연 로딩한다 (TipTap 이 이 청크에만 있다)
// 위에서부터 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → 서명을 넣을 메일(새 메일 · 답장 · 전달) → [저장] → 미리 보기.
// 서명 하나 고치기 (디자인 원본 Settings · Signature16 1 · 3 · README "2단계 › 서명" · "16단계 — 여러 서명")
// 이름 → 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → [저장] (+ [이 서명 지우기] — 새 서명은 [취소]) → 미리 보기.
// 고른 서명마다 새로 만든다(서명 화면 SignatureSettings 가 key 로) — 새 서명은 처음 [저장]에 만들고, 그 뒤로는 같은 칸에서 고친다.
// - 이름: 앞뒤 공백을 뺀 1~30자(칸이 30자에서 막는다), 같은 이름은 영문 대소문자를 가리지 않고 본다. [저장]할 때 걸리면 칸 아래 안내 대신 빨간 글과
// 이름 칸에 초점(알림 없음), 이름을 고치면 안내로 돌아간다. 서버가 거절하면(VAL_001) 목록을 조용히 다시 받아 그사이 다른 창에서 같은 이름을
// 만들었으면 같은 빨간 글, 아니면 서명 칸 아래에 사전 문구(본문 길이 · 그림 3장 등).
// - 그림: 그림 단추 · 붙여 넣기 · 끌어다 놓기(화면 어디든) → 사전 검사(PNG · JPG · GIF, 300KB, 3장) → POST /mail-signature/images →
// 커서 자리에 보통(144) 폭으로 넣는다. 걸리면(또는 서버가 형식 · 크기로 거절하면) 서명 칸 아래에 사전 문구 (FILE_001 · FILE_002 · VAL_001).
// 3장은 서명 칸에 있는 서로 다른 그림으로 센다 — 뺀 그림은 세지 않는다 (서명 저장의 한도, 계약 2절 "서명").
// 올려 두기만 한 그림까지 한 사람 10장은 서버가 올리기에서 막는다(VAL_001) — 그 알림은 인터셉터가 띄운다.
// - 저장: PUT /mail-signature { bodyHtml(쓰기 본문과 같은 인라인 서식), body(텍스트 서명), isForNew, isForReply } —
// 비었으면 둘 다 null. 길이 한도(서식 10,000 · 텍스트 2,000)는 보내기 전에 먼저 막는다(VAL_001).
// 저장하면 "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다." — 무엇이든 다시 바꾸면 내린다.
// - 나가기 확인: 처음 불러온(저장한 뒤에는 저장한) 서명과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창
// (useLeaveConfirm). 올리는 중인 그림도 바꿈이다. 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다.
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 서명 칸 #signature-body · 그림 고르기 #signature-image-input ·
// 새 메일 #signature-for-new · 답장 · 전달 #signature-for-reply · 저장 #signature-save · 상태 #signature-save-status ·
// 오류 #signature-error · 크기 단추 [data-size] · 미리 보기 단추 [data-mode] ·
// 나가기 확인 #signature-leave-dialog · #signature-leave-stay(계속 고치기) · #signature-leave-confirm(나가기)
// 3장은 서명 칸에 있는 서로 다른 그림으로 센다 — 뺀 그림은 세지 않는다. 한 사람 20장은 서버가 올리기에서 막는다(VAL_001 — 알림은 인터셉터).
// - 저장: 새 서명은 POST /mail-signatures, 있던 서명은 PUT /mail-signatures/{id} { name, bodyHtml, body } — 본문이 비었으면 둘 다 null.
// 길이 한도(서식 10,000 · 텍스트 2,000)는 보내기 전에 먼저 막는다(VAL_001). 저장한 뒤 글은 기본으로 넣는 서명이면
// "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 아니면 "저장했습니다." — 무엇이든 다시 바꾸면 내린다.
// - 바꿈(dirty)은 처음(저장한 뒤에는 저장한) 이름 · 서명과 다르거나 그림을 올리는 중 — 서명 화면이 다른 서명을 고르거나 떠날 때 묻는다.
// 고정 id — 화면 검증 · 테스트가 쓴다: 이름 #signature-name(안내 #signature-name-hint · 오류 #signature-name-error) · 서명 칸 #signature-body ·
// 그림 고르기 #signature-image-input · 저장 #signature-save · 상태 #signature-save-status · 오류 #signature-error ·
// 지우기 #signature-delete · 취소 #signature-cancel · 크기 단추 [data-size] · 미리 보기 단추 [data-mode]
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import SignatureEditor from '@/components/mail/editor/signature/SignatureEditor.vue'
import SignaturePreview from '@/components/mail/editor/signature/SignaturePreview.vue'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { useMailSignatures } from '@/composables/useMailSignatures'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGE_ACCEPT,
SIGNATURE_IMAGE_INLINE_CODES,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_SAVE_INLINE_CODES,
SIGNATURE_TEXT,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/shared/constants/mail-signature'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import type { SignatureImage } from '@/shared/types/mail'
import type { MailSignature } from '@/shared/types/mail-signature'
import { renameFile } from '@/shared/utils/editor-files'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { precheckSignatureImage, signatureImageUploadName } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import {
precheckSignatureImage,
signatureImageUploadName,
signatureNameProblem,
signatureUsesOf,
} from '@/shared/utils/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
interface Props {
/** 서버의 서명 — 이 값으로 칸을 채운다 */
signature: MailSignature
/** 고치는 서명 — 새 서명이면 null (처음 [저장]에 만든다) */
signature: MailSignature | null
/** 다른 서명의 이름 — 같은 이름 검사 */
otherNames: readonly string[]
}
interface Emits {
/** 저장했다 — 새 서명이면 만든 서명 */
(e: 'saved', signature: MailSignature): void
/** [이 서명 지우기] — 묻는 창은 서명 화면이 띄운다 */
(e: 'delete'): void
/** 새 서명의 [취소] */
(e: 'cancel'): void
/** 저장하지 않은 바꿈이 있는가 */
(e: 'dirty', dirty: boolean): void
/** 저장하는 중인가 */
(e: 'saving', saving: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { uploadSignatureImage } = useMail()
const authStore = useAuthStore()
const { uploadSignatureImage } = useMailSignatures()
const signatureStore = useMailSignatureStore()
const LABEL_ID = 'signature-label'
const HINT_ID = 'signature-hint'
const ERROR_ID = 'signature-error'
const NAME_HINT_ID = 'signature-name-hint'
const NAME_ERROR_ID = 'signature-name-error'
/** 처음 내용 — 서식 서명, 없으면 텍스트 서명을 문단으로 (다른 메일 프로그램이 텍스트만 남긴 경우) */
const initialHtml =
props.signature.bodyHtml ?? (props.signature.body ? textToParagraphHtml(props.signature.body) : '')
props.signature?.bodyHtml ?? (props.signature?.body ? textToParagraphHtml(props.signature.body) : '')
const editorRef = ref<InstanceType<typeof SignatureEditor> | null>(null)
const nameInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const forNew = ref<boolean>(props.signature.isForNew)
const forReply = ref<boolean>(props.signature.isForReply)
const name = ref<string>(props.signature?.name ?? '')
/** 처음(저장한 뒤에는 저장한) 이름 — 이것과 다르면 바꾼 것이다 */
const savedName = ref<string>(props.signature?.name ?? '')
const nameError = ref<'empty' | 'duplicate' | null>(null)
/** 미리 보기가 그릴 문서 — 편집기가 바뀔 때마다 */
const previewDoc = shallowRef<ProseMirrorNode | null>(null)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
/** 저장한 뒤 글 — 저장한 때 그 서명이 기본으로 들어가는지로 고른다 */
const savedMessage = ref<string>('')
/** 서명 칸 아래 오류 — 사전 문구 */
const errorMessage = ref<string | null>(null)
/** 올리는 중인 그림 수 */
const uploading = ref<number>(0)
/** 알고 있는 그림 — 원본 폭을 찾는다 */
const images = shallowRef<ReadonlyMap<string, SignatureImage>>(
new Map(props.signature.images.map((image) => [image.id, image])),
new Map((props.signature?.images ?? []).map((image) => [image.id, image])),
)
/** 불러온(저장한 뒤에는 저장한) 뒤 서명 칸을 바꿨다 — 편집기가 잰다 */
/** 처음(저장한 뒤에는 저장한) 뒤 서명 칸을 바꿨다 — 편집기가 잰다 */
const editorChanged = ref<boolean>(false)
/** 불러온(저장한) 때의 "넣을 메일" — 이것과 다르면 바꾼 것이다 */
const savedOptions = shallowRef({ forNew: props.signature.isForNew, forReply: props.signature.isForReply })
const describedBy = computed(() => (errorMessage.value ? `${HINT_ID} ${ERROR_ID}` : HINT_ID))
/** 떠나면 잃는 것이 있다 — 저장하지 않은 서명 칸 · 넣을 메일, 올리는 중인 그림 */
const hasUnsavedChanges = computed(
() =>
editorChanged.value ||
uploading.value > 0 ||
forNew.value !== savedOptions.value.forNew ||
forReply.value !== savedOptions.value.forReply,
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp,
const nameErrorText = computed(() => {
if (nameError.value === 'empty') return SIGNATURE_TEXT.nameEmpty
return nameError.value === 'duplicate' ? SIGNATURE_TEXT.nameDuplicate : null
})
/** 떠나거나 다른 서명을 고르면 잃는 것이 있다 — 저장하지 않은 이름 · 서명 칸, 올리는 중인 그림 */
const hasUnsavedChanges = computed(
() => editorChanged.value || uploading.value > 0 || name.value !== savedName.value,
)
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
function markChanged() {
if (saveState.value === 'saved') saveState.value = 'idle'
}
function onNameInput() {
nameError.value = null
markChanged()
}
function onEditorReady(doc: ProseMirrorNode) {
previewDoc.value = doc
}
@@ -138,7 +158,7 @@ async function addImage(file: File) {
markChanged()
} catch (error) {
const apiError = toApiError(error)
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 10장 초과 VAL_001 등)은 인터셉터가 알렸다
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 20장 초과 VAL_001 등)은 인터셉터가 알렸다
if (SIGNATURE_IMAGE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
} finally {
uploading.value -= 1
@@ -150,7 +170,7 @@ async function addImages(files: File[]) {
for (const file of files) await addImage(file)
}
// 화면 어디에 그림을 놓아도 받는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
// 화면 어디에 그림을 놓아도 지금 고른 서명에 넣는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
const { dragging } = useWindowFileDrop((files) => void addImages(files))
function pickImage() {
@@ -165,9 +185,34 @@ function onFilesChosen(event: Event) {
void addImages(files)
}
function focusName() {
nameInputRef.value?.focus()
}
/** 이름이 걸렸다 — 칸 아래 빨간 글 · 이름 칸에 초점 */
function showNameProblem(problem: 'empty' | 'duplicate') {
nameError.value = problem
void nextTick(focusName)
}
/** 서버가 저장을 거절했다(VAL_001) — 목록을 다시 받아 그사이 같은 이름이 생겼으면 이름 칸, 아니면 서명 칸 아래에 사전 문구 */
async function explainRejection(message: string, trimmedName: string) {
await signatureStore.refresh()
const others = signatureStore.signatures
.filter((signature) => signature.id !== props.signature?.id)
.map((signature) => signature.name)
if (signatureNameProblem(trimmedName, others) === 'duplicate') showNameProblem('duplicate')
else errorMessage.value = message
}
async function save() {
const editor = editorRef.value
if (!editor || saveState.value === 'saving') return
const problem = signatureNameProblem(name.value, props.otherNames)
if (problem) {
showNameProblem(problem)
return
}
const content = editor.getContent()
// 길이 한도 — 서버도 같은 한도로 VAL_001 을 준다 (서식은 정화한 뒤 재지만 정화는 거의 줄이지 않는다)
if ((content.html?.length ?? 0) > SIGNATURE_HTML_MAX_LENGTH || content.text.length > SIGNATURE_TEXT_MAX_LENGTH) {
@@ -179,34 +224,82 @@ async function save() {
saveState.value = 'saving'
// 저장할 때의 모양 — 저장이 끝나면 이것을 "바꾸지 않은" 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다)
const savePoint = editor.captureSavePoint()
const options = { forNew: forNew.value, forReply: forReply.value }
const trimmedName = name.value.trim()
const typedName = name.value
const payload = { name: trimmedName, bodyHtml: content.html, body: content.text === '' ? null : content.text }
const options = { silent: SIGNATURE_SAVE_INLINE_CODES }
try {
const saved = await signatureStore.save(
{
bodyHtml: content.html,
body: content.text === '' ? null : content.text,
isForNew: options.forNew,
isForReply: options.forReply,
},
{ silent: SIGNATURE_SAVE_INLINE_CODES },
)
const current = props.signature
const saved = current
? await signatureStore.update(current.id, payload, options)
: await signatureStore.create(payload, options)
images.value = new Map([...images.value, ...saved.images.map((image) => [image.id, image] as const)])
editorRef.value?.markSaved(savePoint)
editorChanged.value = editorRef.value?.isChanged() ?? false
savedOptions.value = options
savedName.value = saved.name
// 저장하는 동안 이름을 더 고치지 않았으면 저장한 이름(앞뒤 공백을 뺀)으로 맞춘다
if (name.value === typedName) name.value = saved.name
savedMessage.value =
signatureUsesOf(saved.id, signatureStore.defaults) !== null ? SIGNATURE_TEXT.saved : SIGNATURE_TEXT.savedPlain
saveState.value = 'saved'
emit('saved', saved)
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) await explainRejection(apiError.message, trimmedName)
}
}
watch([forNew, forReply], markChanged)
watch(hasUnsavedChanges, (dirty) => emit('dirty', dirty), { immediate: true })
watch(
() => saveState.value === 'saving',
(saving) => emit('saving', saving),
)
defineExpose({ focusName })
</script>
<template>
<div class="signature-form">
<div class="signature-form__name">
<label
for="signature-name"
class="signature-form__label"
>{{ SIGNATURE_TEXT.nameLabel }}</label>
<input
id="signature-name"
ref="nameInputRef"
v-model="name"
type="text"
class="form-input signature-form__name-input"
autocomplete="off"
:maxlength="SIGNATURE_NAME_MAX_LENGTH"
:placeholder="SIGNATURE_TEXT.namePlaceholder"
:aria-invalid="nameError ? 'true' : undefined"
:aria-describedby="nameError ? NAME_ERROR_ID : NAME_HINT_ID"
@input="onNameInput"
>
<p
v-if="nameErrorText"
:id="NAME_ERROR_ID"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ nameErrorText }}</span>
</p>
<p
v-else
:id="NAME_HINT_ID"
class="form-message"
>
{{ SIGNATURE_TEXT.nameHint }}
</p>
</div>
<div class="signature-form__field">
<p
:id="LABEL_ID"
@@ -270,31 +363,6 @@ watch([forNew, forReply], markChanged)
</p>
</div>
<fieldset class="signature-form__usage">
<legend class="signature-form__legend">
{{ SIGNATURE_TEXT.usageLegend }}
</legend>
<label class="signature-form__option">
<input
id="signature-for-new"
v-model="forNew"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forNew }}</span>
</label>
<label class="signature-form__option">
<input
id="signature-for-reply"
v-model="forReply"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forReply }}</span>
<span class="signature-form__option-note">{{ SIGNATURE_TEXT.forReplyNote }}</span>
</label>
</fieldset>
<div class="signature-form__actions">
<button
id="signature-save"
@@ -317,27 +385,38 @@ watch([forNew, forReply], markChanged)
:size="15"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_TEXT.saved }}</span>
<span>{{ savedMessage }}</span>
</template>
</span>
<button
v-if="signature"
id="signature-delete"
type="button"
class="signature-form__delete"
:disabled="saveState === 'saving'"
@click="emit('delete')"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ SIGNATURE_TEXT.delete }}</span>
</button>
<button
v-else
id="signature-cancel"
type="button"
class="signature-form__cancel"
:disabled="saveState === 'saving'"
@click="emit('cancel')"
>
{{ SIGNATURE_TEXT.cancel }}
</button>
</div>
<hr class="signature-form__rule">
<SignaturePreview
:doc="previewDoc"
:for-new="forNew"
:for-reply="forReply"
/>
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="signature-leave"
:copy="SIGNATURE_LEAVE_DIALOG"
@stay="stayOnForm"
@leave="leaveForm"
/>
<SignaturePreview :doc="previewDoc" />
</div>
</template>
@@ -348,6 +427,20 @@ watch([forNew, forReply], markChanged)
gap: var(--space-22);
}
.signature-form__name {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 이름 칸 — 폭 320, 칸이 좁으면 칸 폭 (디자인 원본 Signature16) */
.signature-form__name-input {
width: 20rem;
max-width: 100%;
box-sizing: border-box;
font: inherit;
}
.signature-form__field {
display: flex;
flex-direction: column;
@@ -357,6 +450,7 @@ watch([forNew, forReply], markChanged)
.signature-form__label {
margin: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-form__alert {
@@ -383,46 +477,11 @@ watch([forNew, forReply], markChanged)
color: var(--color-text-muted);
}
.signature-form__usage {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.signature-form__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-form__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.signature-form__checkbox {
width: 1.125rem;
height: 1.125rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.signature-form__option-note {
color: var(--color-text-muted);
}
.signature-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
gap: var(--space-8) var(--space-12);
}
.signature-form__save {
@@ -442,7 +501,9 @@ watch([forNew, forReply], markChanged)
background: var(--color-brand-strong);
}
.signature-form__save:disabled {
.signature-form__save:disabled,
.signature-form__delete:disabled,
.signature-form__cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -455,6 +516,42 @@ watch([forNew, forReply], markChanged)
font-weight: 500;
}
/* [이 서명 지우기] — [저장]과 떨어뜨려 오른쪽 끝 (디자인 원본 Signature16 1) */
.signature-form__delete {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-danger);
font-weight: 600;
}
.signature-form__delete:hover:not(:disabled) {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
/* 새 서명의 [취소] — 보조 단추 (디자인 원본 Signature16 3) */
.signature-form__cancel {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.signature-form__cancel:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.signature-form__rule {
width: 100%;
height: var(--border-width);
@@ -0,0 +1,214 @@
<script setup lang="ts">
// 내 서명 목록 (디자인 원본 Signature16 1 · 3) — 만든 차례, 줄 높이 52.
// 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕에 굵은 글자(aria-current). 이름 아래 작은 회색 글은 그 서명을 기본으로 넣는 메일(뱃지 없음),
// 아직 저장하지 않은 새 줄은 "저장하지 않음". 아래에 [새 서명](글자 단추) · 개수 글 — 10개면 [새 서명]은 aria-disabled(초점은 받는다)이고
// 개수 글이 그 까닭으로 바뀐다(필터의 50개와 같은 꼴). 칸 폭이 640 보다 좁으면 목록이 위로 올라가 칸 폭을 쓴다(컨테이너 쿼리 — SignatureSettings).
// 고정 id · 표시 — 목록 이름 #signature-list-label · 줄 [data-signature-row="<id | new>"] · [새 서명] #signature-new · 개수 글 #signature-count
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
export interface SignatureListRow {
/** 서명 id — 아직 저장하지 않은 새 줄은 'new' */
key: string
name: string
/** 이름 아래 회색 글 — 없으면 빈 글 */
sub: string
current: boolean
}
interface Props {
rows: readonly SignatureListRow[]
/** 저장한 서명 수 */
count: number
/** 더 만들 수 없다 (10개) */
full: boolean
}
interface Emits {
(e: 'pick', key: string): void
(e: 'create'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const COUNT_ID = 'signature-count'
function onCreate() {
if (props.full) return
emit('create')
}
</script>
<template>
<div class="signature-list">
<p
id="signature-list-label"
class="signature-list__label"
>
{{ SIGNATURE_TEXT.listLabel }}
</p>
<ul
class="signature-list__rows"
aria-labelledby="signature-list-label"
>
<li
v-for="row in rows"
:key="row.key"
class="signature-list__item"
>
<button
type="button"
:class="['signature-list__row', { 'signature-list__row--current': row.current }]"
:aria-current="row.current ? 'true' : undefined"
:data-signature-row="row.key"
@click="emit('pick', row.key)"
>
<span class="signature-list__name">{{ row.name }}</span>
<span
v-if="row.sub"
class="signature-list__sub"
>{{ row.sub }}</span>
</button>
</li>
</ul>
<button
id="signature-new"
type="button"
class="signature-list__new"
:aria-disabled="full ? 'true' : undefined"
:aria-describedby="full ? COUNT_ID : undefined"
@click="onCreate"
>
<AppIcon
name="plus"
:size="16"
/>
<span>{{ SIGNATURE_TEXT.newSignature }}</span>
</button>
<p
:id="COUNT_ID"
class="signature-list__count"
>
{{ full ? SIGNATURE_TEXT.full : SIGNATURE_TEXT.count(count) }}
</p>
</div>
</template>
<style scoped>
/* 목록 — 폭 208 (디자인 원본 Signature16 1). 칸이 640 보다 좁으면 위로 올라가 칸 폭을 쓴다 */
.signature-list {
flex: 0 0 13rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
@container signature-settings (max-width: 40rem) {
.signature-list {
flex-basis: auto;
width: 100%;
}
}
.signature-list__label {
margin: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-list__rows {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.signature-list__item + .signature-list__item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 줄 — 높이 52 (--size-header-min 재사용 — 밀도 예외 아님) */
.signature-list__row {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
width: 100%;
min-height: var(--size-header-min);
box-sizing: border-box;
padding: var(--space-6) var(--space-12);
border: 0;
background: var(--color-surface);
color: var(--color-text);
text-align: left;
}
.signature-list__row:hover {
background: var(--color-surface-panel);
}
.signature-list__row--current,
.signature-list__row--current:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.signature-list__name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.signature-list__row--current .signature-list__name {
font-weight: 600;
}
.signature-list__sub {
font-size: var(--font-size-small);
line-height: 1.4;
color: var(--color-text-muted);
}
.signature-list__row--current .signature-list__sub {
color: var(--color-brand-strong);
}
/* [새 서명] — 글자 단추 44 (아이콘이 목록 왼쪽 선에 오게 왼쪽 -2) */
.signature-list__new {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
margin-left: calc(var(--space-2) * -1);
padding: 0 var(--space-12) 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.signature-list__new:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 10개 — 초점은 받고 누를 수 없다. 마우스를 올려도 그대로 */
.signature-list__new[aria-disabled='true'] {
color: var(--color-icon-muted);
cursor: not-allowed;
}
.signature-list__count {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 서명 미리 보기 (디자인 원본 Settings) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// 서명 미리 보기 (디자인 원본 Settings · Signature16) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// - 새 메일: (여기에 쓴 내용) · 빈 줄 · 서명 / 답장: 같은 위에 원문 인용(자리만 — 실제 메일이 아니다)
// - 서명은 편집 중인 문서를 편집기 스키마로 그린다 (DOMSerializer — 스키마가 그리는 태그 · 속성뿐이라 남의 HTML 을 넣지 않는다).
// 그 쓰기 종류에 넣지 않기로 했으면 서명을 빼고 아래 안내가 바뀐다.
// - 16단계: 서명이 여럿이라 고른 서명이 그 메일에 들어갈 자리를 늘 보인다 (기본으로 넣을지는 위의 "메일에 넣을 서명"이 정한다)
import { DOMSerializer, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, watch } from 'vue'
@@ -18,10 +18,6 @@ export type SignaturePreviewMode = 'new' | 'reply'
interface Props {
/** 편집 중인 서명 문서 (없으면 아직 불러오는 중) */
doc: ProseMirrorNode | null
/** 새 메일에 넣는다 */
forNew: boolean
/** 답장 · 전달에 넣는다 */
forReply: boolean
}
const props = defineProps<Props>()
@@ -34,12 +30,7 @@ const MODES: readonly { mode: SignaturePreviewMode; label: string }[] = [
{ mode: 'reply', label: SIGNATURE_TEXT.previewReply },
]
/** 이 쓰기 종류에 서명을 넣는다 */
const shown = computed(() => (mode.value === 'reply' ? props.forReply : props.forNew))
const note = computed(() => {
if (mode.value === 'reply') return props.forReply ? SIGNATURE_PREVIEW_NOTES.replyOn : SIGNATURE_PREVIEW_NOTES.replyOff
return props.forNew ? SIGNATURE_PREVIEW_NOTES.newOn : SIGNATURE_PREVIEW_NOTES.newOff
})
const note = computed(() => SIGNATURE_PREVIEW_NOTES[mode.value])
/** 서명 문서를 그린다 — 스키마의 toDOM 결과만 붙인다 */
function render() {
@@ -92,7 +83,6 @@ watch([() => props.doc, signatureRef], render, { flush: 'post', immediate: true
</p>
<p><br></p>
<div
v-show="shown"
ref="signatureRef"
class="signature-preview__signature"
data-signature-preview
@@ -0,0 +1,328 @@
<script setup lang="ts">
// 설정 › 메일 › 서명 — 여러 서명 (디자인 원본 Signature16 · README "16단계 — 여러 서명" · 계약 2절 "서명").
// 서명 화면(MailSignaturePage)이 받은 뒤 지연 로딩한다 — TipTap 이 이 청크에만 있다.
// 위에서부터 "메일에 넣을 서명"(기본 둘 — 서명이 있을 때만) → 왼쪽 내 서명 목록 · 오른쪽 고른 서명 고치기(SignatureForm).
// 칸 폭이 640 보다 좁으면 목록을 위로 올리고 고치는 칸을 그 아래에 둔다 — 창 폭이 아니라 이 칸의 폭을 본다(컨테이너 쿼리).
// - 처음 고른 서명은 새 메일에 넣는 서명, 넣지 않음이면 첫 서명. 서명이 없으면 안내와 [새 서명]만(SignatureEmpty).
// - [새 서명]: 목록 끝에 "저장하지 않음" 줄이 생기고 빈 이름 칸(초점) · 서명 칸이 열린다. 처음 [저장]에 만든다 — 서명이 하나도 없던 직원이면
// 두 곳의 기본이 그 서명이 된다(서버). [취소]는 새 줄을 빼고 전에 고른 서명을 연다. 10개면 [새 서명]을 누를 수 없다.
// - 저장하지 않은 것이 있으면 다른 서명을 고르거나 [새 서명] · [취소]를 누를 때 나가기 확인과 같은 창으로 묻는다
// (처음 초점 [계속 고치기] · [바꾼 내용 버리기]). 다른 화면으로 갈 때는 지금 서명 화면의 나가기 확인 그대로(useLeaveConfirm) —
// 저장하는 중이거나 세션이 끝났을 때(제한된 세션 포함)는 묻지 않는다. 메일에 넣을 서명 두 칸은 고를 때 바로 저장되므로 묻는 까닭이 아니다.
// - [이 서명 지우기]: 묻고(SignatureDeleteDialog — 기본이던 곳마다 다른 글) 지운 뒤 "‘영문’ 서명을 지웠습니다." 알림과 목록의 첫 서명을 연다.
// 마지막 서명을 지우면 서명이 없을 때의 화면이 된다. 지운 서명을 기본으로 넣던 곳은 넣지 않음이 된다(다른 서명이 저절로 기본이 되지 않는다).
// 고정 id — 다른 서명 확인 #signature-switch-dialog(· -stay · -confirm) · 나가기 확인 #signature-leave-dialog(· -stay · -confirm)
import { computed, nextTick, ref, watch } from 'vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import SignatureDefaultsField from '@/components/mail/editor/signature/SignatureDefaultsField.vue'
import SignatureDeleteDialog from '@/components/mail/editor/signature/SignatureDeleteDialog.vue'
import SignatureEmpty from '@/components/mail/editor/signature/SignatureEmpty.vue'
import SignatureForm from '@/components/mail/editor/signature/SignatureForm.vue'
import SignatureList, { type SignatureListRow } from '@/components/mail/editor/signature/SignatureList.vue'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useToast } from '@/composables/useToast'
import {
MAIL_SIGNATURE_MAX_COUNT,
SIGNATURE_DELETE_TEXT,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_SWITCH_DIALOG,
SIGNATURE_TEXT,
} from '@/shared/constants/mail-signature'
import type { MailSignature } from '@/shared/types/mail-signature'
import { signatureUsesOf, signatureUsesText } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
/** 목록의 아직 저장하지 않은 새 줄 */
const NEW_KEY = 'new'
type Selection = { kind: 'saved'; id: string } | { kind: 'new' }
/** 묻는 창의 답을 기다리는 일 — 다른 서명 고르기 · 새 서명 · 새 서명 [취소] */
type PendingSwitch = { kind: 'pick'; id: string } | { kind: 'create' } | { kind: 'cancel' }
const store = useMailSignatureStore()
const authStore = useAuthStore()
const toast = useToast()
const selection = ref<Selection | null>(initialSelection())
/** 고치는 칸을 새로 만든다 — 다른 서명을 열 때마다 (새 서명을 처음 저장한 것은 같은 칸이다) */
const formKey = ref<number>(0)
const formRef = ref<InstanceType<typeof SignatureForm> | null>(null)
const rootRef = ref<HTMLElement | null>(null)
/** 고치는 칸에 저장하지 않은 것이 있다 · 저장하는 중 (SignatureForm 이 알린다) */
const formDirty = ref<boolean>(false)
const formSaving = ref<boolean>(false)
const pendingSwitch = ref<PendingSwitch | null>(null)
/** [새 서명]을 열기 전에 고르던 서명 — [취소]가 돌아간다 */
let beforeNew: string | null = null
/** 지울 서명 — 묻는 창이 열린 동안 */
const deleteTarget = ref<MailSignature | null>(null)
const deleting = ref<boolean>(false)
const selectedId = computed(() => (selection.value?.kind === 'saved' ? selection.value.id : null))
const selectedSignature = computed(() => store.signatureById(selectedId.value))
const isFull = computed(() => store.signatures.length >= MAIL_SIGNATURE_MAX_COUNT)
const otherNames = computed(() =>
store.signatures.filter((signature) => signature.id !== selectedId.value).map((signature) => signature.name),
)
const rows = computed<SignatureListRow[]>(() => {
const saved = store.signatures.map((signature) => ({
key: signature.id,
name: signature.name,
sub: signatureUsesText(signatureUsesOf(signature.id, store.defaults)),
current: signature.id === selectedId.value,
}))
if (selection.value?.kind !== 'new') return saved
return [...saved, { key: NEW_KEY, name: SIGNATURE_TEXT.newSignature, sub: SIGNATURE_TEXT.unsavedRow, current: true }]
})
/** 묻는 창의 글 — 지금 고치는 서명의 이름으로 (새 서명은 이름 대신 "새 서명에 쓴 내용") */
const switchMessage = computed(() => {
const pending = pendingSwitch.value
const current = selectedSignature.value
if (!pending) return ''
if (pending.kind === 'cancel') return SIGNATURE_SWITCH_DIALOG.cancelNew
if (!current) return pending.kind === 'pick' ? SIGNATURE_SWITCH_DIALOG.pickFromNew : SIGNATURE_SWITCH_DIALOG.cancelNew
return pending.kind === 'pick' ? SIGNATURE_SWITCH_DIALOG.pick(current.name) : SIGNATURE_SWITCH_DIALOG.create(current.name)
})
const switchCopy = computed(() => ({
title: SIGNATURE_SWITCH_DIALOG.title,
message: switchMessage.value,
stay: SIGNATURE_SWITCH_DIALOG.stay,
leave: SIGNATURE_SWITCH_DIALOG.discard,
}))
const deleteUses = computed(() =>
deleteTarget.value ? signatureUsesOf(deleteTarget.value.id, store.defaults) : null,
)
/**
* 다른 화면으로 떠나기 전 확인 — 저장하지 않은 서명이 있을 때. 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다).
* 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다
*/
const { confirming: leaveConfirming, stay: stayOnPage, leave: leavePage } = useLeaveConfirm({
active: () => formDirty.value && !formSaving.value && authStore.canUseApp,
unloadActive: () => formDirty.value && authStore.canUseApp,
})
function initialSelection(): Selection | null {
const first = store.defaultSignature('new') ?? store.signatures[0] ?? null
return first ? { kind: 'saved', id: first.id } : null
}
/** 서명을 연다 — 고치는 칸을 새로 만든다 (바꾼 것은 버린다) */
function open(next: Selection | null) {
selection.value = next
formKey.value += 1
formDirty.value = false
formSaving.value = false
}
/** 목록의 줄 — 지금 줄이면 아무것도 하지 않는다 */
function onPick(key: string) {
if (key === NEW_KEY || key === selectedId.value) return
if (formDirty.value) {
pendingSwitch.value = { kind: 'pick', id: key }
return
}
open({ kind: 'saved', id: key })
}
function onCreate() {
if (isFull.value) return
// 새 서명을 이미 열었다 — 이름 칸으로
if (selection.value?.kind === 'new') {
formRef.value?.focusName()
return
}
if (formDirty.value) {
pendingSwitch.value = { kind: 'create' }
return
}
startNew()
}
function startNew() {
beforeNew = selectedId.value
open({ kind: 'new' })
// 이름 칸에 초점 (디자인 원본 Signature16 3)
void nextTick(() => formRef.value?.focusName())
}
function onCancel() {
if (formDirty.value) {
pendingSwitch.value = { kind: 'cancel' }
return
}
cancelNew()
}
/** 새 줄을 빼고 전에 고른 서명을 연다 (없으면 첫 서명, 서명이 없으면 빈 화면) — 초점은 연 줄로 */
function cancelNew() {
const back = store.signatureById(beforeNew) ?? store.signatures[0] ?? null
open(back ? { kind: 'saved', id: back.id } : null)
focusCurrent()
}
/** 지금 줄(없으면 빈 화면의 [새 서명])에 초점 — 누른 단추가 사라졌을 때 */
function focusCurrent() {
void nextTick(() => {
const target =
rootRef.value?.querySelector<HTMLElement>('[data-signature-row][aria-current="true"]') ??
rootRef.value?.querySelector<HTMLElement>('#signature-empty-new')
target?.focus()
})
}
function onSwitchStay() {
pendingSwitch.value = null
}
/** [바꾼 내용 버리기] — 바꾼 것을 버리고 누른 일을 한다 */
function onSwitchDiscard() {
const pending = pendingSwitch.value
pendingSwitch.value = null
if (!pending) return
if (pending.kind === 'pick') open({ kind: 'saved', id: pending.id })
else if (pending.kind === 'create') startNew()
else cancelNew()
}
/** 새 서명을 처음 저장했다 — 그 서명을 고른 것으로 (칸은 그대로 둔다 — 쓰던 자리 · 실행 취소 기록이 남게) */
function onSaved(signature: MailSignature) {
if (selection.value?.kind === 'new') selection.value = { kind: 'saved', id: signature.id }
}
function onDeleteRequest() {
deleteTarget.value = selectedSignature.value
}
function onDeleteCancel() {
if (!deleting.value) deleteTarget.value = null
}
async function onDeleteConfirm() {
const target = deleteTarget.value
if (!target || deleting.value) return
deleting.value = true
try {
await store.remove(target.id)
} catch {
// 지우지 못했다 — 알림은 인터셉터. 창만 닫는다
deleting.value = false
deleteTarget.value = null
return
}
deleting.value = false
deleteTarget.value = null
toast.success(SIGNATURE_DELETE_TEXT.done(target.name))
const first = store.signatures[0] ?? null
open(first ? { kind: 'saved', id: first.id } : null)
focusCurrent()
}
// 고치던 서명이 목록에서 사라졌다(다시 받은 목록 — 다른 창에서 지웠다) — 쓰던 것은 그대로 두고 새 서명으로 본다([저장]하면 새로 만든다)
watch(selectedSignature, (current) => {
if (current === null && selection.value?.kind === 'saved') selection.value = { kind: 'new' }
})
</script>
<template>
<div
ref="rootRef"
class="signature-settings"
>
<SignatureDefaultsField v-if="store.signatures.length > 0" />
<div
v-if="selection"
class="signature-settings__body"
>
<div class="signature-settings__layout">
<SignatureList
:rows="rows"
:count="store.signatures.length"
:full="isFull"
@pick="onPick"
@create="onCreate"
/>
<div class="signature-settings__form">
<SignatureForm
ref="formRef"
:key="formKey"
:signature="selectedSignature"
:other-names="otherNames"
@saved="onSaved"
@delete="onDeleteRequest"
@cancel="onCancel"
@dirty="(dirty) => (formDirty = dirty)"
@saving="(saving) => (formSaving = saving)"
/>
</div>
</div>
</div>
<SignatureEmpty
v-else
@create="onCreate"
/>
<SignatureDeleteDialog
:open="deleteTarget !== null"
:name="deleteTarget?.name ?? ''"
:uses="deleteUses"
:busy="deleting"
@confirm="onDeleteConfirm"
@cancel="onDeleteCancel"
/>
<!-- 저장하지 않은 채 다른 서명을 고르거나 [새 서명] · [취소]를 누를 때 -->
<LeaveConfirmDialog
:open="pendingSwitch !== null"
id-prefix="signature-switch"
:copy="switchCopy"
@stay="onSwitchStay"
@leave="onSwitchDiscard"
/>
<!-- 저장하지 않은 채 다른 화면으로 갈 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="signature-leave"
:copy="SIGNATURE_LEAVE_DIALOG"
@stay="stayOnPage"
@leave="leavePage"
/>
</div>
</template>
<style scoped>
.signature-settings {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
/* 목록과 고치는 칸을 담은 칸 — 이 칸의 폭으로 배치를 고른다 (컨테이너 쿼리, SignatureList 도 같은 이름을 본다) */
.signature-settings__body {
container: signature-settings / inline-size;
}
/* 목록 왼쪽(208) · 고치는 칸 오른쪽 — 사이 24 (디자인 원본 Signature16 1) */
.signature-settings__layout {
display: flex;
align-items: flex-start;
gap: var(--space-24);
}
.signature-settings__form {
flex: 1 1 auto;
min-width: 0;
}
/* 칸이 640 보다 좁으면 목록을 위로 */
@container signature-settings (max-width: 40rem) {
.signature-settings__layout {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -4,6 +4,7 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). Esc → 서명 칸, 서명 칸에서 Alt+F10 → 툴바
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 팝오버에서 고른 값은 서명 칸으로 돌아간다
// 그림 단추는 파일 고르기를 연다 — 올리기는 서명 화면(SignatureForm)이 한다 (pick-image).
// 16단계 — 부재중 내용 칸도 쓴다: 이름(label — "자동 응답 서식" 등) · 그림 단추 없이(allowImages: false) · 칸을 고칠 수 없을 때 모두 끔(disabled).
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref } from 'vue'
@@ -16,6 +17,7 @@ import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/compon
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { readToolbarStatus } from '@/components/mail/editor/editor-state'
import { EDITOR_SHORTCUTS, EDITOR_TEXT_COLORS, type EditorColor } from '@/shared/constants/mail-editor'
import { SIGNATURE_TOOLBAR_LABEL } from '@/shared/constants/mail-signature'
interface Props {
editor: Editor
@@ -23,8 +25,14 @@ interface Props {
revision: number
/** 서명 칸 id — aria-controls */
controls: string
/** 툴바의 화면 낭독 이름 — 주지 않으면 "서명 서식" */
label?: string
/** 그림 단추를 둔다 — false 면 없다 (부재중 내용 칸) */
allowImages?: boolean
/** 그림을 더 넣을 수 없다 (올리는 중 등) */
imageDisabled?: boolean
/** 칸을 고칠 수 없다 — 모든 단추를 끈다 */
disabled?: boolean
}
interface Emits {
@@ -33,7 +41,10 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
label: SIGNATURE_TOOLBAR_LABEL,
allowImages: true,
imageDisabled: false,
disabled: false,
})
const emit = defineEmits<Emits>()
@@ -60,7 +71,9 @@ const marks = computed(() => [
{ id: 'underline' as const, label: '밑줄', icon: 'underline' as const, pressed: status.value.underline, run: (chain: ChainedCommands) => chain.toggleUnderline() },
])
const focusableTools = computed(() => TOOL_ORDER.filter((id) => id !== 'image' || !props.imageDisabled))
const focusableTools = computed(() =>
TOOL_ORDER.filter((id) => id !== 'image' || (props.allowImages && !props.imageDisabled)),
)
const rovingTool = computed<ToolId>(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : 'bold',
)
@@ -179,8 +192,9 @@ defineExpose({
ref="rootRef"
class="signature-toolbar"
role="toolbar"
aria-label="서명 서식"
:aria-label="label"
:aria-controls="controls"
:aria-disabled="disabled ? 'true' : undefined"
@keydown="onKeydown"
@focusin="onFocusIn"
>
@@ -191,6 +205,7 @@ defineExpose({
:label="item.label"
:shortcut="EDITOR_SHORTCUTS[item.id]"
:pressed="item.pressed"
:disabled="disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
@@ -208,6 +223,7 @@ defineExpose({
title="글자 색"
haspopup="dialog"
:expanded="popup?.id === 'textColor'"
:disabled="disabled"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
@@ -235,6 +251,7 @@ defineExpose({
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="popup?.id === 'link'"
:disabled="disabled"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
@@ -254,10 +271,11 @@ defineExpose({
</div>
<EditorToolButton
v-if="allowImages"
tool="image"
label="그림 넣기"
title="그림 넣기 (PNG, JPG, GIF)"
:disabled="imageDisabled"
:disabled="imageDisabled || disabled"
:focusable="rovingTool === 'image'"
@press="emit('pick-image')"
>

Some files were not shown because too many files have changed in this diff Show More