generated from ttipo/project-base
Compare commits
25 Commits
071fe9a60f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5114af9 | |||
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c | |||
| f4a85fda27 | |||
| 0ff1ec46af | |||
| f3586abcd7 | |||
| 6eba013416 | |||
| f0e9a50b01 | |||
| 5ce614cc0c | |||
| def96e833b | |||
| 1d01130c9d | |||
| 1b3f9ba7fe | |||
| cd958f3bf4 | |||
| abe4c1364c | |||
| 1962f07d79 | |||
| d6b804ba83 | |||
| 19dbb2babb | |||
| 907287021f | |||
| 0c3238936a | |||
| fceb6ed3e4 | |||
| 060265c8b0 |
@@ -18,6 +18,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
|
||||
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
|
||||
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
|
||||
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
|
||||
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
|
||||
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
@@ -36,11 +36,13 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
|
||||
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
|
||||
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
|
||||
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
|
||||
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
|
||||
@@ -103,7 +105,7 @@ lib/
|
||||
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
|
||||
│ └── user/ ← 예제(참조용)
|
||||
└── shared/
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 등)
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -310,6 +312,11 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
|
||||
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
|
||||
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
|
||||
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
|
||||
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -43,6 +43,8 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
|
||||
COPY --from=builder /app/release-notes ./release-notes
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
|
||||
|
||||
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
|
||||
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
|
||||
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
|
||||
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
|
||||
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
|
||||
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
|
||||
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
|
||||
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
|
||||
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
|
||||
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
|
||||
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
|
||||
|
||||
## 웹메일 · 메일 읽기와 정리
|
||||
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
|
||||
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
|
||||
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
|
||||
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
|
||||
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
|
||||
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
|
||||
|
||||
## 웹메일 · 주소록
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
|
||||
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
|
||||
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
|
||||
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
|
||||
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
|
||||
|
||||
## 웹메일 · 설정
|
||||
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
|
||||
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
|
||||
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
|
||||
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
|
||||
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
|
||||
|
||||
## 그 밖에
|
||||
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
|
||||
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 업데이트 내역 글
|
||||
|
||||
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
|
||||
|
||||
## 글을 더하는 법
|
||||
|
||||
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
|
||||
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
|
||||
|
||||
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
|
||||
|
||||
## 파일 안
|
||||
|
||||
```
|
||||
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
|
||||
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
|
||||
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
|
||||
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
|
||||
```
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
|
||||
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
|
||||
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
|
||||
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
|
||||
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
|
||||
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
|
||||
|
||||
## 지킬 것
|
||||
|
||||
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
|
||||
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
|
||||
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
|
||||
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
|
||||
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
|
||||
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -18,6 +18,7 @@ import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { PushModule } from '@/modules/push/push.module';
|
||||
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module';
|
||||
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
@@ -89,6 +90,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
AppConfigModule,
|
||||
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
|
||||
AppReleasesModule,
|
||||
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
|
||||
ReleaseNotesModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
RELEASE_NOTE_HEADING_MAX_LENGTH,
|
||||
RELEASE_NOTE_ITEM_MAX_LENGTH,
|
||||
RELEASE_NOTE_TITLE_MAX_LENGTH,
|
||||
} from '../release-notes.constants';
|
||||
import type {
|
||||
ReleaseNoteDetail,
|
||||
ReleaseNoteSection,
|
||||
ReleaseNoteSummary,
|
||||
} from '../release-notes.types';
|
||||
|
||||
/** 목록의 한 줄 · 글 화면의 다른 글 줄 (계약 7절 `ReleaseNoteSummary`) */
|
||||
export class ReleaseNoteSummaryDto implements ReleaseNoteSummary {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'글 id — 글 파일 이름에서 .md 를 뗀 것(`YYYY-MM-DD`, 같은 날 두 번째 글부터 `YYYY-MM-DD-2` …). 글 주소에 쓴다',
|
||||
example: '2026-11-18',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'글의 날짜 — 달력 날짜 글(`YYYY-MM-DD`, 파일 이름에서). 시각이 아니다 — 화면은 시간대를 바꾸지 않고 그대로 쓴다(Date 로 읽지 않는다)',
|
||||
example: '2026-11-18',
|
||||
})
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `제목 — 글 파일의 첫 줄(# 제목), ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지`,
|
||||
example: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
|
||||
})
|
||||
title!: string;
|
||||
}
|
||||
|
||||
/** GET /api/release-notes 의 응답 */
|
||||
export class ReleaseNotePageDto {
|
||||
@ApiProperty({
|
||||
type: [ReleaseNoteSummaryDto],
|
||||
description:
|
||||
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`)',
|
||||
})
|
||||
items!: ReleaseNoteSummaryDto[];
|
||||
|
||||
@ApiProperty({ type: PageInfoDto, description: 'total 은 글 수' })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** 묶음 (계약 7절 `ReleaseNoteSection`) */
|
||||
export class ReleaseNoteSectionDto implements ReleaseNoteSection {
|
||||
@ApiProperty({
|
||||
description: `묶음 머리(## 머리, ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지) — 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)`,
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '휴대폰 앱',
|
||||
})
|
||||
heading!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
`묶음의 줄(- 한 줄, ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지 — 하나 이상). 서버가 다듬은 글 그대로(앞뒤 빈칸 지움 · 안의 빈칸 여럿은 하나로 · 이어 쓴 줄은 띄어쓰기 하나로 · NFC). ` +
|
||||
'마크다운 · HTML 로 해석하지 않고 글로만 그린다',
|
||||
type: [String],
|
||||
example: [
|
||||
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
|
||||
],
|
||||
})
|
||||
items!: string[];
|
||||
}
|
||||
|
||||
/** GET /api/release-notes/{id} 의 응답 (계약 7절 `ReleaseNote`) */
|
||||
export class ReleaseNoteDto
|
||||
extends ReleaseNoteSummaryDto
|
||||
implements ReleaseNoteDetail
|
||||
{
|
||||
@ApiProperty({
|
||||
type: [ReleaseNoteSectionDto],
|
||||
description: '묶음 — 파일에 쓴 차례, 하나 이상',
|
||||
})
|
||||
sections!: ReleaseNoteSectionDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '이전 글 — 바로 더 옛 글(목록에서 이 글 아래). 없으면 null',
|
||||
type: ReleaseNoteSummaryDto,
|
||||
nullable: true,
|
||||
})
|
||||
previous!: ReleaseNoteSummaryDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '다음 글 — 바로 더 새 글(목록에서 이 글 위). 없으면 null',
|
||||
type: ReleaseNoteSummaryDto,
|
||||
nullable: true,
|
||||
})
|
||||
next!: ReleaseNoteSummaryDto | null;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
classifyReleaseNoteFile,
|
||||
compareReleaseNotes,
|
||||
describeReleaseNoteProblem,
|
||||
isCalendarDate,
|
||||
isReleaseNoteId,
|
||||
parseReleaseNoteText,
|
||||
type ReleaseNoteProblem,
|
||||
} from './release-note-format';
|
||||
import {
|
||||
RELEASE_NOTE_HEADING_MAX_LENGTH,
|
||||
RELEASE_NOTE_ITEM_MAX_LENGTH,
|
||||
RELEASE_NOTE_PROBLEMS_MAX,
|
||||
RELEASE_NOTE_TITLE_MAX_LENGTH,
|
||||
} from './release-notes.constants';
|
||||
|
||||
/** 줄들을 이어 글 파일의 글로 */
|
||||
const lines = (...rows: string[]) => rows.join('\n');
|
||||
|
||||
/** 맞는 글의 결과 — 틀리면 문제를 보이며 실패한다 */
|
||||
function parsed(text: string) {
|
||||
const result = parseReleaseNoteText(text);
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`틀린 글로 읽었다: ${result.problems.map(describeReleaseNoteProblem).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 틀린 글의 문제 — 맞는 글로 읽으면 실패한다 */
|
||||
function problemsOf(text: string): readonly ReleaseNoteProblem[] {
|
||||
const result = parseReleaseNoteText(text);
|
||||
if (result.ok) throw new Error('맞는 글로 읽었다');
|
||||
return result.problems;
|
||||
}
|
||||
|
||||
const LINE_SEPARATOR = String.fromCharCode(0x2028);
|
||||
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
|
||||
const BELL = String.fromCharCode(0x07);
|
||||
/** 이모지 하나 — UTF-16 으로 두 칸 */
|
||||
const EMOJI = String.fromCodePoint(0x1f600);
|
||||
|
||||
describe('parseReleaseNoteText — 맞는 글', () => {
|
||||
it('보드 C 의 글 — 제목 · 메모 · 머리 · 줄, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙는다', () => {
|
||||
const result = parsed(
|
||||
lines(
|
||||
'# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
|
||||
'<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->',
|
||||
'',
|
||||
'## 휴대폰 앱',
|
||||
'- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
|
||||
'- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.',
|
||||
' 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
|
||||
'',
|
||||
'## 메일 검색',
|
||||
'- 찾은 말에는 노란 표시가 붙습니다.',
|
||||
'',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
|
||||
sections: [
|
||||
{
|
||||
heading: '휴대폰 앱',
|
||||
items: [
|
||||
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
|
||||
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
|
||||
],
|
||||
},
|
||||
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('머리 없이 바로 쓴 줄은 머리 없는 묶음(heading null) — 뒤에 머리가 오면 새 묶음', () => {
|
||||
expect(
|
||||
parsed(
|
||||
lines(
|
||||
'# 왼쪽 메뉴를 정리했습니다',
|
||||
'- 아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.',
|
||||
'## 주소록',
|
||||
'- 한 줄',
|
||||
),
|
||||
).sections,
|
||||
).toEqual([
|
||||
{
|
||||
heading: null,
|
||||
items: ['아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.'],
|
||||
},
|
||||
{ heading: '주소록', items: ['한 줄'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('앞뒤 빈칸은 지우고 안의 빈칸 · 탭 여럿은 하나로 — 탭으로 시작한 줄도 이어 쓰기, 굵게 · 링크 꼴은 글 그대로', () => {
|
||||
const result = parsed(
|
||||
lines(
|
||||
'# 제목 ',
|
||||
'##\t머리\t ',
|
||||
'- 한 줄\t\t끝 ',
|
||||
'\t탭으로 이어 씀',
|
||||
' 빈칸 여럿으로 이어 씀',
|
||||
'- **굵게** [링크](https://example.com) `코드`',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.title).toBe('제목');
|
||||
expect(result.sections).toEqual([
|
||||
{
|
||||
heading: '머리',
|
||||
items: [
|
||||
'한 줄 끝 탭으로 이어 씀 빈칸 여럿으로 이어 씀',
|
||||
'**굵게** [링크](https://example.com) `코드`',
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 줄 · 메모(여러 줄 · 제목 앞 · 줄 사이)는 화면에 없다 — 빈 줄 · 메모를 지나도 이어 쓰기는 앞 - 줄에 붙는다', () => {
|
||||
const result = parsed(
|
||||
lines(
|
||||
'<!-- 맨 앞 메모',
|
||||
' 여러 줄 -->',
|
||||
'',
|
||||
'# 제목',
|
||||
' <!-- 들여 쓴 메모 -->',
|
||||
'- 첫 줄',
|
||||
'<!-- 줄 사이 메모 -->',
|
||||
'',
|
||||
' 이어 쓴 글',
|
||||
'<!-->',
|
||||
'<!--->',
|
||||
'- 둘째 줄',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
title: '제목',
|
||||
sections: [{ heading: null, items: ['첫 줄 이어 쓴 글', '둘째 줄'] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('CRLF 줄 끝 · 앞의 BOM 은 지운다', () => {
|
||||
const result = parsed(
|
||||
`${BYTE_ORDER_MARK}# 제목\r\n## 머리\r\n- 한 줄\r\n 이어 씀\r\n`,
|
||||
);
|
||||
|
||||
expect(result.title).toBe('제목');
|
||||
expect(result.sections).toEqual([
|
||||
{ heading: '머리', items: ['한 줄 이어 씀'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('NFC 로 모은다 — 풀어 쓴 한글(NFD)도 같은 글 · 같은 길이', () => {
|
||||
const decomposed = '업데이트 내역'.normalize('NFD');
|
||||
expect(decomposed.length).toBeGreaterThan('업데이트 내역'.length);
|
||||
|
||||
const result = parsed(lines(`# ${decomposed}`, `- ${decomposed}`));
|
||||
|
||||
expect(result.title).toBe('업데이트 내역');
|
||||
expect(result.sections[0].items).toEqual(['업데이트 내역']);
|
||||
});
|
||||
|
||||
it('길이 한도 — 제목 100 · 머리 40 · 한 줄 500 까지는 맞다(UTF-16 — 이모지는 두 칸)', () => {
|
||||
const title = '가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 2) + EMOJI;
|
||||
const heading = EMOJI + '나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH - 2);
|
||||
const item = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH);
|
||||
|
||||
const result = parsed(lines(`# ${title}`, `## ${heading}`, `- ${item}`));
|
||||
|
||||
expect(result.title).toHaveLength(RELEASE_NOTE_TITLE_MAX_LENGTH);
|
||||
expect(result.sections[0].heading).toHaveLength(
|
||||
RELEASE_NOTE_HEADING_MAX_LENGTH,
|
||||
);
|
||||
expect(result.sections[0].items[0]).toHaveLength(
|
||||
RELEASE_NOTE_ITEM_MAX_LENGTH,
|
||||
);
|
||||
});
|
||||
|
||||
it('- 만 쓴 줄도 이어 쓰기가 글을 채우면 맞다', () => {
|
||||
expect(parsed(lines('# 제목', '-', ' 이어 쓴 글')).sections).toEqual([
|
||||
{ heading: null, items: ['이어 쓴 글'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReleaseNoteText — 틀린 글 (줄 번호 · 까닭)', () => {
|
||||
it('첫 글 줄이 # 제목이 아니면 그 줄에 title-missing — 나머지도 읽어 함께 알린다', () => {
|
||||
expect(problemsOf(lines('', '## 머리', '- 한 줄', '맨 글'))).toEqual([
|
||||
{ line: 2, reason: 'title-missing' },
|
||||
{ line: 4, reason: 'unknown-line' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 파일 · 메모뿐인 파일은 title-missing · no-items (파일 전체 — 줄 번호 없음)', () => {
|
||||
for (const text of ['', '\n\n', lines('<!-- 메모만', '-->')]) {
|
||||
expect(problemsOf(text)).toEqual([
|
||||
{ line: null, reason: 'title-missing' },
|
||||
{ line: null, reason: 'no-items' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('# 제목을 또 쓰면 title-again, 비면 title-empty, 길면 title-too-long', () => {
|
||||
expect(problemsOf(lines('# 제목', '- 한 줄', '# 또 제목'))).toEqual([
|
||||
{ line: 3, reason: 'title-again' },
|
||||
]);
|
||||
expect(problemsOf(lines('# ', '- 한 줄'))).toEqual([
|
||||
{ line: 1, reason: 'title-empty' },
|
||||
]);
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 1)}${EMOJI}`,
|
||||
'- 줄',
|
||||
),
|
||||
),
|
||||
).toEqual([{ line: 1, reason: 'title-too-long' }]);
|
||||
});
|
||||
|
||||
it('머리가 비면 heading-empty, 길면 heading-too-long, 아래에 줄이 없으면 heading-without-items', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
'# 제목',
|
||||
'##',
|
||||
'- 줄',
|
||||
`## ${'나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH + 1)}`,
|
||||
'- 줄',
|
||||
'## 줄 없는 머리',
|
||||
'',
|
||||
'## 마지막 머리',
|
||||
'- 줄',
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 2, reason: 'heading-empty' },
|
||||
{ line: 4, reason: 'heading-too-long' },
|
||||
{ line: 6, reason: 'heading-without-items' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('줄이 비면 item-empty, 이어 붙인 뒤 500자를 넘으면 그 줄의 시작에 item-too-long', () => {
|
||||
const half = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH / 2);
|
||||
expect(
|
||||
problemsOf(
|
||||
lines('# 제목', '- ', '- 맞는 줄', `- ${half}`, ` ${half}`),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 2, reason: 'item-empty' },
|
||||
// 둘을 띄어쓰기 하나로 이으면 501자
|
||||
{ line: 4, reason: 'item-too-long' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('글에 줄이 하나도 없으면 no-items', () => {
|
||||
expect(problemsOf(lines('# 제목', '<!-- 줄은 나중에 -->'))).toEqual([
|
||||
{ line: null, reason: 'no-items' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['맨 글', '그냥 쓴 글'],
|
||||
['###', '### 작은 머리'],
|
||||
['별표 목록', '* 별표 줄'],
|
||||
['더하기 목록', '+ 더하기 줄'],
|
||||
['번호 목록', '1. 번호 줄'],
|
||||
['표', '| 칸 | 칸 |'],
|
||||
['인용', '> 인용'],
|
||||
['가로줄', '---'],
|
||||
['한 칸 들여 쓴 줄', ' - 한 칸 들여 쓴 줄'],
|
||||
['띄어쓰기 없는 머리', '##머리'],
|
||||
['띄어쓰기 없는 줄', '-줄'],
|
||||
])('쓸 수 없는 줄(%s)은 unknown-line', (_name, row) => {
|
||||
expect(problemsOf(lines('# 제목', '- 한 줄', row))).toEqual([
|
||||
{ line: 3, reason: 'unknown-line' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('띄어쓰기 없는 제목(#제목)은 제목이 아니다 — 첫 글 줄이면 title-missing · unknown-line', () => {
|
||||
expect(problemsOf(lines('#제목', '- 한 줄'))).toEqual([
|
||||
{ line: 1, reason: 'title-missing' },
|
||||
{ line: 1, reason: 'unknown-line' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('이어 쓰기 — 앞에 - 줄이 없으면(제목 · 머리 바로 아래) continuation-without-item, 들여 쓴 - 는 nested-list', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
'# 제목',
|
||||
' 제목에 이어 씀',
|
||||
'## 머리',
|
||||
' 머리에 이어 씀',
|
||||
'- 한 줄',
|
||||
' - 하위 목록',
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 2, reason: 'continuation-without-item' },
|
||||
{ line: 4, reason: 'continuation-without-item' },
|
||||
{ line: 6, reason: 'nested-list' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('메모 — 닫지 않으면 시작 줄에 memo-unclosed, 닫은 뒤 같은 줄에 글이 있으면 memo-trailing, 글 안의 <!-- 는 memo-inside-text', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
'# 제목 <!-- 제목 메모 -->',
|
||||
'- 한 줄 <!-- 줄 메모 -->',
|
||||
'<!-- 메모 --> 뒤에 글',
|
||||
'<!-- 여러 줄',
|
||||
'x --> 닫은 뒤 글',
|
||||
'- 둘째 줄',
|
||||
'<!-- 닫지 않은 메모',
|
||||
'- 메모 안이라 읽지 않는 줄',
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 1, reason: 'memo-inside-text' },
|
||||
{ line: 2, reason: 'memo-inside-text' },
|
||||
{ line: 3, reason: 'memo-trailing' },
|
||||
{ line: 5, reason: 'memo-trailing' },
|
||||
{ line: 7, reason: 'memo-unclosed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('보이지 않는 글자(제어 문자 · 줄 끝이 아닌 CR · 줄 구분 문자)는 control-character', () => {
|
||||
expect(
|
||||
problemsOf(
|
||||
lines(
|
||||
`# 제목${BELL}`,
|
||||
`## 머리\r중간`,
|
||||
`- 한 줄${LINE_SEPARATOR}둘째`,
|
||||
'- 맞는 줄',
|
||||
` 이어 씀${BELL}`,
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ line: 1, reason: 'control-character' },
|
||||
{ line: 2, reason: 'control-character' },
|
||||
{ line: 3, reason: 'control-character' },
|
||||
{ line: 5, reason: 'control-character' },
|
||||
]);
|
||||
});
|
||||
|
||||
it(`문제는 줄 차례로 앞의 ${RELEASE_NOTE_PROBLEMS_MAX}개까지 — 파일 전체의 문제가 먼저`, () => {
|
||||
const many = Array.from({ length: 300 }, (_, index) => `맨 글 ${index}`);
|
||||
|
||||
const problems = problemsOf(lines('# 제목', ...many));
|
||||
|
||||
expect(problems).toHaveLength(RELEASE_NOTE_PROBLEMS_MAX);
|
||||
expect(problems[0]).toEqual({ line: null, reason: 'no-items' });
|
||||
expect(problems.slice(1)).toEqual(
|
||||
Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 1 }, (_, index) => ({
|
||||
line: index + 2,
|
||||
reason: 'unknown-line',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('다 읽은 뒤에 찾는 앞쪽 줄의 문제(1행 제목 길이 · 3행 줄 없는 머리)도 뒤에 줄 문제가 많아 앞의 20개에 든다', () => {
|
||||
const many = Array.from({ length: 500 }, (_, index) => `맨 글 ${index}`);
|
||||
|
||||
const problems = problemsOf(
|
||||
lines(
|
||||
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH + 1)}`,
|
||||
'- 줄',
|
||||
'## 줄 없는 머리',
|
||||
...many,
|
||||
),
|
||||
);
|
||||
|
||||
expect(problems).toEqual([
|
||||
{ line: 1, reason: 'title-too-long' },
|
||||
{ line: 3, reason: 'heading-without-items' },
|
||||
...Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 2 }, (_, index) => ({
|
||||
line: index + 4,
|
||||
reason: 'unknown-line',
|
||||
})),
|
||||
]);
|
||||
});
|
||||
|
||||
it('문제를 글로 — 줄 번호 · 까닭 · 설명만 (글 내용은 없다)', () => {
|
||||
expect(
|
||||
describeReleaseNoteProblem({ line: 12, reason: 'item-too-long' }),
|
||||
).toBe('12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))');
|
||||
expect(describeReleaseNoteProblem({ line: null, reason: 'encoding' })).toBe(
|
||||
'파일 encoding(UTF-8 글이 아닙니다)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('파일 이름 · id · 날짜', () => {
|
||||
it('글 파일 — 이름이 id, 앞 10자가 날짜, 같은 날 둘째부터 번호(2~99)', () => {
|
||||
expect(classifyReleaseNoteFile('2026-11-18.md')).toEqual({
|
||||
kind: 'note',
|
||||
id: '2026-11-18',
|
||||
date: '2026-11-18',
|
||||
sequence: 1,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2026-11-18-2.md')).toEqual({
|
||||
kind: 'note',
|
||||
id: '2026-11-18-2',
|
||||
date: '2026-11-18',
|
||||
sequence: 2,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2026-11-18-99.md')).toMatchObject({
|
||||
kind: 'note',
|
||||
sequence: 99,
|
||||
});
|
||||
expect(classifyReleaseNoteFile('2024-02-29.md')).toMatchObject({
|
||||
kind: 'note',
|
||||
date: '2024-02-29',
|
||||
});
|
||||
});
|
||||
|
||||
it('쓰는 사람용 안내(README.md)는 글이 아니고 문제도 아니다', () => {
|
||||
expect(classifyReleaseNoteFile('README.md')).toEqual({ kind: 'readme' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-11-18-1.md',
|
||||
'2026-11-18-0.md',
|
||||
'2026-11-18-02.md',
|
||||
'2026-11-18-100.md',
|
||||
'2026-11-18-2-3.md',
|
||||
'2026-11-18.MD',
|
||||
'2026-11-18.txt',
|
||||
'2026-11-18',
|
||||
'2026-1-18.md',
|
||||
'20261118.md',
|
||||
'readme.md',
|
||||
'notes.md',
|
||||
'.gitkeep',
|
||||
'.DS_Store',
|
||||
])('글 파일 이름이 아니다: %s', (name) => {
|
||||
expect(classifyReleaseNoteFile(name)).toEqual({
|
||||
kind: 'not-a-note',
|
||||
reason: 'file-name',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-02-30.md',
|
||||
'2026-02-29.md',
|
||||
'2100-02-29.md',
|
||||
'2026-13-01.md',
|
||||
'2026-00-10.md',
|
||||
'2026-04-31.md',
|
||||
'2026-11-00.md',
|
||||
'0000-01-01.md',
|
||||
])('없는 날짜: %s', (name) => {
|
||||
expect(classifyReleaseNoteFile(name)).toEqual({
|
||||
kind: 'not-a-note',
|
||||
reason: 'date',
|
||||
});
|
||||
});
|
||||
|
||||
it('있는 날짜 — 윤년(4로 나누어지되 100은 아니고 400은 맞다)', () => {
|
||||
expect(isCalendarDate(2024, 2, 29)).toBe(true);
|
||||
expect(isCalendarDate(2000, 2, 29)).toBe(true);
|
||||
expect(isCalendarDate(1900, 2, 29)).toBe(false);
|
||||
expect(isCalendarDate(2026, 12, 31)).toBe(true);
|
||||
expect(isCalendarDate(2026, 6, 31)).toBe(false);
|
||||
});
|
||||
|
||||
it('경로 id 의 모양 — 맞는 모양만, 32자 넘거나 경로 · 확장자 · 다른 숫자 글자가 섞이면 아니다', () => {
|
||||
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-42']) {
|
||||
expect(isReleaseNoteId(id)).toBe(true);
|
||||
}
|
||||
for (const id of [
|
||||
'',
|
||||
'2026-11-18-1',
|
||||
'2026-11-18.md',
|
||||
'../2026-11-18',
|
||||
'2026-11-18/../x',
|
||||
'2026-11-18 ',
|
||||
`2026-11-18${'-2'.repeat(20)}`,
|
||||
'2026-11-18',
|
||||
'x'.repeat(33),
|
||||
]) {
|
||||
expect(isReleaseNoteId(id)).toBe(false);
|
||||
}
|
||||
for (const value of [20261118, null, undefined, ['2026-11-18']]) {
|
||||
expect(isReleaseNoteId(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('차례 — 최근 글부터(날짜 내림차순), 같은 날은 뒤 번호가 먼저', () => {
|
||||
const notes = [
|
||||
{ id: '2026-11-03', date: '2026-11-03', sequence: 1 },
|
||||
{ id: '2026-11-18', date: '2026-11-18', sequence: 1 },
|
||||
{ id: '2026-11-18-10', date: '2026-11-18', sequence: 10 },
|
||||
{ id: '2025-12-31', date: '2025-12-31', sequence: 1 },
|
||||
{ id: '2026-11-18-2', date: '2026-11-18', sequence: 2 },
|
||||
];
|
||||
|
||||
expect([...notes].sort(compareReleaseNotes).map((note) => note.id)).toEqual(
|
||||
[
|
||||
'2026-11-18-10',
|
||||
'2026-11-18-2',
|
||||
'2026-11-18',
|
||||
'2026-11-03',
|
||||
'2025-12-31',
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReleaseNoteText — 입력 길이에 비례', () => {
|
||||
it('20만 줄(이어 쓰기 · 메모 · 줄)과 빈칸이 많은 1MB 한 줄도 금방 끝난다', () => {
|
||||
const continuation = Array.from({ length: 200_000 }, () => ' 이어 씀');
|
||||
const memo = Array.from({ length: 200_000 }, () => '메모 안 글');
|
||||
const items = Array.from(
|
||||
{ length: 100_000 },
|
||||
(_, index) => `- 줄 ${index}`,
|
||||
);
|
||||
// 배열로 잇는다 — 수십만 줄을 함수 인자로 펼치면 호출 스택이 넘친다
|
||||
const text = ['# 제목', '- 첫 줄']
|
||||
.concat(continuation, ['<!-- 긴 메모'], memo, ['-->'], items, [
|
||||
`- ${'가 \t'.repeat(300_000)}`,
|
||||
])
|
||||
.join('\n');
|
||||
|
||||
const cpuMs = startCpuTimer();
|
||||
const result = parseReleaseNoteText(text);
|
||||
|
||||
// 지금 Linux 컨테이너에서 수백 ms — 줄마다 앞의 글을 다시 훑는(제곱) 길로 돌아가면 이 한도를 크게 넘는다
|
||||
expect(cpuMs()).toBeLessThan(5_000);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import {
|
||||
RELEASE_NOTE_FILE_EXTENSION,
|
||||
RELEASE_NOTE_FILE_MAX_BYTES,
|
||||
RELEASE_NOTE_FIRST_SEQUENCE,
|
||||
RELEASE_NOTE_HEADING_MAX_LENGTH,
|
||||
RELEASE_NOTE_ID_MAX_LENGTH,
|
||||
RELEASE_NOTE_ID_PATTERN,
|
||||
RELEASE_NOTE_ITEM_MAX_LENGTH,
|
||||
RELEASE_NOTE_PROBLEMS_MAX,
|
||||
RELEASE_NOTE_TITLE_MAX_LENGTH,
|
||||
RELEASE_NOTES_README,
|
||||
} from './release-notes.constants';
|
||||
import type { ReleaseNoteSection } from './release-notes.types';
|
||||
|
||||
/**
|
||||
* 업데이트 내역 글 파일 해석 (순수 함수 — 계약 7절 "글 파일").
|
||||
*
|
||||
* 파일 이름 `YYYY-MM-DD.md`(같은 날 둘째부터 `YYYY-MM-DD-2.md` …)가 글 id · 날짜이고, 파일 안은 마크다운의 아주 작은 일부(글만)다:
|
||||
* 첫 글 줄 `# 제목`(꼭 — 한 번만), `## 머리`(묶음 시작), `- 한 줄`(그 묶음의 줄 — 머리 없이 쓰면 머리 없는 묶음),
|
||||
* 빈칸 두 개 이상(또는 탭)으로 시작한 줄은 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙인다. 빈 줄 · `<!-- 메모 -->`(여러 줄도)는 어디든 —
|
||||
* 메모는 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다).
|
||||
* 그 밖의 줄(맨 글 · `###` · 표 · 한 칸 들여 쓴 줄 등)은 틀림. 굵게 · 링크 꼴은 해석하지 않는다(글 그대로).
|
||||
*
|
||||
* 틀리면 줄 번호를 단 문제 목록을 돌려준다 — 시험(CI)은 실패하고 서버는 그 글만 건너뛴다. 글 내용은 문제에 싣지 않는다(로그에 남는다).
|
||||
* 입력 길이에 비례한다 — 줄을 한 번씩만 보고, 정규식은 앞부분만 보거나 한 번 훑는다
|
||||
*/
|
||||
|
||||
/** 문제의 까닭 → 쓰는 사람에게 보일 말 (로그 · 시험 출력 — 글 내용은 넣지 않는다) */
|
||||
export const RELEASE_NOTE_PROBLEM_TEXTS = {
|
||||
'file-name':
|
||||
'글 파일 이름이 아닙니다 — YYYY-MM-DD.md 로, 같은 날 두 번째 글부터 YYYY-MM-DD-2.md 로 씁니다',
|
||||
'not-a-file': '보통 파일이 아닙니다(폴더 · 링크)',
|
||||
date: '없는 날짜입니다',
|
||||
'too-large': `파일이 너무 큽니다(${RELEASE_NOTE_FILE_MAX_BYTES / 1024}KB까지)`,
|
||||
unreadable: '파일을 읽지 못했습니다',
|
||||
encoding: 'UTF-8 글이 아닙니다',
|
||||
'title-missing': '첫 줄은 # 제목이어야 합니다',
|
||||
'title-again': '# 제목은 첫 줄에 한 번만 씁니다',
|
||||
'title-empty': '제목이 비었습니다',
|
||||
'title-too-long': `제목은 ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지입니다`,
|
||||
'heading-empty': '묶음 머리가 비었습니다',
|
||||
'heading-too-long': `묶음 머리는 ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지입니다`,
|
||||
'heading-without-items': '묶음 머리 아래에 줄(- )이 없습니다',
|
||||
'item-empty': '줄이 비었습니다',
|
||||
'item-too-long': `한 줄은 ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지입니다(이어 붙인 뒤)`,
|
||||
'continuation-without-item': '이어 붙일 줄(- )이 앞에 없습니다',
|
||||
'nested-list': '하위 목록은 쓸 수 없습니다',
|
||||
'unknown-line':
|
||||
'쓸 수 없는 줄입니다 — # 제목, ## 머리, - 한 줄, 빈칸 두 개로 시작한 이어 쓰기, <!-- 메모 --> 만 씁니다',
|
||||
'memo-unclosed': '메모(<!--)를 닫지(-->) 않았습니다',
|
||||
'memo-trailing': '메모를 닫은(-->) 뒤 같은 줄에 글이 있습니다',
|
||||
'memo-inside-text':
|
||||
'글 안에 메모(<!--)가 있습니다 — 메모는 글 뒤에 붙이지 않고 줄 앞에서 시작합니다',
|
||||
'control-character': '보이지 않는 글자(제어 문자)가 있습니다',
|
||||
'no-items': '글에 줄(- )이 하나도 없습니다',
|
||||
} as const;
|
||||
|
||||
export type ReleaseNoteProblemReason = keyof typeof RELEASE_NOTE_PROBLEM_TEXTS;
|
||||
|
||||
/** 글 파일의 문제 하나 — 줄 번호는 1부터, 파일 전체의 문제(이름 · 크기 · 글자 · 줄 없음 등)면 null */
|
||||
export interface ReleaseNoteProblem {
|
||||
readonly line: number | null;
|
||||
readonly reason: ReleaseNoteProblemReason;
|
||||
}
|
||||
|
||||
// ── 파일 이름 · id ──────────────────────────────────────────────────────
|
||||
|
||||
/** `YYYY-MM-DD` 의 길이 — id 의 앞부분이 날짜다 */
|
||||
const DATE_LENGTH = 'YYYY-MM-DD'.length;
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
const FEBRUARY = 2;
|
||||
|
||||
interface ReleaseNoteIdParts {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
/** id 를 날짜 · 차례 번호로 — 모양(길이 · 글자)이 틀리면 null. 있는 날짜인지는 보지 않는다 */
|
||||
function releaseNoteIdParts(id: string): ReleaseNoteIdParts | null {
|
||||
if (id.length > RELEASE_NOTE_ID_MAX_LENGTH) return null;
|
||||
const match = RELEASE_NOTE_ID_PATTERN.exec(id);
|
||||
if (!match) return null;
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
sequence:
|
||||
match[4] === undefined ? RELEASE_NOTE_FIRST_SEQUENCE : Number(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 경로의 글 id 모양인가 — 길이를 먼저 보고 정규식(앞에서 한 번 훑는다). 맞아도 그런 글이 있는지는 목록이 정한다 */
|
||||
export function isReleaseNoteId(value: unknown): value is string {
|
||||
return typeof value === 'string' && releaseNoteIdParts(value) !== null;
|
||||
}
|
||||
|
||||
function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
/** 있는 날짜인가 (그레고리력 — 2026-02-30 · 2026-13-01 · 0000-01-01 은 없다) */
|
||||
export function isCalendarDate(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
): boolean {
|
||||
if (!Number.isInteger(year) || year < 1) return false;
|
||||
if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
|
||||
return false;
|
||||
}
|
||||
const days =
|
||||
month === FEBRUARY && isLeapYear(year)
|
||||
? DAYS_PER_MONTH[month - 1] + 1
|
||||
: DAYS_PER_MONTH[month - 1];
|
||||
return Number.isInteger(day) && day >= 1 && day <= days;
|
||||
}
|
||||
|
||||
/** 글 폴더의 파일 이름을 가른다 — 안내(README) · 글 · 글이 아님(이름 모양 · 없는 날짜) */
|
||||
export type ReleaseNoteFileKind =
|
||||
| { readonly kind: 'readme' }
|
||||
| {
|
||||
readonly kind: 'note';
|
||||
readonly id: string;
|
||||
readonly date: string;
|
||||
readonly sequence: number;
|
||||
}
|
||||
| { readonly kind: 'not-a-note'; readonly reason: 'file-name' | 'date' };
|
||||
|
||||
export function classifyReleaseNoteFile(fileName: string): ReleaseNoteFileKind {
|
||||
if (fileName === RELEASE_NOTES_README) return { kind: 'readme' };
|
||||
if (!fileName.endsWith(RELEASE_NOTE_FILE_EXTENSION)) {
|
||||
return { kind: 'not-a-note', reason: 'file-name' };
|
||||
}
|
||||
const id = fileName.slice(0, -RELEASE_NOTE_FILE_EXTENSION.length);
|
||||
const parts = releaseNoteIdParts(id);
|
||||
if (parts === null) return { kind: 'not-a-note', reason: 'file-name' };
|
||||
if (!isCalendarDate(parts.year, parts.month, parts.day)) {
|
||||
return { kind: 'not-a-note', reason: 'date' };
|
||||
}
|
||||
return {
|
||||
kind: 'note',
|
||||
id,
|
||||
date: id.slice(0, DATE_LENGTH),
|
||||
sequence: parts.sequence,
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록 차례 — 최근 글부터: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`) */
|
||||
export function compareReleaseNotes(
|
||||
a: { readonly date: string; readonly sequence: number },
|
||||
b: { readonly date: string; readonly sequence: number },
|
||||
): number {
|
||||
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
|
||||
return b.sequence - a.sequence;
|
||||
}
|
||||
|
||||
// ── 파일 안 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 줄의 꼴 — 앞부분만 본다(빈칸 두 개 · 탭으로 시작하면 이어 쓰기, `###` 은 쓰지 않는 줄) */
|
||||
const CONTINUATION_LINE = /^(?:\t|[ \t]{2})/;
|
||||
const DEEPER_HEADING_LINE = /^###/;
|
||||
const HEADING_LINE = /^##(?:[ \t]|$)/;
|
||||
const TITLE_LINE = /^#(?:[ \t]|$)/;
|
||||
const ITEM_LINE = /^-(?:[ \t]|$)/;
|
||||
const HEADING_MARKER_LENGTH = '##'.length;
|
||||
const TITLE_MARKER_LENGTH = '#'.length;
|
||||
const ITEM_MARKER_LENGTH = '-'.length;
|
||||
|
||||
const MEMO_START = '<!--';
|
||||
const MEMO_END = '-->';
|
||||
/** `<!-->` · `<!--->` 도 닫힌 메모다(HTML 과 같다) — 닫는 표시를 여는 표시의 셋째 글자부터 찾는다 */
|
||||
const MEMO_END_SEARCH_OFFSET = 2;
|
||||
|
||||
/** 보이지 않는 글자 — C0(탭 빼고) · DEL · C1(아래 줄 · 문단 구분 문자와 함께). 화면에 글로 나가지 않게 틀린 글로 본다 */
|
||||
const CONTROL_CHARACTER =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/;
|
||||
/**
|
||||
* 줄 · 문단 구분 문자(U+2028 · U+2029) — 보이지 않는 글자와 같이 본다.
|
||||
* 글자 코드로 만든다 — 그 글자가 정규식 리터럴에 그대로 들어가면 줄 끝이라 문법 오류다(이스케이프가 풀린 채 저장되면)
|
||||
*/
|
||||
const LINE_SEPARATORS = [0x2028, 0x2029].map((code) =>
|
||||
String.fromCharCode(code),
|
||||
);
|
||||
|
||||
/** 보이지 않는 글자가 있는가 — 한 번씩 훑는다 */
|
||||
function hasInvisibleCharacter(text: string): boolean {
|
||||
return (
|
||||
CONTROL_CHARACTER.test(text) ||
|
||||
LINE_SEPARATORS.some((separator) => text.includes(separator))
|
||||
);
|
||||
}
|
||||
/** 안의 빈칸 · 탭 여럿 — 하나로 (웹 · 앱이 같은 글을 그리게) */
|
||||
const INNER_BLANKS = /[ \t]+/g;
|
||||
/** 글 앞의 BOM(U+FEFF) — 지운다 (UTF-8 풀기가 이미 지우지만 해석만 따로 부를 때도) */
|
||||
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
|
||||
const CARRIAGE_RETURN = '\r';
|
||||
|
||||
type LineKind = 'title' | 'heading' | 'item' | 'continuation' | 'other';
|
||||
|
||||
function lineKindOf(line: string): LineKind {
|
||||
if (CONTINUATION_LINE.test(line)) return 'continuation';
|
||||
if (DEEPER_HEADING_LINE.test(line)) return 'other';
|
||||
if (HEADING_LINE.test(line)) return 'heading';
|
||||
if (TITLE_LINE.test(line)) return 'title';
|
||||
if (ITEM_LINE.test(line)) return 'item';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** 화면에 나갈 글로 다듬는다 — NFC · 안의 빈칸 · 탭 여럿은 하나로 · 앞뒤 빈칸 지움 */
|
||||
function tidy(text: string): string {
|
||||
return text.normalize('NFC').replace(INNER_BLANKS, ' ').trim();
|
||||
}
|
||||
|
||||
interface DraftItem {
|
||||
readonly line: number;
|
||||
readonly parts: string[];
|
||||
}
|
||||
|
||||
interface DraftSection {
|
||||
/** `## 머리` 의 줄 — 머리 없는 묶음은 null */
|
||||
readonly line: number | null;
|
||||
readonly heading: string | null;
|
||||
readonly items: DraftItem[];
|
||||
}
|
||||
|
||||
/** 해석 결과 — 맞으면 제목 · 묶음(다듬은 글), 틀리면 줄 차례의 문제(앞의 RELEASE_NOTE_PROBLEMS_MAX 개) */
|
||||
export type ReleaseNoteTextParse =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly title: string;
|
||||
readonly sections: readonly ReleaseNoteSection[];
|
||||
}
|
||||
| { readonly ok: false; readonly problems: readonly ReleaseNoteProblem[] };
|
||||
|
||||
/** 모아 둔 문제 하나와 차례 키(줄 번호 — 파일 전체의 문제는 0) */
|
||||
interface KeptProblem {
|
||||
readonly problem: ReleaseNoteProblem;
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문제 모음 — 줄 차례(파일 전체의 문제가 먼저 · 같은 줄은 찾은 차례)로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개만 늘 지킨다.
|
||||
* 다 읽은 뒤에 더하는 앞쪽 줄의 문제(1행 제목 길이 · 머리 아래 줄 없음 등)도 그 자리에 들어가고 뒤의 것이 밀려난다.
|
||||
* 메모리는 상한만큼 — 쓰레기 파일이어도 일정하다. 상한이 작아(20) 자리를 뒤에서부터 훑어도 문제마다 일정한 비용이다
|
||||
*/
|
||||
class ProblemList {
|
||||
private readonly kept: KeptProblem[] = [];
|
||||
private count = 0;
|
||||
|
||||
/** 문제가 하나라도 있었나 (지킨 것 밖으로 밀려난 것까지) */
|
||||
get hasProblems(): boolean {
|
||||
return this.count > 0;
|
||||
}
|
||||
|
||||
add(line: number | null, reason: ReleaseNoteProblemReason): void {
|
||||
this.count += 1;
|
||||
const order = line ?? 0;
|
||||
// 같은 줄은 먼저 찾은 것이 앞 — 새 것은 차례 키가 더 작을 때만 앞으로 간다
|
||||
let index = this.kept.length;
|
||||
while (index > 0 && order < this.kept[index - 1].order) index -= 1;
|
||||
if (index >= RELEASE_NOTE_PROBLEMS_MAX) return;
|
||||
this.kept.splice(index, 0, { problem: { line, reason }, order });
|
||||
if (this.kept.length > RELEASE_NOTE_PROBLEMS_MAX) this.kept.pop();
|
||||
}
|
||||
|
||||
/** 글 조각(제목 · 머리 · 줄)에 메모 · 보이지 않는 글자가 없는지 */
|
||||
checkText(text: string, line: number): void {
|
||||
if (text.includes(MEMO_START)) this.add(line, 'memo-inside-text');
|
||||
if (hasInvisibleCharacter(text)) this.add(line, 'control-character');
|
||||
}
|
||||
|
||||
/** 줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개 */
|
||||
sorted(): ReleaseNoteProblem[] {
|
||||
return this.kept.map((entry) => entry.problem);
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 줄씩 읽으며 제목 · 묶음 · 줄을 모은다 (다듬기 · 길이는 다 읽은 뒤) */
|
||||
class ReleaseNoteReader {
|
||||
readonly problems = new ProblemList();
|
||||
readonly sections: DraftSection[] = [];
|
||||
/** 제목 — 첫 글 줄이 `# ` 가 아니면 null */
|
||||
title: { readonly text: string; readonly line: number } | null = null;
|
||||
/** 글 줄(빈 줄 · 메모 밖)을 하나라도 보았나 — 첫 글 줄이 제목이어야 한다 */
|
||||
private sawContent = false;
|
||||
private section: DraftSection | null = null;
|
||||
/** 이어 쓰기가 붙을 줄 — 머리 · 제목 줄에서 끊긴다(빈 줄 · 메모는 끊지 않는다) */
|
||||
private item: DraftItem | null = null;
|
||||
/** 열린 메모의 시작 줄 */
|
||||
private memoLine: number | null = null;
|
||||
|
||||
read(text: string): void {
|
||||
const source = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text;
|
||||
const lines = source.split('\n');
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const raw = lines[index];
|
||||
const line = raw.endsWith(CARRIAGE_RETURN) ? raw.slice(0, -1) : raw;
|
||||
this.readLine(line, index + 1);
|
||||
}
|
||||
if (this.memoLine !== null)
|
||||
this.problems.add(this.memoLine, 'memo-unclosed');
|
||||
if (!this.sawContent) this.problems.add(null, 'title-missing');
|
||||
}
|
||||
|
||||
private readLine(line: string, lineNumber: number): void {
|
||||
if (this.memoLine !== null) {
|
||||
const end = line.indexOf(MEMO_END);
|
||||
if (end < 0) return;
|
||||
this.memoLine = null;
|
||||
this.checkAfterMemo(line, end, lineNumber);
|
||||
return;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') return;
|
||||
if (trimmed.startsWith(MEMO_START)) {
|
||||
const start = line.indexOf(MEMO_START);
|
||||
const end = line.indexOf(MEMO_END, start + MEMO_END_SEARCH_OFFSET);
|
||||
if (end < 0) {
|
||||
this.memoLine = lineNumber;
|
||||
return;
|
||||
}
|
||||
this.checkAfterMemo(line, end, lineNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = lineKindOf(line);
|
||||
if (!this.sawContent) {
|
||||
this.sawContent = true;
|
||||
if (kind === 'title') {
|
||||
const text = line.slice(TITLE_MARKER_LENGTH);
|
||||
this.problems.checkText(text, lineNumber);
|
||||
this.title = { text, line: lineNumber };
|
||||
return;
|
||||
}
|
||||
this.problems.add(lineNumber, 'title-missing');
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case 'title':
|
||||
this.problems.add(lineNumber, 'title-again');
|
||||
this.item = null;
|
||||
return;
|
||||
case 'heading': {
|
||||
const heading = line.slice(HEADING_MARKER_LENGTH);
|
||||
this.problems.checkText(heading, lineNumber);
|
||||
this.section = { line: lineNumber, heading, items: [] };
|
||||
this.sections.push(this.section);
|
||||
this.item = null;
|
||||
return;
|
||||
}
|
||||
case 'item': {
|
||||
const text = line.slice(ITEM_MARKER_LENGTH);
|
||||
this.problems.checkText(text, lineNumber);
|
||||
if (this.section === null) {
|
||||
// 머리 없이 바로 쓴 줄 — 머리 없는 묶음 (글의 맨 앞에만 생긴다)
|
||||
this.section = { line: null, heading: null, items: [] };
|
||||
this.sections.push(this.section);
|
||||
}
|
||||
this.item = { line: lineNumber, parts: [text.trim()] };
|
||||
this.section.items.push(this.item);
|
||||
return;
|
||||
}
|
||||
case 'continuation':
|
||||
if (this.item === null) {
|
||||
this.problems.add(lineNumber, 'continuation-without-item');
|
||||
} else if (ITEM_LINE.test(trimmed)) {
|
||||
// 들여 쓴 `- ` — 마크다운에서는 하위 목록이지만 화면은 한 단계만 그린다
|
||||
this.problems.add(lineNumber, 'nested-list');
|
||||
} else {
|
||||
this.problems.checkText(trimmed, lineNumber);
|
||||
this.item.parts.push(trimmed);
|
||||
}
|
||||
return;
|
||||
case 'other':
|
||||
this.problems.add(lineNumber, 'unknown-line');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 메모를 닫은 뒤 같은 줄에는 빈칸만 */
|
||||
private checkAfterMemo(line: string, end: number, lineNumber: number): void {
|
||||
if (line.slice(end + MEMO_END.length).trim() !== '') {
|
||||
this.problems.add(lineNumber, 'memo-trailing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글 파일의 글(UTF-8 을 푼 것)을 해석한다 — 앞의 BOM · 줄 끝의 CR 은 지운다.
|
||||
* 맞으면 다듬은 제목 · 묶음(파일에 쓴 차례), 틀리면 줄 번호를 단 문제(줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개)
|
||||
*/
|
||||
export function parseReleaseNoteText(text: string): ReleaseNoteTextParse {
|
||||
const reader = new ReleaseNoteReader();
|
||||
reader.read(text);
|
||||
const { problems } = reader;
|
||||
|
||||
let title = '';
|
||||
if (reader.title !== null) {
|
||||
title = tidy(reader.title.text);
|
||||
if (title === '') problems.add(reader.title.line, 'title-empty');
|
||||
if (title.length > RELEASE_NOTE_TITLE_MAX_LENGTH) {
|
||||
problems.add(reader.title.line, 'title-too-long');
|
||||
}
|
||||
}
|
||||
|
||||
let itemCount = 0;
|
||||
const sections = reader.sections.map((draft): ReleaseNoteSection => {
|
||||
let heading: string | null = null;
|
||||
if (draft.heading !== null && draft.line !== null) {
|
||||
heading = tidy(draft.heading);
|
||||
if (heading === '') problems.add(draft.line, 'heading-empty');
|
||||
if (heading.length > RELEASE_NOTE_HEADING_MAX_LENGTH) {
|
||||
problems.add(draft.line, 'heading-too-long');
|
||||
}
|
||||
if (draft.items.length === 0) {
|
||||
problems.add(draft.line, 'heading-without-items');
|
||||
}
|
||||
}
|
||||
const items = draft.items.map((item) => {
|
||||
const text = tidy(item.parts.join(' '));
|
||||
if (text === '') problems.add(item.line, 'item-empty');
|
||||
if (text.length > RELEASE_NOTE_ITEM_MAX_LENGTH) {
|
||||
problems.add(item.line, 'item-too-long');
|
||||
}
|
||||
return text;
|
||||
});
|
||||
itemCount += items.length;
|
||||
return { heading, items };
|
||||
});
|
||||
if (itemCount === 0) problems.add(null, 'no-items');
|
||||
|
||||
if (problems.hasProblems) {
|
||||
return { ok: false, problems: problems.sorted() };
|
||||
}
|
||||
return { ok: true, title, sections };
|
||||
}
|
||||
|
||||
/** 문제 하나를 로그 · 시험 출력용 글로 — `12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))` (글 내용은 없다) */
|
||||
export function describeReleaseNoteProblem(
|
||||
problem: ReleaseNoteProblem,
|
||||
): string {
|
||||
const where = problem.line === null ? '파일' : `${problem.line}행`;
|
||||
return `${where} ${problem.reason}(${RELEASE_NOTE_PROBLEM_TEXTS[problem.reason]})`;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
|
||||
|
||||
describe('ReleaseNoteIdPipe — 경로의 글 id', () => {
|
||||
const pipe = new ReleaseNoteIdPipe();
|
||||
|
||||
it('글 id 모양이면 그대로', () => {
|
||||
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-99']) {
|
||||
expect(pipe.transform(id)).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it('모양이 틀리면 400 이 아니라 404 RES_001 — 없는 글과 같다(경로로 쓰지 않는다)', () => {
|
||||
for (const value of [
|
||||
'',
|
||||
'latest',
|
||||
'2026-11-18-1',
|
||||
'2026-11-18.md',
|
||||
'../../etc/passwd',
|
||||
'..%2F2026-11-18',
|
||||
'x'.repeat(33),
|
||||
42,
|
||||
undefined,
|
||||
]) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
pipe.transform(value);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) code = error.code;
|
||||
}
|
||||
expect(code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isReleaseNoteId } from './release-note-format';
|
||||
|
||||
/**
|
||||
* 경로의 업데이트 내역 글 id (`/api/release-notes/{id}`) — `YYYY-MM-DD` · `YYYY-MM-DD-N`(N 은 2~99) · 32자까지.
|
||||
* 모양이 틀린 id 도 "그런 글이 없다"와 같으므로 400 이 아니라 404 RES_001 이다(손으로 고친 주소도 화면은 "없는 글").
|
||||
* 파일 경로로 쓰지 않는다 — 서비스가 읽어 둔 목록에서 찾는다(경로 탈출 없음)
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReleaseNoteIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (isReleaseNoteId(value)) return value;
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-release-note-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { RELEASE_NOTE_FILE_MAX_BYTES } from './release-notes.constants';
|
||||
import {
|
||||
ReleaseNotesFolder,
|
||||
readReleaseNoteFile,
|
||||
readUpTo,
|
||||
} from './release-notes-folder';
|
||||
|
||||
/** 맞는 글 하나 — 제목 · 줄 */
|
||||
const note = (title: string, item = '한 줄') => `# ${title}\n- ${item}\n`;
|
||||
|
||||
describe('ReleaseNotesFolder — 글 폴더 읽기', () => {
|
||||
let dir: string;
|
||||
let errorLog: jest.SpyInstance;
|
||||
let warnLog: jest.SpyInstance;
|
||||
|
||||
const pathOf = (name: string) => join(dir, name);
|
||||
const write = (name: string, text: string | Buffer) =>
|
||||
writeFileSync(pathOf(name), text);
|
||||
/** 바뀐 시각을 옮긴다 (초) — 같은 크기로 고친 파일을 "바뀌었다"고 알리게 */
|
||||
const touch = (name: string, seconds: number) => {
|
||||
const { atime, mtime } = statSync(pathOf(name));
|
||||
utimesSync(
|
||||
pathOf(name),
|
||||
atime,
|
||||
new Date(mtime.getTime() + seconds * 1_000),
|
||||
);
|
||||
};
|
||||
const loggedText = (spy: jest.SpyInstance) =>
|
||||
spy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
|
||||
errorLog = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
warnLog = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('글 파일만 최근 글부터 읽는다 — 쓰는 사람용 안내(README.md)는 건너뛰고 로그도 없다', async () => {
|
||||
write('README.md', '# 안내\n아무 글이나 써도 된다\n');
|
||||
write('2026-11-05.md', note('화면의 안내 글을 다듬었습니다'));
|
||||
write('2026-11-18.md', note('휴대폰 앱이 나왔습니다'));
|
||||
write('2026-11-18-2.md', note('같은 날 두 번째 글'));
|
||||
|
||||
const snapshot = await new ReleaseNotesFolder(dir).read();
|
||||
|
||||
expect(snapshot.problems).toEqual([]);
|
||||
expect(snapshot.notes).toEqual([
|
||||
{
|
||||
id: '2026-11-18-2',
|
||||
date: '2026-11-18',
|
||||
sequence: 2,
|
||||
title: '같은 날 두 번째 글',
|
||||
sections: [{ heading: null, items: ['한 줄'] }],
|
||||
},
|
||||
{
|
||||
id: '2026-11-18',
|
||||
date: '2026-11-18',
|
||||
sequence: 1,
|
||||
title: '휴대폰 앱이 나왔습니다',
|
||||
sections: [{ heading: null, items: ['한 줄'] }],
|
||||
},
|
||||
{
|
||||
id: '2026-11-05',
|
||||
date: '2026-11-05',
|
||||
sequence: 1,
|
||||
title: '화면의 안내 글을 다듬었습니다',
|
||||
sections: [{ heading: null, items: ['한 줄'] }],
|
||||
},
|
||||
]);
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('이름 · 크기 · 바뀐 시각이 같으면 앞서 읽은 결과를 쓴다 — 하나라도 바뀌면 그 파일을 다시 읽는다', async () => {
|
||||
// 바뀐 시각을 초 단위의 같은 값으로 둔다 — 파일 시스템의 ns 시각을 그대로 되돌리지는 못한다
|
||||
const fixed = new Date('2026-11-18T00:00:00.000Z');
|
||||
const folder = new ReleaseNotesFolder(dir);
|
||||
write('2026-11-18.md', note('가나다'));
|
||||
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
|
||||
const first = await folder.read();
|
||||
|
||||
// 아무것도 바뀌지 않았다 — 앞의 결과 그대로
|
||||
expect(await folder.read()).toBe(first);
|
||||
|
||||
// 같은 크기 · 같은 바뀐 시각으로 글만 고치면 알아채지 못한다 — 이름 · 크기 · 시각만 본다는 증거
|
||||
write('2026-11-18.md', note('가나라'));
|
||||
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
|
||||
expect((await folder.read()).notes[0].title).toBe('가나다');
|
||||
|
||||
// 바뀐 시각이 다르면 다시 읽는다
|
||||
touch('2026-11-18.md', 10);
|
||||
expect((await folder.read()).notes[0].title).toBe('가나라');
|
||||
|
||||
// 새 파일 · 지운 파일
|
||||
write('2026-11-19.md', note('새 글'));
|
||||
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
|
||||
'2026-11-19',
|
||||
'2026-11-18',
|
||||
]);
|
||||
unlinkSync(pathOf('2026-11-18.md'));
|
||||
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
|
||||
'2026-11-19',
|
||||
]);
|
||||
});
|
||||
|
||||
it('틀린 글 · 글이 아닌 파일 · 폴더는 건너뛰고 바뀔 때마다 error 로그 한 번 — 파일 이름 · 줄 번호 · 까닭만(글 내용은 없다)', async () => {
|
||||
const folder = new ReleaseNotesFolder(dir);
|
||||
write('2026-11-18.md', note('맞는 글'));
|
||||
write('2026-11-19.md', '# 틀린 글\n비밀스러운 맨 글\n- 한 줄\n');
|
||||
write('notes.txt', '메모');
|
||||
write('2026-02-30.md', note('없는 날짜'));
|
||||
mkdirSync(pathOf('2026-11-20.md'));
|
||||
|
||||
const snapshot = await folder.read();
|
||||
|
||||
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
|
||||
expect(snapshot.problems).toEqual([
|
||||
{ file: '2026-02-30.md', line: null, reason: 'date' },
|
||||
{ file: '2026-11-19.md', line: 2, reason: 'unknown-line' },
|
||||
{ file: '2026-11-20.md', line: null, reason: 'not-a-file' },
|
||||
{ file: 'notes.txt', line: null, reason: 'file-name' },
|
||||
]);
|
||||
expect(errorLog).toHaveBeenCalledTimes(4);
|
||||
const logged = loggedText(errorLog);
|
||||
expect(logged).toContain('file="2026-11-19.md"');
|
||||
expect(logged).toContain('2행 unknown-line');
|
||||
expect(logged).toContain('[SYS_001]');
|
||||
expect(logged).not.toContain('비밀스러운');
|
||||
expect(logged).not.toContain('틀린 글');
|
||||
|
||||
// 그대로면 다시 남기지 않는다
|
||||
await folder.read();
|
||||
expect(errorLog).toHaveBeenCalledTimes(4);
|
||||
|
||||
// 그 파일이 바뀌면 그 파일만 다시 읽고 다시 남긴다
|
||||
touch('2026-11-19.md', 10);
|
||||
await folder.read();
|
||||
expect(errorLog).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 고치면 목록에 들어온다
|
||||
write('2026-11-19.md', note('고친 글'));
|
||||
touch('2026-11-19.md', 20);
|
||||
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
|
||||
'2026-11-19',
|
||||
'2026-11-18',
|
||||
]);
|
||||
expect(errorLog).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('UTF-8 이 아닌 글은 encoding, 너무 큰 파일은 읽지 않고 too-large', async () => {
|
||||
// EUC-KR 의 "가"(0xB0 0xA1) — UTF-8 로는 틀린 바이트
|
||||
write('2026-11-18.md', Buffer.from([0x23, 0x20, 0xb0, 0xa1, 0x0a]));
|
||||
write(
|
||||
'2026-11-19.md',
|
||||
`# 큰 글\n- ${'가'.repeat(RELEASE_NOTE_FILE_MAX_BYTES)}\n`,
|
||||
);
|
||||
|
||||
const snapshot = await new ReleaseNotesFolder(dir).read();
|
||||
|
||||
expect(snapshot.notes).toEqual([]);
|
||||
expect(snapshot.problems).toEqual([
|
||||
{ file: '2026-11-18.md', line: null, reason: 'encoding' },
|
||||
{ file: '2026-11-19.md', line: null, reason: 'too-large' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('폴더가 없으면 빈 목록 + warn 한 번 — 생기면 읽고, 또 없어지면 다시 warn', async () => {
|
||||
const missing = join(dir, 'release-notes');
|
||||
const folder = new ReleaseNotesFolder(missing);
|
||||
|
||||
expect(await folder.read()).toEqual({ notes: [], problems: [] });
|
||||
expect(await folder.read()).toEqual({ notes: [], problems: [] });
|
||||
expect(warnLog).toHaveBeenCalledTimes(1);
|
||||
expect(loggedText(warnLog)).toContain(missing);
|
||||
|
||||
mkdirSync(missing);
|
||||
writeFileSync(join(missing, '2026-11-18.md'), note('생긴 글'));
|
||||
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
|
||||
'2026-11-18',
|
||||
]);
|
||||
|
||||
rmSync(missing, { recursive: true });
|
||||
expect(await folder.read()).toEqual({ notes: [], problems: [] });
|
||||
expect(warnLog).toHaveBeenCalledTimes(2);
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('함께 온 요청은 한 번 읽은 결과를 나눈다 — 틀린 글의 로그도 한 번', async () => {
|
||||
write('2026-11-18.md', note('맞는 글'));
|
||||
write('2026-11-19.md', '맨 글\n');
|
||||
const folder = new ReleaseNotesFolder(dir);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
folder.read(),
|
||||
folder.read(),
|
||||
folder.read(),
|
||||
]);
|
||||
|
||||
expect(b).toBe(a);
|
||||
expect(c).toBe(a);
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('링크로 둔 글 파일은 따라가지 않고 not-a-file (링크를 만들 수 없는 곳에서는 건너뛴다)', async () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'arasystem-release-outside-'));
|
||||
try {
|
||||
writeFileSync(join(outside, 'target.md'), note('폴더 밖의 글'));
|
||||
if (!trySymlink(join(outside, 'target.md'), pathOf('2026-11-21.md'))) {
|
||||
return;
|
||||
}
|
||||
write('2026-11-18.md', note('맞는 글'));
|
||||
|
||||
const snapshot = await new ReleaseNotesFolder(dir).read();
|
||||
|
||||
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
|
||||
expect(snapshot.problems).toEqual([
|
||||
{ file: '2026-11-21.md', line: null, reason: 'not-a-file' },
|
||||
]);
|
||||
expect(await readReleaseNoteFile(pathOf('2026-11-21.md'))).toEqual(
|
||||
// Windows 에는 O_NOFOLLOW 가 없다 — 그곳에서 링크를 만들 수 있었으면 여기서 따라간다(Linux · CI 가 본다)
|
||||
process.platform === 'win32'
|
||||
? expect.objectContaining({ ok: true })
|
||||
: { ok: false, reason: 'not-a-file' },
|
||||
);
|
||||
} finally {
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('기동 때 한 번 읽어 틀린 글을 바로 로그에 남긴다 — 읽지 못해도 던지지 않는다', async () => {
|
||||
write('2026-11-19.md', '맨 글\n');
|
||||
const folder = new ReleaseNotesFolder(dir);
|
||||
|
||||
await folder.onApplicationBootstrap();
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
expect(loggedText(errorLog)).toContain('file="2026-11-19.md"');
|
||||
|
||||
// 요청 때는 기동 때 읽은 결과 그대로 — 다시 남기지 않는다
|
||||
await folder.read();
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.spyOn(folder, 'read').mockRejectedValue(new Error('EACCES 흉내'));
|
||||
await expect(folder.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
expect(errorLog).toHaveBeenCalledTimes(2);
|
||||
expect(loggedText(errorLog)).toContain('기동 때 읽지 못했습니다');
|
||||
});
|
||||
});
|
||||
|
||||
/** 링크를 만든다 — 권한이 없는 곳(Windows 의 보통 계정)에서는 false */
|
||||
function trySymlink(target: string, path: string): boolean {
|
||||
try {
|
||||
symlinkSync(target, path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('readReleaseNoteFile — 한 번 열어 그 파일로만 본다', () => {
|
||||
let dir: string;
|
||||
const pathOf = (name: string) => join(dir, name);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-file-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('일반 파일은 바이트 그대로', async () => {
|
||||
writeFileSync(pathOf('2026-11-18.md'), note('글'));
|
||||
|
||||
const read = await readReleaseNoteFile(pathOf('2026-11-18.md'));
|
||||
|
||||
expect(read).toEqual({ ok: true, bytes: Buffer.from(note('글')) });
|
||||
});
|
||||
|
||||
it('폴더는 not-a-file, 없는 파일은 unreadable', async () => {
|
||||
mkdirSync(pathOf('2026-11-19.md'));
|
||||
|
||||
expect(await readReleaseNoteFile(pathOf('2026-11-19.md'))).toEqual({
|
||||
ok: false,
|
||||
reason: 'not-a-file',
|
||||
});
|
||||
expect(await readReleaseNoteFile(pathOf('2026-11-20.md'))).toEqual({
|
||||
ok: false,
|
||||
reason: 'unreadable',
|
||||
});
|
||||
});
|
||||
|
||||
it('크기 한도를 넘으면 연 파일의 크기(fstat)로 too-large — 한도 그대로는 읽는다', async () => {
|
||||
writeFileSync(
|
||||
pathOf('large.md'),
|
||||
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES + 1, 0x61),
|
||||
);
|
||||
writeFileSync(
|
||||
pathOf('limit.md'),
|
||||
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES, 0x61),
|
||||
);
|
||||
|
||||
expect(await readReleaseNoteFile(pathOf('large.md'))).toEqual({
|
||||
ok: false,
|
||||
reason: 'too-large',
|
||||
});
|
||||
const limit = await readReleaseNoteFile(pathOf('limit.md'));
|
||||
expect(limit.ok && limit.bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES);
|
||||
});
|
||||
|
||||
it('readUpTo 는 정한 바이트까지만 읽는다 — 그사이 커진 파일도 끝까지 읽지 않는다', async () => {
|
||||
writeFileSync(
|
||||
pathOf('grown.md'),
|
||||
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES * 2, 0x61),
|
||||
);
|
||||
const handle = await open(pathOf('grown.md'), 'r');
|
||||
try {
|
||||
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
|
||||
expect(bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES + 1);
|
||||
expect((await readUpTo(handle, 10)).toString()).toBe('a'.repeat(10));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { constants } from 'fs';
|
||||
import { lstat, open, readdir, type FileHandle } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import {
|
||||
classifyReleaseNoteFile,
|
||||
compareReleaseNotes,
|
||||
describeReleaseNoteProblem,
|
||||
parseReleaseNoteText,
|
||||
type ReleaseNoteProblem,
|
||||
type ReleaseNoteProblemReason,
|
||||
} from './release-note-format';
|
||||
import {
|
||||
RELEASE_NOTE_FILE_MAX_BYTES,
|
||||
RELEASE_NOTES_DIR,
|
||||
} from './release-notes.constants';
|
||||
import type { ReleaseNote } from './release-notes.types';
|
||||
|
||||
/** 건너뛴 파일의 문제 하나 — 파일 이름 · 줄 번호(파일 전체면 null) · 까닭. 글 내용은 없다 */
|
||||
export interface ReleaseNoteFileProblem {
|
||||
readonly file: string;
|
||||
readonly line: number | null;
|
||||
readonly reason: ReleaseNoteProblemReason;
|
||||
}
|
||||
|
||||
/** 폴더를 읽은 결과 — 글(최근 글부터)과 건너뛴 파일의 문제(파일 이름 차례) */
|
||||
export interface ReleaseNotesSnapshot {
|
||||
readonly notes: readonly ReleaseNote[];
|
||||
readonly problems: readonly ReleaseNoteFileProblem[];
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ReleaseNotesSnapshot = Object.freeze({
|
||||
notes: Object.freeze([]),
|
||||
problems: Object.freeze([]),
|
||||
});
|
||||
|
||||
/** 폴더에서 본 이름 하나 — 이름 · 보통 파일인지 · 크기 · 바뀐 시각(ms) */
|
||||
interface FolderEntry {
|
||||
readonly name: string;
|
||||
readonly isFile: boolean;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
}
|
||||
|
||||
/** 이름 하나를 읽은 결과 — 글 · 안내(README — 건너뜀) · 문제 */
|
||||
type EntryOutcome =
|
||||
| { readonly kind: 'note'; readonly note: ReleaseNote }
|
||||
| { readonly kind: 'skipped' }
|
||||
| {
|
||||
readonly kind: 'problems';
|
||||
readonly problems: readonly ReleaseNoteProblem[];
|
||||
};
|
||||
|
||||
interface CachedEntry {
|
||||
readonly entry: FolderEntry;
|
||||
readonly outcome: EntryOutcome;
|
||||
}
|
||||
|
||||
/** UTF-8 만 — 틀린 바이트가 있으면 던진다(EUC-KR 로 저장한 글을 깨진 글로 내보내지 않게). 앞의 BOM 은 지운다 */
|
||||
const UTF8 = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
/**
|
||||
* 글 파일 열기 — 링크를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
|
||||
* 일반 파일인지 · 크기는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0) — modules/app-releases/apk-file.ts 와 같다
|
||||
*/
|
||||
const OPEN_FLAGS =
|
||||
constants.O_RDONLY |
|
||||
(constants.O_NOFOLLOW ?? 0) |
|
||||
(constants.O_NONBLOCK ?? 0);
|
||||
|
||||
function errorCodeOf(error: unknown): string | undefined {
|
||||
return (error as NodeJS.ErrnoException | null)?.code;
|
||||
}
|
||||
|
||||
function reasonOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 글 파일 하나를 읽은 결과 — 바이트, 또는 글로 읽지 않는 까닭 */
|
||||
export type ReleaseNoteFileRead =
|
||||
| { readonly ok: true; readonly bytes: Buffer }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason: 'not-a-file' | 'too-large' | 'unreadable';
|
||||
};
|
||||
|
||||
/** 연 파일의 처음부터 `limit` 바이트까지만 읽는다 — 더 길어도 거기서 멈춘다(넘었는지는 부르는 쪽이 길이로 본다) */
|
||||
export async function readUpTo(
|
||||
handle: FileHandle,
|
||||
limit: number,
|
||||
): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(limit);
|
||||
let filled = 0;
|
||||
while (filled < limit) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
filled,
|
||||
limit - filled,
|
||||
filled,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
filled += bytesRead;
|
||||
}
|
||||
return buffer.subarray(0, filled);
|
||||
}
|
||||
|
||||
/** 열지 못한 까닭 — 링크(ELOOP) · 폴더 · 특수 파일은 글이 아니고, 그 밖(권한 · 그사이 지워짐 등)은 읽지 못함 */
|
||||
function openFailureReason(error: unknown): 'not-a-file' | 'unreadable' {
|
||||
switch (errorCodeOf(error)) {
|
||||
case 'ELOOP':
|
||||
case 'EISDIR':
|
||||
case 'ENXIO':
|
||||
return 'not-a-file';
|
||||
default:
|
||||
return 'unreadable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글 파일을 한 번 열어(OPEN_FLAGS) 그 열린 파일로만 본다 — 일반 파일인지 · 크기(fstat), 그리고 크기 한도 + 1 바이트까지만 읽는다.
|
||||
* 이름을 본(lstat) 뒤 같은 경로가 링크로 바뀌어도 따라가지 않고, 그사이 커진 파일도 끝까지 읽지 않는다
|
||||
*/
|
||||
export async function readReleaseNoteFile(
|
||||
path: string,
|
||||
): Promise<ReleaseNoteFileRead> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(path, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: openFailureReason(error) };
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) return { ok: false, reason: 'not-a-file' };
|
||||
if (stats.size > RELEASE_NOTE_FILE_MAX_BYTES) {
|
||||
return { ok: false, reason: 'too-large' };
|
||||
}
|
||||
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
|
||||
if (bytes.length > RELEASE_NOTE_FILE_MAX_BYTES) {
|
||||
return { ok: false, reason: 'too-large' };
|
||||
}
|
||||
return { ok: true, bytes };
|
||||
} catch {
|
||||
return { ok: false, reason: 'unreadable' };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function problemsOf(reason: ReleaseNoteProblemReason): EntryOutcome {
|
||||
return { kind: 'problems', problems: [{ line: null, reason }] };
|
||||
}
|
||||
|
||||
function sameEntry(a: FolderEntry, b: FolderEntry): boolean {
|
||||
return a.isFile === b.isFile && a.size === b.size && a.mtimeMs === b.mtimeMs;
|
||||
}
|
||||
|
||||
/** 읽어 둔 결과로 글 목록 · 문제를 만든다 — 캐시는 파일 이름 차례로 넣었다 */
|
||||
function snapshotOf(
|
||||
cache: ReadonlyMap<string, CachedEntry>,
|
||||
): ReleaseNotesSnapshot {
|
||||
const notes: ReleaseNote[] = [];
|
||||
const problems: ReleaseNoteFileProblem[] = [];
|
||||
for (const [file, { outcome }] of cache) {
|
||||
if (outcome.kind === 'note') notes.push(outcome.note);
|
||||
if (outcome.kind === 'problems') {
|
||||
for (const { line, reason } of outcome.problems) {
|
||||
problems.push({ file, line, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.sort(compareReleaseNotes);
|
||||
return { notes, problems };
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 내역 글 폴더 (계약 7절 "읽기") — 요청 때 폴더를 본다: 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다
|
||||
* (폴더가 통째로 그대로면 앞의 결과 그대로 — 파일 수십 개라 이름 보기 · lstat 비용은 작다). 개발 서버는 다시 띄우지 않아도 새 글이 보이고,
|
||||
* 운영은 이미지 안의 폴더라 배포 때 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
|
||||
*
|
||||
* 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 로그 한 번(파일 이름 · 줄 번호 · 까닭 — 글 내용은 싣지 않는다).
|
||||
* 기동은 막지 않는다 — 기동 때 한 번 읽어 틀린 글의 로그가 배포 직후에 보인다. 폴더가 없으면 빈 목록 + warn 한 번.
|
||||
* 시험(CI)은 실제 폴더를 읽어 문제가 하나도 없어야 한다(release-notes.content.spec.ts).
|
||||
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 시험은 임시 폴더를 넘긴다
|
||||
*/
|
||||
export class ReleaseNotesFolder implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(ReleaseNotesFolder.name);
|
||||
private cache = new Map<string, CachedEntry>();
|
||||
/** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */
|
||||
private signature: string | null = null;
|
||||
private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT;
|
||||
private loading: Promise<ReleaseNotesSnapshot> | null = null;
|
||||
private warnedMissing = false;
|
||||
|
||||
constructor(readonly dir: string = RELEASE_NOTES_DIR) {}
|
||||
|
||||
/**
|
||||
* 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게.
|
||||
* 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다)
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.read();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */
|
||||
read(): Promise<ReleaseNotesSnapshot> {
|
||||
if (this.loading !== null) return this.loading;
|
||||
const loading = this.load().finally(() => {
|
||||
this.loading = null;
|
||||
});
|
||||
this.loading = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
private async load(): Promise<ReleaseNotesSnapshot> {
|
||||
const entries = await this.listEntries();
|
||||
if (entries === null) return this.folderMissing();
|
||||
this.warnedMissing = false;
|
||||
|
||||
const signature = JSON.stringify(entries);
|
||||
if (signature === this.signature) return this.snapshot;
|
||||
|
||||
const next = new Map<string, CachedEntry>();
|
||||
for (const entry of entries) {
|
||||
const cached = this.cache.get(entry.name);
|
||||
next.set(
|
||||
entry.name,
|
||||
cached !== undefined && sameEntry(cached.entry, entry)
|
||||
? cached
|
||||
: { entry, outcome: await this.readEntry(entry) },
|
||||
);
|
||||
}
|
||||
this.cache = next;
|
||||
this.signature = signature;
|
||||
this.snapshot = snapshotOf(next);
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
/** 폴더의 이름들(이름 차례) · 크기 · 바뀐 시각 — 폴더가 없으면 null. 링크는 따라가지 않는다(lstat) */
|
||||
private async listEntries(): Promise<FolderEntry[] | null> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
const code = errorCodeOf(error);
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
|
||||
throw error;
|
||||
}
|
||||
names.sort();
|
||||
const entries: FolderEntry[] = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const stats = await lstat(join(this.dir, name));
|
||||
entries.push({
|
||||
name,
|
||||
isFile: stats.isFile(),
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
// 이름을 본 뒤 지워졌다 — 없는 것으로
|
||||
if (errorCodeOf(error) === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 바뀐(또는 처음 본) 이름 하나를 읽는다 — 문제가 있으면 여기서 한 번 error 로그 */
|
||||
private async readEntry(entry: FolderEntry): Promise<EntryOutcome> {
|
||||
const outcome = await this.readOutcome(entry);
|
||||
if (outcome.kind === 'problems') {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 업데이트 내역 글을 건너뜁니다 — 글 파일을 고쳐 주세요: file=${JSON.stringify(entry.name)}, problems=${outcome.problems.map(describeReleaseNoteProblem).join(' · ')}`,
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async readOutcome(entry: FolderEntry): Promise<EntryOutcome> {
|
||||
const kind = classifyReleaseNoteFile(entry.name);
|
||||
if (kind.kind === 'readme') return { kind: 'skipped' };
|
||||
if (!entry.isFile) return problemsOf('not-a-file');
|
||||
if (kind.kind === 'not-a-note') return problemsOf(kind.reason);
|
||||
if (entry.size > RELEASE_NOTE_FILE_MAX_BYTES)
|
||||
return problemsOf('too-large');
|
||||
|
||||
// 이름을 본 뒤 링크로 바뀌었거나 커졌을 수 있다 — 연 파일로 다시 본다
|
||||
const read = await readReleaseNoteFile(join(this.dir, entry.name));
|
||||
if (!read.ok) return problemsOf(read.reason);
|
||||
let text: string;
|
||||
try {
|
||||
text = UTF8.decode(read.bytes);
|
||||
} catch {
|
||||
return problemsOf('encoding');
|
||||
}
|
||||
|
||||
const parsed = parseReleaseNoteText(text);
|
||||
if (!parsed.ok) return { kind: 'problems', problems: parsed.problems };
|
||||
return {
|
||||
kind: 'note',
|
||||
note: {
|
||||
id: kind.id,
|
||||
date: kind.date,
|
||||
sequence: kind.sequence,
|
||||
title: parsed.title,
|
||||
sections: parsed.sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 폴더가 없다 — 빈 목록, warn 은 없어질 때마다 한 번 */
|
||||
private folderMissing(): ReleaseNotesSnapshot {
|
||||
if (!this.warnedMissing) {
|
||||
this.warnedMissing = true;
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 업데이트 내역 폴더가 없어 빈 목록으로 답합니다: dir=${this.dir}`,
|
||||
);
|
||||
}
|
||||
this.cache = new Map();
|
||||
this.signature = null;
|
||||
this.snapshot = EMPTY_SNAPSHOT;
|
||||
return EMPTY_SNAPSHOT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* 업데이트 내역 (계약 7절 "업데이트 내역") — 한도 · 형식 · 자리를 한 곳에 모은다.
|
||||
* 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판이고, 글은 운영자가 저장소의 글 파일로 쓴다(올리는 API 는 없다).
|
||||
* 글자 수는 앞뒤 빈칸을 지운 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다.
|
||||
*/
|
||||
|
||||
/** 라우트 경로 (`/api/release-notes`) */
|
||||
export const RELEASE_NOTES_ROUTE = 'release-notes';
|
||||
|
||||
/**
|
||||
* 글 폴더 — 저장소의 `backend/release-notes/`(src 밖). `__dirname` 기준이라 ts(src/modules/release-notes)와
|
||||
* 빌드 산출물(dist/modules/release-notes) 양쪽에서 같은 곳이다 — 운영 이미지는 `/app/release-notes`(backend/Dockerfile 의 COPY),
|
||||
* 개발 컨테이너는 backend 를 `/app` 에 바인드 마운트하므로 파일을 더하면 다시 띄우지 않아도 보인다
|
||||
*/
|
||||
export const RELEASE_NOTES_DIR = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'release-notes',
|
||||
);
|
||||
|
||||
/** 쓰는 사람용 안내 — 글이 아니라 건너뛴다(로그도 남기지 않는다) */
|
||||
export const RELEASE_NOTES_README = 'README.md';
|
||||
|
||||
/** 글 파일의 확장자 — 이름은 `YYYY-MM-DD.md` · 같은 날 둘째부터 `YYYY-MM-DD-2.md` … (이름 = 글 id) */
|
||||
export const RELEASE_NOTE_FILE_EXTENSION = '.md';
|
||||
|
||||
/**
|
||||
* 글 id — 글 파일 이름에서 확장자를 뗀 것. 같은 날 둘째부터 `-2` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이).
|
||||
* 경로의 id 도 이 모양(과 길이)만 본다 — 파일 경로로 쓰지 않고 읽어 둔 목록에서 찾는다
|
||||
*/
|
||||
export const RELEASE_NOTE_ID_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:-([2-9]|[1-9]\d))?$/;
|
||||
/** 경로 id 의 길이 한도 — 모양을 보기 전에 본다 */
|
||||
export const RELEASE_NOTE_ID_MAX_LENGTH = 32;
|
||||
/** 번호 없는 글(그날 첫 글)의 차례 번호 */
|
||||
export const RELEASE_NOTE_FIRST_SEQUENCE = 1;
|
||||
|
||||
/** 길이 한도 — 제목 100자 · 묶음 머리 40자 · 한 줄 500자 (이어 붙인 뒤) */
|
||||
export const RELEASE_NOTE_TITLE_MAX_LENGTH = 100;
|
||||
export const RELEASE_NOTE_HEADING_MAX_LENGTH = 40;
|
||||
export const RELEASE_NOTE_ITEM_MAX_LENGTH = 500;
|
||||
|
||||
/** 글 파일 하나의 크기 한도 — 넘으면 읽지 않고 건너뛴다(글은 몇 KB — 잘못 넣은 큰 파일을 막는다) */
|
||||
export const RELEASE_NOTE_FILE_MAX_BYTES = 256 * 1024;
|
||||
|
||||
/** 글 하나에서 알려 주는 문제 수 — 고치고 다시 보면 나머지가 나온다 (로그 한 줄이 끝없이 길어지지 않게) */
|
||||
export const RELEASE_NOTE_PROBLEMS_MAX = 20;
|
||||
|
||||
/** 목록 커서의 범위 — 거르는 값이 없는 목록 하나 (shared/utils/offset-cursor.ts) */
|
||||
export const RELEASE_NOTES_CURSOR_SCOPE = 'release-notes';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describeReleaseNoteProblem } from './release-note-format';
|
||||
import {
|
||||
RELEASE_NOTES_DIR,
|
||||
RELEASE_NOTES_README,
|
||||
} from './release-notes.constants';
|
||||
import { ReleaseNotesFolder } from './release-notes-folder';
|
||||
|
||||
/**
|
||||
* 실제 글 폴더(backend/release-notes)를 엄격하게 읽는다 — 운영자가 더한 글의 오타를 CI 가 잡는다.
|
||||
* 서버는 틀린 글을 건너뛰고 뜨지만(로그만), 여기서는 틀린 글 · 글이 아닌 파일이 하나라도 있으면 실패한다.
|
||||
* 실패하면 "파일 이름 줄 번호 까닭(설명)"이 보인다 — 글을 고치는 법은 그 폴더의 README.md
|
||||
*/
|
||||
describe('업데이트 내역 글 폴더 (backend/release-notes — 엄격하게)', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('틀린 글 · 글이 아닌 파일이 하나도 없고 글이 하나 이상 있다', async () => {
|
||||
const snapshot = await new ReleaseNotesFolder(RELEASE_NOTES_DIR).read();
|
||||
|
||||
expect(
|
||||
snapshot.problems.map(
|
||||
(problem) => `${problem.file} ${describeReleaseNoteProblem(problem)}`,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(snapshot.notes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('쓰는 사람용 안내(README.md)가 있다', () => {
|
||||
expect(existsSync(join(RELEASE_NOTES_DIR, RELEASE_NOTES_README))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
type PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
|
||||
import {
|
||||
RELEASE_NOTE_ID_MAX_LENGTH,
|
||||
RELEASE_NOTES_ROUTE,
|
||||
} from './release-notes.constants';
|
||||
import { ReleaseNotesService } from './release-notes.service';
|
||||
import type {
|
||||
ReleaseNoteDetail,
|
||||
ReleaseNoteSummary,
|
||||
} from './release-notes.types';
|
||||
import { ReleaseNoteDto, ReleaseNotePageDto } from './dto/release-note.dto';
|
||||
|
||||
/**
|
||||
* 업데이트 내역 (계약 7절) — 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판. 읽기만 있다 —
|
||||
* 글은 운영자가 저장소의 `backend/release-notes/` 에 글 파일로 쓴다(올리는 API 는 없다).
|
||||
* 로그인한 직원만(웹 쿠키 · 앱 Bearer — JwtAuthGuard). 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
|
||||
* 요청 수는 기본 한도. 응답은 private, no-store — 글을 고친 배포가 바로 보이게
|
||||
*/
|
||||
@ApiTags('ReleaseNotes')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors()
|
||||
@Controller(RELEASE_NOTES_ROUTE)
|
||||
export class ReleaseNotesController {
|
||||
constructor(private readonly releaseNotes: ReleaseNotesService) {}
|
||||
|
||||
@Get()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '업데이트 내역 목록 (커서 페이지네이션 · total 포함)',
|
||||
description:
|
||||
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). total 은 글 수. ' +
|
||||
'커서는 위치라 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. 형식이 틀린 글 파일은 건너뛴다(목록에 없다). ' +
|
||||
'Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(ReleaseNotePageDto, {
|
||||
description: '업데이트 내역 목록',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'잘못된 커서 · limit 범위 밖(1~100) · 모르는 쿼리',
|
||||
)
|
||||
list(
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ReleaseNoteSummary>> {
|
||||
return this.releaseNotes.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '업데이트 내역 글 하나 (묶음 · 이전 글 · 다음 글)',
|
||||
description:
|
||||
'previous 는 바로 더 옛 글(목록에서 이 글 아래), next 는 바로 더 새 글(목록에서 이 글 위) — 없으면 null. ' +
|
||||
'글은 서버가 다듬은 글 그대로다 — 마크다운 · HTML 로 해석하지 않고 글로만 그린다. ' +
|
||||
'없는 글 · 모양이 틀린 id 모두 404 RES_001(400 이 아니다 — 손으로 고친 주소도 "없는 글"). Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: `글 id — 글 파일 이름(\`YYYY-MM-DD\` · 같은 날 두 번째 글부터 \`YYYY-MM-DD-2\` …, ${RELEASE_NOTE_ID_MAX_LENGTH}자까지)`,
|
||||
example: '2026-11-18',
|
||||
})
|
||||
@ApiStandardResponse(ReleaseNoteDto, { description: '업데이트 내역 글' })
|
||||
@ApiErrorResponse('RES_001', '없는 글 (모양이 틀린 id 포함)')
|
||||
get(@Param('id', ReleaseNoteIdPipe) id: string): Promise<ReleaseNoteDetail> {
|
||||
return this.releaseNotes.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ReleaseNotesController } from './release-notes.controller';
|
||||
import { ReleaseNotesFolder } from './release-notes-folder';
|
||||
import { ReleaseNotesService } from './release-notes.service';
|
||||
|
||||
/**
|
||||
* 업데이트 내역 모듈 (계약 7절 — `GET /api/release-notes` · `/api/release-notes/{id}`) — 읽기만.
|
||||
* 글은 저장소의 `backend/release-notes/`(글 파일 하나가 글 하나 — release-notes.constants.ts 의 RELEASE_NOTES_DIR).
|
||||
* 폴더는 useFactory 로 만든다 — 시험은 ReleaseNotesFolder 를 임시 폴더로 바꿔 끼운다(env 를 따로 두지 않는다).
|
||||
* 이 모듈은 다른 기능 모듈을 가져오지 않는다 (DB 표 없음)
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ReleaseNotesController],
|
||||
providers: [
|
||||
{
|
||||
provide: ReleaseNotesFolder,
|
||||
useFactory: () => new ReleaseNotesFolder(),
|
||||
},
|
||||
ReleaseNotesService,
|
||||
],
|
||||
})
|
||||
export class ReleaseNotesModule {}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
|
||||
import { ReleaseNotesFolder } from './release-notes-folder';
|
||||
import { ReleaseNotesService } from './release-notes.service';
|
||||
|
||||
/** 목록 쿼리 — 전역 파이프가 만든 것처럼(limit 기본 20) */
|
||||
function queryOf(
|
||||
values: Partial<Pick<CursorPaginationQueryDto, 'cursor' | 'limit'>> = {},
|
||||
): CursorPaginationQueryDto {
|
||||
return Object.assign(new CursorPaginationQueryDto(), values);
|
||||
}
|
||||
|
||||
/** 던진 업무 오류의 코드 */
|
||||
async function codeOf(work: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await work;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error.code;
|
||||
throw error;
|
||||
}
|
||||
throw new Error('던지지 않았다');
|
||||
}
|
||||
|
||||
describe('ReleaseNotesService — 목록 · 글 (실제 폴더를 읽는다)', () => {
|
||||
let dir: string;
|
||||
let service: ReleaseNotesService;
|
||||
|
||||
const write = (name: string, text: string) =>
|
||||
writeFileSync(join(dir, name), text);
|
||||
/** 제목 · 묶음 하나의 글 */
|
||||
const note = (title: string) =>
|
||||
`# ${title}\n\n## 묶음\n- ${title}의 첫 줄\n- 둘째 줄\n`;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
service = new ReleaseNotesService(new ReleaseNotesFolder(dir));
|
||||
// 파일을 만든 차례와 상관없이 날짜 · 번호로 줄 세운다
|
||||
write('2026-11-05.md', note('11월 5일 글'));
|
||||
write('2026-11-18-2.md', note('18일 두 번째 글'));
|
||||
write('2026-11-03.md', note('11월 3일 글'));
|
||||
write('2026-11-18.md', note('18일 첫 글'));
|
||||
write('2026-11-18-10.md', note('18일 열 번째 글'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('목록은 최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저, 칸은 id · date · title 만, total 은 글 수', async () => {
|
||||
const page = await service.list(queryOf());
|
||||
|
||||
expect(page).toEqual({
|
||||
items: [
|
||||
{ id: '2026-11-18-10', date: '2026-11-18', title: '18일 열 번째 글' },
|
||||
{ id: '2026-11-18-2', date: '2026-11-18', title: '18일 두 번째 글' },
|
||||
{ id: '2026-11-18', date: '2026-11-18', title: '18일 첫 글' },
|
||||
{ id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
|
||||
{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' },
|
||||
],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('쪽 나누기 — nextCursor 로 끝까지 받고, 마지막 쪽은 hasNext false · nextCursor null', async () => {
|
||||
const first = await service.list(queryOf({ limit: 2 }));
|
||||
expect(first.items.map((item) => item.id)).toEqual([
|
||||
'2026-11-18-10',
|
||||
'2026-11-18-2',
|
||||
]);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
|
||||
expect(typeof first.pageInfo.nextCursor).toBe('string');
|
||||
|
||||
const second = await service.list(
|
||||
queryOf({ limit: 2, cursor: first.pageInfo.nextCursor ?? undefined }),
|
||||
);
|
||||
expect(second.items.map((item) => item.id)).toEqual([
|
||||
'2026-11-18',
|
||||
'2026-11-05',
|
||||
]);
|
||||
|
||||
const last = await service.list(
|
||||
queryOf({ limit: 2, cursor: second.pageInfo.nextCursor ?? undefined }),
|
||||
);
|
||||
expect(last).toEqual({
|
||||
items: [{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('틀린 커서 · 다른 목록의 커서는 폴더를 읽기 전에 400 VAL_001', async () => {
|
||||
for (const cursor of [
|
||||
'not-a-cursor',
|
||||
'x'.repeat(300),
|
||||
encodeOffsetCursor(2, 'contacts'),
|
||||
]) {
|
||||
expect(await codeOf(service.list(queryOf({ cursor })))).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('글 — 묶음과 previous(바로 더 옛 글) · next(바로 더 새 글), 칸은 계약의 것만(차례 번호 없음)', async () => {
|
||||
const detail = await service.get('2026-11-18');
|
||||
|
||||
expect(detail).toEqual({
|
||||
id: '2026-11-18',
|
||||
date: '2026-11-18',
|
||||
title: '18일 첫 글',
|
||||
sections: [{ heading: '묶음', items: ['18일 첫 글의 첫 줄', '둘째 줄'] }],
|
||||
previous: { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
|
||||
next: {
|
||||
id: '2026-11-18-2',
|
||||
date: '2026-11-18',
|
||||
title: '18일 두 번째 글',
|
||||
},
|
||||
});
|
||||
expect(Object.keys(detail)).toEqual([
|
||||
'id',
|
||||
'date',
|
||||
'title',
|
||||
'sections',
|
||||
'previous',
|
||||
'next',
|
||||
]);
|
||||
});
|
||||
|
||||
it('맨 위 글은 next 가 null, 맨 아래 글은 previous 가 null', async () => {
|
||||
expect(await service.get('2026-11-18-10')).toMatchObject({
|
||||
previous: { id: '2026-11-18-2' },
|
||||
next: null,
|
||||
});
|
||||
expect(await service.get('2026-11-03')).toMatchObject({
|
||||
previous: null,
|
||||
next: { id: '2026-11-05' },
|
||||
});
|
||||
});
|
||||
|
||||
it('없는 글은 404 RES_001', async () => {
|
||||
for (const id of ['2026-11-19', '2026-11-18-3', '2026-02-30']) {
|
||||
expect(await codeOf(service.get(id))).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('틀린 글은 목록 · 글에 없다 — 앞뒤 글은 맞는 글끼리 잇는다', async () => {
|
||||
write('2026-11-10.md', '# 제목만 있는 글\n');
|
||||
|
||||
const page = await service.list(queryOf());
|
||||
expect(page.items.map((item) => item.id)).not.toContain('2026-11-10');
|
||||
expect(page.pageInfo.total).toBe(5);
|
||||
expect(await codeOf(service.get('2026-11-10'))).toBe('RES_001');
|
||||
expect(await service.get('2026-11-18')).toMatchObject({
|
||||
previous: { id: '2026-11-05' },
|
||||
});
|
||||
});
|
||||
|
||||
it('글 파일을 더하면 다시 띄우지 않아도 다음 요청이 본다', async () => {
|
||||
expect((await service.list(queryOf())).pageInfo.total).toBe(5);
|
||||
|
||||
write('2026-11-25.md', note('새 글'));
|
||||
|
||||
const page = await service.list(queryOf({ limit: 1 }));
|
||||
expect(page.items).toEqual([
|
||||
{ id: '2026-11-25', date: '2026-11-25', title: '새 글' },
|
||||
]);
|
||||
expect(page.pageInfo.total).toBe(6);
|
||||
});
|
||||
|
||||
it('폴더가 없으면 빈 목록 — 글은 404 RES_001', async () => {
|
||||
const empty = new ReleaseNotesService(
|
||||
new ReleaseNotesFolder(join(dir, 'none')),
|
||||
);
|
||||
|
||||
expect(await empty.list(queryOf())).toEqual({
|
||||
items: [],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
||||
});
|
||||
expect(await codeOf(empty.get('2026-11-18'))).toBe('RES_001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type CursorPaginationQueryDto,
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import { RELEASE_NOTES_CURSOR_SCOPE } from './release-notes.constants';
|
||||
import { ReleaseNotesFolder } from './release-notes-folder';
|
||||
import type {
|
||||
ReleaseNote,
|
||||
ReleaseNoteDetail,
|
||||
ReleaseNoteSummary,
|
||||
} from './release-notes.types';
|
||||
|
||||
/** 응답에 싣는 칸만 — 읽어 둔 글의 차례 번호는 싣지 않는다 */
|
||||
function summaryOf(note: ReleaseNote): ReleaseNoteSummary {
|
||||
return { id: note.id, date: note.date, title: note.title };
|
||||
}
|
||||
|
||||
/** 커서의 위치 — 없으면 0, 틀리면 400 VAL_001 (폴더를 읽기 전에 본다) */
|
||||
function cursorOffset(cursor: string | undefined): number {
|
||||
if (cursor === undefined) return 0;
|
||||
const reading = decodeOffsetCursor(cursor, RELEASE_NOTES_CURSOR_SCOPE);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-release-note-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
return reading.offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 내역 (계약 7절) — 글 폴더(ReleaseNotesFolder)를 읽어 목록 · 글을 준다. 올리고 고치는 길은 없다(운영자가 글 파일을 더해 배포).
|
||||
* 차례는 최근 글부터(날짜 내림차순, 같은 날은 뒤 번호가 먼저) — previous 는 바로 더 옛 글(목록의 아래), next 는 바로 더 새 글(목록의 위).
|
||||
* 커서는 위치(offset-cursor) — 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(주소록과 같다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReleaseNotesService {
|
||||
constructor(private readonly folder: ReleaseNotesFolder) {}
|
||||
|
||||
/** 목록 한 쪽 — total 은 글 수 */
|
||||
async list(
|
||||
query: CursorPaginationQueryDto,
|
||||
): Promise<PaginatedResult<ReleaseNoteSummary>> {
|
||||
const start = cursorOffset(query.cursor);
|
||||
const { notes } = await this.folder.read();
|
||||
const page = notes.slice(start, start + query.limit).map(summaryOf);
|
||||
const next = start + page.length;
|
||||
const hasNext = next < notes.length;
|
||||
return toPaginatedResult(page, {
|
||||
hasNext,
|
||||
nextCursor: hasNext
|
||||
? encodeOffsetCursor(next, RELEASE_NOTES_CURSOR_SCOPE)
|
||||
: null,
|
||||
total: notes.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** 글 하나와 앞뒤 글 — 없으면 404 RES_001 (id 의 모양은 파이프가 보았다 — 여기서는 읽어 둔 목록에서만 찾는다) */
|
||||
async get(id: string): Promise<ReleaseNoteDetail> {
|
||||
const { notes } = await this.folder.read();
|
||||
const index = notes.findIndex((note) => note.id === id);
|
||||
if (index < 0) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'release-note-not-found', id },
|
||||
});
|
||||
}
|
||||
const note = notes[index];
|
||||
return {
|
||||
id: note.id,
|
||||
date: note.date,
|
||||
title: note.title,
|
||||
sections: note.sections.map((section) => ({
|
||||
heading: section.heading,
|
||||
items: [...section.items],
|
||||
})),
|
||||
previous: index + 1 < notes.length ? summaryOf(notes[index + 1]) : null,
|
||||
next: index > 0 ? summaryOf(notes[index - 1]) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 업데이트 내역의 모양 (계약 7절) — 응답과 읽어 둔 글이 함께 쓴다.
|
||||
* `date` 는 달력 날짜 글(`YYYY-MM-DD` — 파일 이름에서)이고 시각이 아니다. 화면은 시간대를 바꾸지 않고 그대로 쓴다
|
||||
*/
|
||||
|
||||
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
|
||||
export interface ReleaseNoteSummary {
|
||||
readonly id: string;
|
||||
readonly date: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/** 묶음 — 머리(`## 머리`, 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`, 하나 이상) */
|
||||
export interface ReleaseNoteSection {
|
||||
readonly heading: string | null;
|
||||
readonly items: readonly string[];
|
||||
}
|
||||
|
||||
/** 글 하나 — 응답 (previous = 바로 더 옛 글, next = 바로 더 새 글 — 없으면 null) */
|
||||
export interface ReleaseNoteDetail extends ReleaseNoteSummary {
|
||||
readonly sections: readonly ReleaseNoteSection[];
|
||||
readonly previous: ReleaseNoteSummary | null;
|
||||
readonly next: ReleaseNoteSummary | null;
|
||||
}
|
||||
|
||||
/** 읽어 둔 글 — 차례 번호(`sequence` — 같은 날 둘째부터 2 …, 번호 없는 글은 1)는 줄 세우기에만 쓰고 응답에 싣지 않는다 */
|
||||
export interface ReleaseNote extends ReleaseNoteSummary {
|
||||
readonly sequence: number;
|
||||
readonly sections: readonly ReleaseNoteSection[];
|
||||
}
|
||||
+1608
-19
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@
|
||||
- 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다.
|
||||
- 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다.
|
||||
- 화면 낭독기 이름은 "앱 메뉴".
|
||||
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
|
||||
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
|
||||
업데이트 내역(2026-11-10 — 아래 「업데이트 내역」)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다.
|
||||
[새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림).
|
||||
[로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다.
|
||||
|
||||
@@ -81,6 +82,8 @@
|
||||
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
|
||||
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
|
||||
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
|
||||
**본문에 보이는 것이 없으면 본문 칸을 두지 않고** 머리 전체(안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다 — 빈 본문이라는 글은 따로 두지 않는다(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
보이는 것은 공백 · 보이지 않는 글자(nbsp · 제로폭 · 방향 표시 · 한글 채움 문자 등)가 아닌 글자, 주소가 남은 그림(외부 그림은 받기 전에도), 가로줄 · 목록 표시 · 펼침 · 따옴표다 — 꾸밈(style · bgcolor · border)만으로 그린 상자 · 선은 치지 않는다. 보일 본문을 고르는 규칙은 그대로다(서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다 — 웹과 같은 본문).
|
||||
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
|
||||
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
|
||||
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
|
||||
@@ -112,6 +115,7 @@
|
||||
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
|
||||
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
|
||||
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
|
||||
첨부만 있는 메일(본문 칸 없이 마지막 첨부까지 한 스크롤 · 그 메일에서 [이미지 보기]를 누르면 본문 칸이 생기는지),
|
||||
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
|
||||
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
|
||||
|
||||
@@ -174,11 +178,17 @@
|
||||
- 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일).
|
||||
- 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다.
|
||||
- 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12).
|
||||
- 입력칸(받는 사람 · 제목 · 본문)은 테두리 · 바탕 없이 글만 그리고, 선은 칸 줄의 아래 선(초점이면 청록 2)뿐이다 — 보드대로. 검색 막대의 칸도 테두리 없이 글만 그린다.
|
||||
앱 테마의 입력칸 테두리를 물려받지 않게 `borderlessInputDecoration`(`core/theme/app_theme.dart`)을 쓴다(2026-11-12 — `InputDecoration.collapsed` 가 테마의 둥근 테두리를 물려받아 칸마다 글자를 바짝 감싼 테두리가 그려지고, 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
- 칸 줄(2026-11-12 — 보드에 맞춤): 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16, 칸 이름 폭 72 · 글자 15(보드 14) · 칸 이름과 칸 사이 6. 아래 선은 자리를 차지하지 않아 초점으로 굵어져도 칸이 움직이지 않는다.
|
||||
**받는 사람 칩이 여러 줄이면 칸 이름과 [참조 펼치기]는 첫 칩 줄에 맞춘다**(보드는 한 줄만 그렸다 — 줄 전체의 가운데로 내려가지 않게). 칩 ✕ 는 #4A5363, 제목 · 본문의 안내 글은 보드의 placeholder 색 #8A94A3(검색 칸과 같다).
|
||||
본문 칸 최소 높이는 원본 메일이 아래에 있으면(답장 · 전달) 150, 없으면(새 메일 — 서명이 있어도) 240 이다(보드의 「새 메일 · 받는 사람 추천」 240 · 「답장 · 참조 펼침 · 첨부 · 원본 접힘」 150).
|
||||
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
|
||||
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
|
||||
**알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고
|
||||
"임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다).
|
||||
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
|
||||
기기 글자 크기를 키웠을 때 칸 이름(숨은참조 — 폭 72 에서 낱말 가운데 꺾임),
|
||||
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
|
||||
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
|
||||
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
|
||||
@@ -303,5 +313,35 @@
|
||||
- 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것".
|
||||
- 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림").
|
||||
|
||||
### 업데이트 내역 (`ReleaseNotes` B — 2026-11-10 확정, 캔버스 v80)
|
||||
|
||||
직원이 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(목록 → 글)이다. 웹과 같은 글을 보여 준다(서버가 글 파일을 읽어 준다 — 웹 · 앱을 거르지 않는다).
|
||||
보드는 웹 · 앱을 한 장에 그린 `docs/design/webmail/ReleaseNotes.dc.html` 의 B(앱)이고, 웹 화면은 웹 디자인 README 「업데이트 내역」, 글 파일 · API 는 계약 7절이다.
|
||||
|
||||
- **자리**: 설정 탭 › 앱 정보 절의 앱 버전 다음 줄 "업데이트 내역"(52 · 끝에 › — 오픈소스 라이선스 줄과 같은 꼴). 누르면 목록 `/settings/release-notes`, 줄을 누르면 글 `/settings/release-notes/{id}` —
|
||||
둘 다 탭 막대 없는 전체 화면이고 글은 목록 위에 쌓인다(주소로 바로 열어도 아래에 목록이 있다).
|
||||
- **목록**(B2): 위 막대 64(뒤로 · "업데이트 내역" 18 · 아래 선). 줄 68(설정의 두 줄 칸과 같다) — 제목 16 · 날짜 13(회색 · 숫자 고른 폭) · ›, 줄 전체를 누른다. 최근 글부터 20개이고
|
||||
더 있으면 목록 아래 [더 보기](폭 전체 48 · 회색 테두리 — 20개를 넘을 때만). 받는 동안은 그 단추만 바쁨(둥근 진행 표시 18 · 회색 글 · 다시 누르지 못한다),
|
||||
더 받지 못하면 그 코드의 사전 문구로 알리고 받은 줄은 그대로다.
|
||||
- **글**(B3 · B4): 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜 13 · 제목 18 · 묶음(머리 15 · 점 목록 15 / 줄 간격 1.6 · 회색 점 5) 차례이고 길면 화면째 스크롤한다.
|
||||
끝에 다른 글 줄 64 — "다음 글 · 날짜"(더 새 글)가 위, "이전 글 · 날짜"(더 옛 글)가 아래(목록과 같은 차례), 없는 쪽은 줄이 없다. [목록] 단추는 두지 않는다.
|
||||
- **상태**: 목록 — 불러오는 중은 줄 자리의 막대(B7 — 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다), 받지 못함은 그 자리에 사전 문구 · 문의 코드 · [다시 시도](B8 — 알림 없음),
|
||||
글이 없으면 문서 그림 원과 "아직 알려 드릴 업데이트가 없습니다." 한 줄(B9). 글 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로](B5 — 알림 없음),
|
||||
그 밖의 실패는 [다시 시도](B6). 받지 못함 · 없는 글의 제목은 긴 문장이라 15, 글이 없음의 제목은 빈 메일함과 같은 크기(18)다.
|
||||
- **글 규칙**: 서버가 다듬은 글 그대로 그린다(마크다운 해석 · 링크 · 그림 없음 — 대괄호 글도 꾸미지 않는다). 날짜는 달력 날짜 그대로 "2026년 11월 18일"이다(시각으로 바꾸지 않는다 — 시간대와 상관없이 그날).
|
||||
새 글 표시(점 · 개수 · NEW) · 번호 · 작성자 · 조회 수는 두지 않는다.
|
||||
- **구현하며 정한 것**(2026-11-10)
|
||||
- 받지 못함 · 가운데 상태의 틀과 불러오는 중 막대는 메일 목록에 있던 것을 `shared/widgets/load_states.dart`(`StateLayout` · `LoadErrorView` · `LoadingSkeleton` · `SkeletonBar`)로 옮겨 메일과 함께 쓴다 —
|
||||
메일 화면의 모양 · 동작은 그대로다. 목록의 막대 줄은 B7 대로 따로 그렸다(메일 목록의 막대 줄에는 사진 원이 있다).
|
||||
- 글을 받는 동안의 막대(보드에 없음): 날짜 · 제목 · 머리 · 줄 넷 자리를 목록 막대와 같은 결(끝이 둥근 막대)로 잡아 둔다. 화면 낭독기는 "글 불러오는 중"(웹과 같은 글).
|
||||
- 다른 글 줄을 누르면 지금 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). 새 화면이 들어오는 넘김이고 스크롤은 맨 위부터다.
|
||||
- 글이 없을 때의 그림은 Material `description_outlined`(접힌 모서리와 글 줄 — 웹 `file-text` 와 같은 꼴, 보드의 "article_outlined 쯤").
|
||||
- [더 보기]는 받는 동안에도 단추를 그대로 두고 누름만 받지 않는다(초점이 옮겨 가지 않게 — 메일 검색의 [더 찾기]와 같다). 응답의 모양이 틀린 [더 보기](서버 쪽 문제)도 `SYS_001` 로 알린다.
|
||||
- 화면 낭독기: 목록 줄은 "제목, 날짜", 다른 글 줄은 "다음 글, 날짜, 제목"(가운뎃점 대신 쉼표로 쉰다)을 단추 하나로 읽는다. 받는 동안의 [더 보기]는 꺼진 단추 "더 보기, 불러오는 중"이다
|
||||
(앱에서 새로 쓴 글 — 보드는 바쁨 표시만). 위 막대의 뒤로는 "뒤로"다.
|
||||
- 세션이 바뀌면(로그아웃 · 다른 직원) 목록 · 글을 처음부터 받고 앞 세션의 늦은 응답은 버린다. 목록은 화면을 열 때마다 새로 받는다(닫으면 버린다).
|
||||
- **실기기에서 볼 것**: 큰 글자에서 줄 높이 · 점 위치 · [더 보기], TalkBack 차례(위 막대 → 날짜 → 제목 → 머리 → 줄 → 다른 글 줄)와 [더 보기]를 받는 동안 읽기,
|
||||
다른 글로 옮길 때의 넘김 · 뒤로 가기 몸짓, 업데이트 내역 서버(feat/release-notes)와 함께 실제 글(첫 글의 날짜는 배포 날로 고친다).
|
||||
|
||||
### 처음 판에서 미룬 것
|
||||
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,6 +229,24 @@
|
||||
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
|
||||
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
|
||||
|
||||
### 받는 사람이 많을 때 · 낮은 화면 — 스크롤 (2026-11-10 사용자 보고 — 캔버스에는 없다)
|
||||
|
||||
전 직원 공지를 숨은참조로 쓰자 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다
|
||||
(본문 위에서 휠을 돌리면 본문만 움직였다). 모양 · 크기는 그대로 두고 아래처럼 고쳤다.
|
||||
|
||||
- **[보내기] 줄은 늘 보인다** — 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다. 위 선 · 흰 바탕 · 여백은 그대로이고 넘칠 때만 내용 위에 뜬다.
|
||||
[보내기 ▾] · [서명 ▾] 메뉴는 위로 연다. 겹침은 툴바 < [보내기] 줄 < 받는 사람 추천 목록 · 툴바의 메뉴(연 동안)다.
|
||||
- **받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다** — 보이는 높이 96: 맨 위에서는 칩 세 줄, 맨 아래(입력 자리)에서는
|
||||
칩 두 줄 + 입력 줄로 줄 사이에서 끊겨 반쯤 잘린 칩이 보이지 않는다. 초점이 오거나 · 칩이 늘거나 · 글을 치면 맨 아래로 간다.
|
||||
칩의 ✕ 로 지우면 초점은 입력칸으로 가되 칸의 자리는 그대로다(가운데 칩을 이어 지운다). 추천 목록은 칸 밖(줄 아래)에 뜬다.
|
||||
- **넓은 화면(1120 이상)은 본문 편집 영역이 300 에서 200 까지 먼저 줄어든다** — 보통 높이(1366×768 · 1440×900)에서는 숨은참조 30명이어도
|
||||
쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(1366×768 에서 편집 영역 226). 그래도 넘치면(첨부가 많음 · 아주 낮은 창) 쓰기 칸이 스크롤하고
|
||||
[보내기] 줄은 아래에 붙어 있다. 좁은 화면은 300 그대로이고 문서가 스크롤한다.
|
||||
- [보내기] 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다(WCAG 2.4.11 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 가렸다).
|
||||
마우스 · 손가락으로 누른 칸은 그대로다 — 누른 채 내용이 움직이면 글이 골라진다.
|
||||
- 넓은 화면에서 문서까지 168px 스크롤하던 것(화면 낭독 전용 글이 앱 틀 밖에 놓였다)도 고쳤다 — 쓰기 칸이 위치를 잡는다.
|
||||
- 알려진 한계: 휴대폰 폭(360)에서는 [보내기] 줄이 세 줄(보내기 · 임시 저장 / 서명 · 저장 상태 / 보내는 사람 — 161px)로 화면의 1/5 을 덮는다 — 나중에 다듬는다.
|
||||
|
||||
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
|
||||
|
||||
### 임시 저장
|
||||
@@ -302,6 +320,11 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
|
||||
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
|
||||
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
|
||||
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
|
||||
행 어디를 눌러도 메일을 열되 **고르기 칸 · 별 칸은 어디를 눌러도 열지 않는다** — 고르기 칸은 빈자리(단추 밖)를 눌러도 상자를 누른 것과 같이 고르고
|
||||
(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸은 별 단추만 — 별 칸의 빈자리는 아무 일도 없다
|
||||
(2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 고르면서 메일까지 열렸다: 고르자 상자 그림이 다시 그려져 문서에서 떨어진 탓.
|
||||
줄은 사건이 지나간 길로 가르고 상자 · 별 그림은 누름을 받지 않는다 — 대화로 묶은 표도 같다). Ctrl(⌘) · Shift + 클릭은 칸의 빈자리에서도 지금처럼 고른다.
|
||||
분할 보기는 고르기 칸(36)이 줄의 링크 밖이라 이 탈이 없었다(같은 시험으로 확인).
|
||||
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
|
||||
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
|
||||
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
|
||||
@@ -337,7 +360,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 +773,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 +917,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다."
|
||||
그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로.
|
||||
- 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절).
|
||||
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로).
|
||||
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(**16단계부터 이 직원도 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**)(나머지 두 줄은 12단계 그대로).
|
||||
- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다.
|
||||
직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래).
|
||||
|
||||
@@ -1272,6 +1295,234 @@ 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 · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
|
||||
창 안의 칸은 줄어들지 않는다 — 세 묶음 상자(둥근 모서리 때문에 `overflow: hidden`)는 `flex-shrink: 0`. 창(`<dialog>`)이 flex 열이라 overflow 를 건 칸은 내용보다 작게 눌릴 수 있다
|
||||
(2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 상자가 눌려 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다). 내보내기 창도 같다.
|
||||
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 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-11-10 확정 — `ReleaseNotes`)
|
||||
|
||||
직원이 웹메일과 휴대폰 앱에 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더하고, 직원은 목록에서 글을 골라 들어가 읽은 뒤 [목록]이나 뒤로 가기로 돌아온다.
|
||||
글은 운영자가 저장소의 `backend/release-notes/` 에 글마다 파일 하나(`2026-11-18.md` — 이름이 날짜이자 글 주소)로 쓰고, 서버가 웹 · 앱에 같은 글을 준다. 올리는 화면 · API 는 없다(쓰는 법은 보드 C 와 그 폴더의 `README.md`).
|
||||
서버 약속은 계약 7절("업데이트 내역")이다 — 목록 `GET /api/release-notes?cursor=&limit=` → `{ items: [{ id, date, title }], pageInfo }`(최근 글부터 · 20개씩),
|
||||
글 `GET /api/release-notes/{id}` → `{ id, date, title, sections: [{ heading, items }], previous, next }`(없는 글 · 모양이 틀린 id 는 404 `RES_001`). 로그인해야 보이고 제한된 세션은 막힌다.
|
||||
글의 원본은 화면 상수 `frontend/src/shared/constants/release-notes.ts` 이고 아래는 옮겨 적은 것이다. 글 내용(제목 · 묶음 · 줄)은 서버가 다듬은 글 그대로 그린다 — 마크다운 해석 · v-html 없음, 대괄호 글([설정] → [휴대폰 앱])도 꾸미지 않는다.
|
||||
새 크기 토큰 · 밀도 예외 · 새 아이콘은 없다(메뉴 아이콘은 이미 있는 문서 그림 `file-text`). 새 글 표시(점 · 개수 · NEW)와 번호 · 작성자 · 조회 수 칸은 두지 않는다. 앱 화면(보드 B)은 앱 디자인 README 가 적는다.
|
||||
|
||||
### 자리 · 주소
|
||||
|
||||
- 설정 왼쪽 메뉴의 셋째 묶음 「정보」(계정 · 메일 다음) › 「업데이트 내역」. 주소는 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(id 는 글 파일 이름 — `2026-11-18`, 같은 날 둘째 글은 `2026-11-18-2`).
|
||||
- 글을 보는 동안에도 메뉴의 「업데이트 내역」이 고른 상태(청록 바탕)다 — 메일함 패널의 지금 메일함과 같은 결. 화면 낭독은 목록이면 `aria-current="page"`,
|
||||
글이면 링크가 가리키는 곳(목록)과 지금 쪽이 달라 `aria-current="true"` 다. 탭 제목은 목록 · 글 모두 "업데이트 내역 · ARA SYSTEM".
|
||||
- `/settings/info` 는 목록으로 바꿔 끼운다 — 주소창에 글 주소 뒤 `..`(`%2E%2E` 도)를 치면 브라우저가 `/settings/info/` 로 다듬는다. 다른 묶음 주소(`/settings/account` · `/settings/mail`)는 그대로다.
|
||||
- 날짜는 쓴 날짜 그대로 "2026년 11월 18일"이다. 시각이 아니라 달력 날짜라 브라우저 시간대로 바꾸지 않는다(`formatCalendarDate`).
|
||||
|
||||
### 목록 (`ReleaseNotes` A1)
|
||||
|
||||
- 머리 "업데이트 내역" + "웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다." 콘텐츠 열은 다른 설정 화면과 같은 800.
|
||||
- 표 꼴 상자(로그인 기록 표와 같은 테두리 · 모서리 10): 머리 줄(32 · #FBFBFC · 12 회색 굵게) "제목" · "날짜"(136). 머리 줄은 보이는 꼴만이고, 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다.
|
||||
글마다 한 줄(48 이상 · 위아래 13): 제목(14 · 500) · 날짜(12.5 회색 · 숫자 고정폭), 최근 글부터. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸에 둔다.
|
||||
- 줄 전체가 그 글로 가는 링크다 — 마우스를 올리면 옅은 청록 바탕(#F3F8F8), 키보드 초점은 줄 안쪽 2px 청록 테두리.
|
||||
- 처음 20개를 받고, 더 있으면 상자 아래(사이 16) [더 보기](44 · 흰 바탕 테두리)로 20개씩 더 받는다. 받는 동안 그 단추만 바쁨(`aria-busy` · `aria-disabled` · 흐리게)이고,
|
||||
더 받지 못하면(응답의 모양이 틀려도 — `SYS_001`) 알림(사전 문구)으로 알리고 받은 줄 · [더 보기]는 그대로 둔다.
|
||||
- 들어올 때마다 새로 받고, 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다(글에서 돌아와도 — 아래 보완 절 "스크롤 자리"). [더 보기] 뒤에는 맞추지 않는다.
|
||||
|
||||
### 글 (`ReleaseNotes` A2)
|
||||
|
||||
- 위에 [← 목록으로] 줄(52 · 아래 선 — 목록 보기에서 연 메일의 [목록으로]와 같은 꼴, 청록 굵은 글 · 화살표 15). 어떤 상태에서도 위에 남는다.
|
||||
- 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다(아래 보완 절 "스크롤 자리").
|
||||
- 날짜(12.5 · 500 회색) · 제목(h1 18 · 줄 간격 1.4), 사이 4. 그 아래(사이 22) 묶음(묶음 사이 18): 머리(h2 14 굵게) + 점 목록(13 · #344054 · 줄 간격 1.6 · 줄 사이 4 · 회색 점 4).
|
||||
머리가 없는 묶음은 점 목록만이다. 묶음 글은 680 까지.
|
||||
- 글 끝(사이 22)의 다른 글 줄 — 위아래 선 · 680 까지. 줄(48 이상)은 이름(64 · 12.5 회색 굵게) · 제목(14 · 500) · 날짜(12.5 회색)이고 목록 줄처럼 줄 전체를 누른다.
|
||||
「다음 글」(바로 더 새 글)이 위, 「이전 글」(바로 더 옛 글)이 아래(목록과 같은 차례)다. 없는 쪽의 줄은 두지 않는다(글이 하나뿐이면 칸째 없다).
|
||||
- 맨 아래 [목록](44 · 흰 바탕 테두리 · 안쪽 18). 설명 한 줄은 목록 화면에만 둔다.
|
||||
|
||||
### 상태 (`ReleaseNotes` A1 · A2)
|
||||
|
||||
- 목록 · 불러오는 중: 머리 줄은 두고 줄 자리를 막대 넷(제목 막대 54 · 38 · 62 · 46%, 날짜 막대 96)으로 잡아 둔다 — 메일 목록을 불러올 때와 같은 막대. 화면 낭독 "업데이트 내역 불러오는 중".
|
||||
- 목록 · 받지 못함: 상자 자리에 사전 문구 · [다시 시도] · 문의 코드(다른 설정 화면의 `MailLoadError`). 알림은 띄우지 않는다.
|
||||
- 목록 · 글이 없을 때: 상자와 [더 보기] 없이 문서 그림 36 · "아직 알려 드릴 업데이트가 없습니다." 한 줄(`StateMessage`).
|
||||
- 글 · 받는 동안: 글 자리를 막대로 잡아 둔다(날짜 · 제목 · 묶음 자리 — 보드에는 그리지 않았다). 화면 낭독 "글 불러오는 중".
|
||||
- 글 · 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`): 알림 없이 사전 문구("찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.") · [목록으로] · 문의 코드.
|
||||
파일 이름(날짜)을 고치면 글 주소가 바뀌어, 그전 주소로 들어오면 이 화면이다.
|
||||
- 글 · 받지 못함(그 밖의 실패): 사전 문구 · [다시 시도] · 문의 코드. 알림은 띄우지 않는다.
|
||||
|
||||
### 좁은 폭 (`ReleaseNotes` A3)
|
||||
|
||||
- 화면 폭이 840보다 좁으면(레일 72 · 메뉴 208 · 글 칸 560 이 한 줄에 들지 않으면) 메뉴가 위로 올라가고 목록 · 글은 그 아래로 내려간다 — 지금 다른 설정 화면과 같다(휴대폰 브라우저에서는 메뉴 아래로 내려야 글이 나온다).
|
||||
- **목록 칸이 480보다 좁으면**(화면 폭이 아니라 그 칸의 폭) 머리 줄을 감추고 날짜를 제목 아래로 내린다(사이 2 · 줄 안쪽 12 16) — 이것만 새로 정한 것이다.
|
||||
글 화면은 넓은 화면과 같은 차례 · 글자 크기로 한 칸에 흐른다(좌우 여백 32 그대로 — 폭 360 에서 글 폭 224).
|
||||
|
||||
### 글 규칙 (보드 C 요약 — 쓰는 법의 원본은 `backend/release-notes/README.md`)
|
||||
|
||||
- 글 하나가 파일 하나다(`YYYY-MM-DD.md`, 같은 날 둘째부터 `YYYY-MM-DD-2.md`). 첫 줄 `# 제목`(꼭) · `## 묶음 머리` · `- 한 줄`, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙고,
|
||||
`<!-- 메모 -->` 는 화면에 나오지 않는다. 머리 없이 바로 `- ` 를 쓰면 머리 없는 묶음이다.
|
||||
- 길이는 제목 100자 · 묶음 머리 40자 · 한 줄 500자. 굵게 · 링크 · 그림은 없다 — 단추 · 메뉴 이름은 화면 글처럼 `[설정] → [휴대폰 앱]`.
|
||||
- 글도 위 「화면 문구 — 사용자에게 말하듯」을 따른다(직원이 읽는 글이다 — 정중하고 부드럽게, 만든 쪽 말 없이). 웹과 앱이 같은 글을 보여 주니 어디 이야기인지는 묶음 머리로 가른다.
|
||||
|
||||
### 처음 판에서 미룬 것
|
||||
|
||||
새 글 알림(안 읽은 표시 · 한 번 뜨는 안내 줄 — 뱃지를 쓰지 않는 원칙이라 배포 때 직원 공지 메일에 [설정] → [업데이트 내역]을 적는다), 글 안의 링크 · 그림 · 굵게, 웹 · 앱 거르기, 검색.
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -1754,3 +2005,112 @@ 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 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
|
||||
한글을 조합하는 중에는 미뤘다가 조합이 끝나면(화살표 · 다른 칸 누르기 등 — 다른 입력이 없어도) 넣고(2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 돌아왔다),
|
||||
보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. 조합 중에 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다(저장한 본문과 같다).
|
||||
알려진 한계: 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 로 저장한다).
|
||||
|
||||
### 업데이트 내역 구현하며 정한 보완 (2026-11-10 — 캔버스에는 아직 없다)
|
||||
|
||||
데이터 · 경로는 위 "업데이트 내역" 머리말(계약 7절)과 같다.
|
||||
|
||||
- **기록**: 다른 글 줄은 기록을 바꿔 끼운다 — 몇 글을 넘겨 봐도 뒤로 가기는 목록으로 간다(메일 읽기 화면의 이전 · 다음과 같다).
|
||||
[← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(메일의 [목록으로]와 같다). 링크라 새 탭으로도 열 수 있다.
|
||||
- **[← 목록으로] 줄은 스크롤해도 위에 남는다**(읽기 화면의 도구 줄과 같다 — 긴 글을 읽다가도 바로 돌아간다).
|
||||
그 줄 밑에 초점이 가리지 않게(WCAG 2.4.11) 글 칸의 제목 · 링크 · 단추는 줄 높이 + 8(61)만큼 띄워 스크롤한다(`scroll-margin-top`).
|
||||
- **스크롤 자리**(2026-11-10 실사용 시험): 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남아, 목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 빈 글처럼 보였고
|
||||
글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위가, 목록은 받은 뒤 목록의 맨 위가 보이게 맞춘다
|
||||
(`composables/useSettingsPageScroll.ts` — 라우터 전역 `scrollBehavior` 는 메일 등 다른 화면의 뒤로 가기까지 바꿔 쓰지 않는다). 스크롤하는 것이 폭마다 달라 둘 다 맞춘다:
|
||||
넓은 화면(1120 이상)은 글 칸을 맨 위로, 좁은 화면은 문서를 — 맨 위로 올려도 제목이 화면 안이면 맨 위로(메뉴가 옆에 있는 폭), 아니면(위에 메뉴가 긴 휴대폰 · 낮은 창) 글 칸의 위 끝을 화면 위 끝에.
|
||||
문서가 맨 위에 있으면(처음 연 화면) 그대로 둔다 — 다른 설정 화면처럼 휴대폰에서는 메뉴부터 보인다. 없는 글 · 받지 못함도 그 안내가 보이게 같은 규칙으로 맞춘다.
|
||||
- **메뉴가 고른 상태**: 목록 · 글 라우트를 화면 없는 상위 라우트로 묶었다(`router/routes.ts`). 설정 메뉴는 그 항목을 고른 상태면 청록 바탕을 그리고,
|
||||
`aria-current` 는 정확히 그 화면이면 `page` · 아래에 묶은 화면이면 `true` 다(RouterLink 의 `isExactActive` · `isActive`). 다른 메뉴 항목은 아래에 묶은 화면이 없어 그대로다.
|
||||
- **초점**: 다른 글 줄을 누르면 그 줄이 사라져 초점을 잃는다 — 새 글을 받아 맨 위를 보이게 맞춘 뒤 제목(h1)으로 옮긴다(스크롤 없이 · 키보드로 왔을 때만 고리를 그린다).
|
||||
[더 보기]로 마지막 쪽을 받아 단추가 사라졌으면 새로 붙은 첫 줄로 옮긴다(단추가 남으면 초점도 그대로).
|
||||
- **주소의 글 id 가 글 id 꼴이 아니면**(`2026-11-18` · `2026-11-18-2` 꼴이 아님 · 32자 넘음) 묻지 않고 없는 글 화면이다(문의 코드 없음).
|
||||
"." · ".." 같은 경로 조각은 브라우저가 주소를 다듬으며 다른 경로(목록 API 등)로 보내서 꼴부터 거른다.
|
||||
- **목록을 받지 못했는데 404 여도**(그 경로가 없는 옛 서버) 빠져나갈 곳이 없어 [다시 시도]다.
|
||||
- **응답의 모양**: 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 틀리면(날짜가 실제 있는 달력 날짜가 아님 포함) 받지 못함(`SYS_001` 사전 문구)으로 본다(앱과 같은 규칙).
|
||||
처음 목록 · 글은 칸 자리에 그리고(알림 없음), [더 보기]는 다른 실패와 같은 길로 알린다(받은 줄 · [더 보기]는 그대로).
|
||||
- **다른 글 칸이 480보다 좁으면** 날짜를 제목 아래로 내린다(보드 A3 에는 그리지 않았다 — 목록 줄과 같은 기준). 이름 칸 64 는 그대로다.
|
||||
- 목록 · 글은 들어올 때마다 새로 받는다(배포하며 글을 더하거나 고친다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 늦게 온 응답을 버리고 받아 둔 목록 · 글을 지운다.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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,13 @@
|
||||
"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 },
|
||||
"ReleaseNotes.dc.html": { "x": 0, "y": 60700, "w": 1440, "h": 8464, "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", "ReleaseNotes.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 +88,9 @@
|
||||
"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 },
|
||||
"releasenotes": { "x": 0, "y": 60400, "text": "업데이트 내역(게시판 — 목록에서 글로) — 웹 설정 › 정보 · 앱 설정 › 앱 정보 · 글 파일 쓰는 법 — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+305
-27
@@ -8,6 +8,7 @@
|
||||
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
|
||||
(예외: 7절 업데이트 내역의 `date` 는 시각이 아닌 달력 날짜 글 `YYYY-MM-DD` 다 — `new Date()` 로 읽지 않고 시간대를 바꾸지 않은 채 그대로 쓴다)
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
|
||||
(11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다)
|
||||
- **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.**
|
||||
@@ -415,8 +416,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 +441,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 +733,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 +768,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 +1188,7 @@ interface MailboxBulkResult {
|
||||
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
|
||||
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
|
||||
|
||||
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
|
||||
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계 · 16단계(서식 본문 · 회사 밖 응답)
|
||||
|
||||
```ts
|
||||
interface MailVacation {
|
||||
@@ -1152,7 +1196,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 +1218,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 +1234,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 +1411,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 +1434,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 +1778,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 +1885,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 +1974,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 +2805,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 +2827,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 +3056,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 추천대로)
|
||||
@@ -3091,3 +3289,83 @@ interface FileUploadCreate {
|
||||
|
||||
웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다),
|
||||
공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱.
|
||||
|
||||
---
|
||||
|
||||
## 7. 업데이트 내역 (2026-11-10 정함 · 서버)
|
||||
|
||||
> 직원이 웹메일과 휴대폰 앱에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더한다(목록에서 글을 골라 들어가 읽는다).
|
||||
> 글은 운영자가 저장소의 `backend/release-notes/` 에 **글마다 파일 하나**로 쓴다 — 올리고 고치는 API 는 없다(파일을 더해 커밋 → 배포). 웹과 앱이 같은 글을 보고 거르지 않는다(어디 이야기인지는 묶음 머리가 말한다).
|
||||
> 보드 `docs/design/webmail/ReleaseNotes.dc.html`(웹 A · 앱 B · 쓰는 법 C), 서버는 `modules/release-notes`. 새 에러 코드 · DB 표 · env 는 없다.
|
||||
|
||||
### 타입
|
||||
|
||||
```ts
|
||||
interface ReleaseNoteSummary {
|
||||
id: string // 글 파일 이름에서 .md 를 뗀 것 — "2026-11-18", 같은 날 두 번째 글부터 "2026-11-18-2" …
|
||||
date: string // 달력 날짜 "YYYY-MM-DD"(파일 이름에서) — 시각이 아니다
|
||||
title: string // 첫 줄의 # 제목 — 100자까지
|
||||
}
|
||||
|
||||
interface ReleaseNoteSection {
|
||||
heading: string | null // ## 머리 — 40자까지. 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)
|
||||
items: string[] // - 한 줄 — 500자까지, 하나 이상
|
||||
}
|
||||
|
||||
interface ReleaseNote extends ReleaseNoteSummary {
|
||||
sections: ReleaseNoteSection[] // 파일에 쓴 차례 — 하나 이상
|
||||
previous: ReleaseNoteSummary | null // 바로 더 옛 글(목록에서 이 글 아래) — 없으면 null
|
||||
next: ReleaseNoteSummary | null // 바로 더 새 글(목록에서 이 글 위) — 없으면 null
|
||||
}
|
||||
```
|
||||
|
||||
### 엔드포인트
|
||||
|
||||
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/release-notes` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ReleaseNoteSummary[], pageInfo }` — 최근 글부터, `total` 은 글 수 | 400 `VAL_001`(틀린 커서 · limit 범위 밖 · 모르는 쿼리) |
|
||||
| `GET /api/release-notes/{id}` | — | 200 `ReleaseNote` | 404 `RES_001` — 없는 글 · 모양이 틀린 id |
|
||||
|
||||
- 두 경로 모두 로그인 필요(웹 쿠키 · 앱 Bearer — `JwtAuthGuard`), 없으면 401 `AUTH_001`. 제한된 세션(6절)은 403 `AUTH_010`(허용 목록에 넣지 않는다 — 그 단계를 마치면 본다).
|
||||
요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 넘으면 429 `REQ_001`). 응답은 `Cache-Control: private, no-store`(글을 고친 배포가 바로 보이게).
|
||||
- **차례**: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). `previous` · `next` 는 이 차례의 아래 · 위다(목록 화면과 같은 차례).
|
||||
- `cursor` 는 불투명 문자열(위치)이다. 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(3절과 같다).
|
||||
- **id**: 서버는 모양(`YYYY-MM-DD` 또는 `YYYY-MM-DD-N` — N 은 2~99, 32자까지)만 보고 읽어 둔 목록에서 찾는다 — 파일 경로로 쓰지 않는다.
|
||||
모양이 틀린 id 도 400 이 아니라 404 `RES_001` 이다(손으로 고친 주소도 "없는 글"). 파일 이름(날짜)을 고치면 글 주소도 바뀌어 옛 주소는 404 다.
|
||||
- **글은 서버가 다듬은 그대로다** — 앞뒤 빈칸을 지우고, 안의 빈칸 · 탭 여럿은 하나로, 이어 쓴 줄은 띄어쓰기 하나로 붙이고, NFC 로 모은다.
|
||||
마크다운(굵게 · 링크 꼴)은 해석하지 않고 쓴 글 그대로다 — 클라이언트도 글로만 그린다(v-html · 마크다운 해석 없음). 단추 · 메뉴 이름은 글에 `[설정] → [휴대폰 앱]` 처럼 쓴다.
|
||||
- **날짜(`date`)는 달력 날짜 글이다** — 화면은 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다. `new Date('2026-11-18')` 로 읽지 않는다(UTC 자정이라 음수 시간대에서 하루 앞이 된다).
|
||||
|
||||
### 글 파일 (`backend/release-notes/`)
|
||||
|
||||
- 글 하나 = 파일 하나. 이름 `YYYY-MM-DD.md`(글의 날짜 — 있는 날짜여야 한다: `2026-02-30` 은 틀림), 같은 날 둘째부터 `YYYY-MM-DD-2.md` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이). 이름이 글 id 다.
|
||||
- 폴더에는 `README.md`(쓰는 사람용 안내 — 서버는 읽지 않는다)와 글 파일만 둔다. 그 밖의 것(다른 이름 · 폴더 · 링크)은 글이 아니다.
|
||||
- 파일 안 — 마크다운의 아주 작은 일부(글만). UTF-8, 256KB 까지:
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `# 제목` | 첫 글 줄(앞의 빈 줄 · 메모는 된다) — 꼭 있어야 하고 한 번만. 100자까지 |
|
||||
| `## 머리` | 묶음 시작 — 40자까지. 아래에 줄이 하나는 있어야 한다 |
|
||||
| `- 한 줄` | 그 묶음의 한 줄 — 500자까지(이어 붙인 뒤). 머리 없이 바로 쓰면 머리 없는 묶음(`heading: null` — 제목 바로 아래에만) |
|
||||
| 빈칸 두 개 이상(또는 탭)으로 시작한 줄 | 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙는다(빈 줄 · 메모를 사이에 두어도). 들여 쓴 `- `(하위 목록)는 틀림 |
|
||||
| `<!-- … -->` | 메모 — 화면에 나오지 않는다. 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다). 여러 줄도 된다. 닫은 뒤 같은 줄에 글을 쓰지 않는다 |
|
||||
| 빈 줄 | 어디든 |
|
||||
|
||||
그 밖의 줄(맨 글 · `###` · `* ` · `1. ` · 표 · 인용 · 한 칸 들여 쓴 줄 등), 글 안의 `<!--`, 보이지 않는 글자(제어 문자 · 줄 구분 문자)는 틀림. 글 하나에 줄이 하나는 있어야 한다.
|
||||
글자 수는 앞뒤 빈칸을 지운 뒤의 JS 문자열 길이(UTF-16 — 웹의 `.length` 와 같다). 줄 끝의 CR(CRLF) · 앞의 BOM 은 지운다.
|
||||
- **읽기**: 서버는 기동 때 한 번, 그 뒤로는 요청 때 폴더를 본다 — 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다(개발 서버는 다시 띄우지 않아도 새 글이 보인다).
|
||||
운영은 이미지 안의 폴더(`/app/release-notes` — `backend/Dockerfile` 이 넣는다)라 배포해야 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
|
||||
글 파일은 한 번 열어(링크를 따라가지 않는다) 그 열린 파일로 일반 파일인지 · 크기를 보고 256KB + 1 바이트까지만 읽는다.
|
||||
- **틀린 글**: 시험(CI — `release-notes.content.spec.ts` 가 실제 폴더를 엄격하게 읽는다)이 실패한다. 그래도 배포되면 서버는 그 파일만 건너뛰고
|
||||
바뀔 때마다 error 로그를 한 번 남긴다(파일 이름 · 줄 번호 · 까닭 — 줄 차례로 앞의 20개, 글 내용은 싣지 않는다). 기동 때 읽으므로 배포 직후의 로그에 보인다.
|
||||
기동은 막지 않는다(기동 때 읽지 못해도 — 요청 때 다시 읽는다). 폴더가 없으면 빈 목록 + warn 한 번.
|
||||
|
||||
### 클라이언트
|
||||
|
||||
- **웹**: 설정 메뉴의 셋째 묶음 "정보" › "업데이트 내역" — 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(글을 보는 동안에도 메뉴 항목이 고른 상태). 404 `RES_001` 은 알림 없이 "없는 글" 화면.
|
||||
- **앱**: 설정 탭 › 앱 정보 › "업데이트 내역" — 목록 `/settings/release-notes`, 글 `/settings/release-notes/{id}`(둘 다 탭 막대 없는 전체 화면). 앱은 옛 판이어도 서버의 새 글을 본다(새 APK 를 내지 않아도 된다).
|
||||
- 화면 · 상태(불러오는 중 · 받지 못함 · 글 없음 · 없는 글)는 디자인 README(웹 · 앱)의 "업데이트 내역" 절.
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
올리고 고치는 API · 화면, 새 글 표시(점 · 개수 · NEW — 뱃지를 쓰지 않는다), 웹 · 앱 거르기, 글 안의 링크 · 그림 · 굵게, 검색, 번호 · 작성자 · 조회 수.
|
||||
|
||||
+14
-12
@@ -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-12
|
||||
|
||||
## 지금
|
||||
|
||||
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
|
||||
- **배포를 기다림**: 메일 14단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
|
||||
- **개발 중**: 메일 15단계(메일 · 메일함 다루기).
|
||||
- **다음**: 메일 16단계(개인 설정).
|
||||
- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.1) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로),
|
||||
2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱(1.0.0)을 나눠 주기 시작했다.
|
||||
같은 날 배포 뒤 받은 의견(본문 없이 첨부만 있는 메일의 앱 읽기 화면 · 앱 쓰기 화면 입력칸)을 고친 앱 1.0.1 을 올렸다. 1.0.0 도 그대로 쓸 수 있다.
|
||||
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
|
||||
|
||||
## 1부 — 메일
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
|
||||
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
|
||||
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 개발 중 |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 다음 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) |
|
||||
|
||||
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
|
||||
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
|
||||
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
|
||||
|
||||
## 공통 기반 — 조직 · 권한
|
||||
@@ -48,8 +48,8 @@
|
||||
|
||||
| 판 | 내용 | 상태 |
|
||||
| --- | --- | --- |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.1(2026-11-12) |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.1 을 올림(2026-11-12) |
|
||||
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
|
||||
|
||||
## 추후 후보 (필요해지면)
|
||||
@@ -60,9 +60,11 @@
|
||||
7일 넘는 예약(메일 엔진 한도라 어렵다).
|
||||
- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
|
||||
- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리.
|
||||
- **개인 설정**(16단계에서 미룸): 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기,
|
||||
받는 사람에 따라 서명 저절로 바꾸기, 앱에서 서명 고르기, 부재중 응답을 내 연락처에게만 · 그림 넣기.
|
||||
- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록,
|
||||
처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기.
|
||||
- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
|
||||
- **계정 · 보안**(12 · 13 · 16단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 2단계 휴대폰을 바꿀 때 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
|
||||
앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면.
|
||||
- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다).
|
||||
|
||||
|
||||
+3
-2
@@ -137,8 +137,9 @@ flutter build apk --release
|
||||
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
|
||||
```
|
||||
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다:
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다:
|
||||
`$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"`
|
||||
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
|
||||
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
|
||||
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
|
||||
|
||||
@@ -171,10 +171,10 @@ abstract final class AppSize {
|
||||
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
|
||||
static const double composeFileIconBox = 36;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
|
||||
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
|
||||
static const double composeBodyMinHeight = 240;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
|
||||
static const double composeBodyMinHeightWithQuote = 150;
|
||||
|
||||
/// 원본 메일 접힌 줄의 최소 높이
|
||||
@@ -229,4 +229,18 @@ abstract final class AppSize {
|
||||
/// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께
|
||||
static const double progressRing = 22;
|
||||
static const double progressRingStroke = 2.6;
|
||||
|
||||
// ── 앱 보드에서 정한 크기 (업데이트 내역 `ReleaseNotes` B — 2026-11-10 확정) ──
|
||||
// 목록 줄은 설정의 두 줄 칸([settingsRowMinHeight] 68)과 같다
|
||||
/// 글 화면 끝의 다른 글 줄(다음 글 · 이전 글)의 최소 높이
|
||||
static const double noteLinkRowMinHeight = 64;
|
||||
|
||||
/// 글의 점 목록 — 점의 지름(점 칸 폭은 `AppSpacing.s16`)
|
||||
static const double bulletDot = 5;
|
||||
|
||||
/// 목록을 불러오는 동안 줄의 제목 막대 높이(날짜 막대는 `AppSpacing.s10`)
|
||||
static const double skeletonTitleBarHeight = 13;
|
||||
|
||||
/// 목록을 불러오는 동안 줄의 날짜 막대 폭
|
||||
static const double skeletonDateBarWidth = 104;
|
||||
}
|
||||
|
||||
@@ -231,6 +231,34 @@ abstract final class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
|
||||
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
|
||||
///
|
||||
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
|
||||
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
|
||||
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
|
||||
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
|
||||
InputDecoration borderlessInputDecoration({
|
||||
String? hintText,
|
||||
TextStyle? hintStyle,
|
||||
Widget? hint,
|
||||
}) => InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: hintStyle,
|
||||
hint: hint,
|
||||
isCollapsed: true,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
);
|
||||
|
||||
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
|
||||
@immutable
|
||||
class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 달력 날짜 — 시각이 아닌 "그날"(서버의 `YYYY-MM-DD` 글, 예: 업데이트 내역 글의 쓴 날 — 계약 7절).
|
||||
///
|
||||
/// 시간대를 바꾸지 않는다 — 글을 직접 나눠 연 · 월 · 일 숫자로만 쥔다. `DateTime.parse('2026-11-18')` 은 기기 시간대의 자정이고
|
||||
/// 웹의 `new Date('2026-11-18')` 은 UTC 자정이라(음수 시간대에서 하루 앞) 어느 쪽이든 "시각"으로 바꾸면 그날이 흔들릴 수 있다.
|
||||
@immutable
|
||||
class CalendarDate {
|
||||
const CalendarDate(this.year, this.month, this.day);
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final int day;
|
||||
|
||||
/// `YYYY-MM-DD` 글 → 날짜. 모양이 다르거나 없는 날(2026-02-30 · 2026-13-01)이면 null
|
||||
static CalendarDate? tryParse(Object? value) {
|
||||
if (value is! String) return null;
|
||||
final RegExpMatch? match = _pattern.firstMatch(value);
|
||||
if (match == null) return null;
|
||||
final int year = int.parse(match.group(1)!);
|
||||
final int month = int.parse(match.group(2)!);
|
||||
final int day = int.parse(match.group(3)!);
|
||||
if (month < 1 || month > _monthsInYear) return null;
|
||||
if (day < 1 || day > _daysInMonth(year, month)) return null;
|
||||
return CalendarDate(year, month, day);
|
||||
}
|
||||
|
||||
/// 숫자 칸 셋 — 연 넷 · 월 둘 · 일 둘(ASCII 숫자만)
|
||||
static final RegExp _pattern = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$');
|
||||
|
||||
static const int _monthsInYear = 12;
|
||||
|
||||
/// 그달의 날 수 — 다음 달 0일(= 이달 마지막 날). UTC 로 만들어 시간대 · 서머타임과 상관없이 날만 센다
|
||||
static int _daysInMonth(int year, int month) =>
|
||||
DateTime.utc(year, month + 1, 0).day;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CalendarDate &&
|
||||
other.year == year &&
|
||||
other.month == month &&
|
||||
other.day == day;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(year, month, day);
|
||||
|
||||
/// `YYYY-MM-DD` — 서버가 준 글과 같은 모양
|
||||
@override
|
||||
String toString() =>
|
||||
'${year.toString().padLeft(4, '0')}-'
|
||||
'${month.toString().padLeft(2, '0')}-'
|
||||
'${day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
///
|
||||
/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 —
|
||||
/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다.
|
||||
/// 달력 날짜([CalendarDate] — 시각이 아닌 "그날")는 시간대를 바꾸지 않고 그대로 쓴다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/calendar_date.dart';
|
||||
|
||||
/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다)
|
||||
String formatCount(int value) {
|
||||
final String digits = value.abs().toString();
|
||||
@@ -49,6 +52,11 @@ String koreanScheduleTime(DateTime local) =>
|
||||
String koreanFullDateTime(DateTime local) =>
|
||||
'${local.year}년 ${koreanScheduleTime(local)}';
|
||||
|
||||
/// 달력 날짜 — "2026년 11월 18일" (웹 `{ year: 'numeric', month: 'long', day: 'numeric' }` — 업데이트 내역의 쓴 날).
|
||||
/// 시각이 아니라 시간대를 바꾸지 않는다
|
||||
String koreanCalendarDate(CalendarDate date) =>
|
||||
'${date.year}년 ${date.month}월 ${date.day}일';
|
||||
|
||||
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
|
||||
bool isSameCalendarDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user