generated from ttipo/project-base
feat: 메일 16단계 — 주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식과 회사 밖 응답 · 2단계 인증 휴대폰 바꾸기 #24
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 실배달 메일에서 받은 값)
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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절)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1327
-18
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>김아라 <ara.kim@arasystem.it.kr></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">최도윤 <doyun.choi@example.net></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">최도윤 <doyun.choi@example.net></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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user