generated from ttipo/project-base
chore: 문구 다듬기 브랜치(14단계 · main 을 합친 판)를 앱 서버 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m47s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m26s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m20s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m4s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m47s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m26s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m20s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m4s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled
이 PR 이 문구 다듬기 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
충돌 넷은 모두 설정 › 로그인된 기기의 글이다 — 앱 쪽은 앱 세션이 함께 보여 "브라우저"를 "기기"로,
다듬기 쪽은 같은 안내 문장을 규칙 9 로("여기에 보이지 않습니다" → "이 목록에 없습니다") 바꿨다.
뜻은 앱 쪽 · 문장은 다듬은 글로 풀었고(account-security.ts · LoginSessionList.vue 주석 · 디자인 README),
시험은 두 쪽이 더한 상수를 모두 남기고 글자 그대로 견주던 줄을 상수로 바꿨다(AccountLoginHistoryPage.spec.ts).
합친 뒤 backend tsc · lint 0 · 단위 3,701 · e2e 333, frontend type-check · lint 0 · 단위 2,774 통과.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
Generated
+26
-26
@@ -33,7 +33,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -5764,18 +5764,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -5788,9 +5788,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6715,6 +6715,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7524,9 +7525,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8413,9 +8414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -10305,9 +10306,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
||||
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -11340,9 +11341,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -11601,12 +11602,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz",
|
||||
"integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
|
||||
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.1.0",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "^2.4.0"
|
||||
"multer": "^2.5.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -20,30 +20,32 @@ export interface ErrorCodeDefinition {
|
||||
const COMMON_ERROR_CODES = {
|
||||
SYS_001: {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
REQ_001: {
|
||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_001: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_002: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
|
||||
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
|
||||
clientOnly: true,
|
||||
},
|
||||
AUTH_003: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
message: '이 기능을 쓸 수 있는 권한이 없습니다.',
|
||||
},
|
||||
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
|
||||
AUTH_004: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
|
||||
},
|
||||
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
|
||||
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
|
||||
@@ -56,7 +58,7 @@ const COMMON_ERROR_CODES = {
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
|
||||
'코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
@@ -88,11 +90,11 @@ const COMMON_ERROR_CODES = {
|
||||
},
|
||||
RES_001: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
|
||||
},
|
||||
BIZ_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
@@ -109,24 +111,24 @@ const COMMON_ERROR_CODES = {
|
||||
const DOMAIN_ERROR_CODES = {
|
||||
FILE_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
message:
|
||||
'쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
|
||||
},
|
||||
FILE_002: {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
message: '파일이 너무 커서 올릴 수 없습니다.',
|
||||
},
|
||||
// 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 ·
|
||||
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
|
||||
FILE_003: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
|
||||
'그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
|
||||
message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
@@ -138,7 +140,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
FILE_006: {
|
||||
status: HttpStatus.INSUFFICIENT_STORAGE,
|
||||
message:
|
||||
'서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
'회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기)
|
||||
FILE_007: {
|
||||
@@ -163,14 +165,14 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
'메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
|
||||
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
|
||||
MAIL_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
|
||||
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
|
||||
@@ -183,7 +185,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
|
||||
MAIL_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
|
||||
},
|
||||
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
|
||||
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
|
||||
@@ -248,7 +250,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_016: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
|
||||
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () =>
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
traceId: 'trace-e',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -898,7 +898,7 @@ describe('JmapMailProvider', () => {
|
||||
],
|
||||
];
|
||||
|
||||
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => {
|
||||
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 — 엔진의 after 는 그 초를 넣지 않아 한 초 앞 · before 미만), 첨부는 hasAttachment', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses(['m1'], 1),
|
||||
@@ -942,13 +942,37 @@ describe('JmapMailProvider', () => {
|
||||
operator: 'OR',
|
||||
conditions: particleForms('견적').map((body) => ({ body })),
|
||||
},
|
||||
{ after: '2026-10-31T15:00:00Z' },
|
||||
{ after: '2026-10-31T14:59:59Z' },
|
||||
{ before: '2026-11-30T15:00:00Z' },
|
||||
{ hasAttachment: true },
|
||||
]);
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1']);
|
||||
});
|
||||
|
||||
it('기간의 after 는 색인 길(>=)과 같은 경계로 — 정수 초는 한 초 앞(달 · 날이 바뀌는 0시도), 소수 초는 초로 올린 값의 한 초 앞(= 그 초)', async () => {
|
||||
const cases: [string, string][] = [
|
||||
['2026-11-01T00:00:00.000Z', '2026-10-31T23:59:59Z'],
|
||||
['2026-10-31T15:00:00Z', '2026-10-31T14:59:59Z'],
|
||||
['2026-10-31T15:00:00.500Z', '2026-10-31T15:00:00Z'],
|
||||
['2026-10-31T15:00:00.001Z', '2026-10-31T15:00:00Z'],
|
||||
['2026-10-31T15:00:00.999Z', '2026-10-31T15:00:00Z'],
|
||||
];
|
||||
for (const [after, engineAfter] of cases) {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses([], 0),
|
||||
]);
|
||||
await provider.listMessages(ARA, { limit: 20, conditions: { after } });
|
||||
const { filter } = argsOf(recorded[1]) as {
|
||||
filter: { conditions: unknown[] };
|
||||
};
|
||||
expect({ after, conditions: filter.conditions.slice(1) }).toEqual({
|
||||
after,
|
||||
conditions: [{ after: engineAfter }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
const conditions = { from: '이도윤', hasAttachment: true as const };
|
||||
const first = fakeClient([
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
selectAccountQuota,
|
||||
toAddresses,
|
||||
toDetailSource,
|
||||
toJmapAfterInclusive,
|
||||
toJmapUtcDate,
|
||||
toMailbox,
|
||||
toSummary,
|
||||
@@ -310,6 +311,7 @@ function conditionsKey(
|
||||
|
||||
/**
|
||||
* 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정.
|
||||
* after 는 엔진이 그 초를 넣지 않아 한 초 앞을 넘긴다(toJmapAfterInclusive — 색인 길의 `>=` 와 같은 경계)
|
||||
* 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과)
|
||||
*/
|
||||
function conditionFilters(
|
||||
@@ -325,7 +327,7 @@ function conditionFilters(
|
||||
filters.push(...found);
|
||||
}
|
||||
if (conditions.after)
|
||||
filters.push({ after: toJmapUtcDate(conditions.after) });
|
||||
filters.push({ after: toJmapAfterInclusive(conditions.after) });
|
||||
if (conditions.before) {
|
||||
filters.push({ before: toJmapUtcDate(conditions.before) });
|
||||
}
|
||||
|
||||
@@ -378,6 +378,25 @@ export function toJmapUtcDate(iso: string): string {
|
||||
return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/** JMAP UTCDate 의 단위 — 초 (엔진은 받은 시각을 초로 둔다) */
|
||||
const JMAP_DATE_RESOLUTION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* "받은 시각이 그 시각 이상"을 엔진의 Email/query `after` 값으로 — 엔진의 after 는 **그 초를 넣지 않는다**
|
||||
* ("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적지만 Stalwart 0.16.27 은 아니다, 2026-11-08 개발 엔진에서 확인).
|
||||
* 받은 시각은 초라 "그 시각 이상"은 "그 시각을 초로 올린 값 이상"이고, 엔진에는 그 한 초 앞을 넘긴다 — 색인 길(mail-search/search-sql.ts 의 `>=`)과
|
||||
* 같은 경계다. 화면이 보내는 0시(정수 초)면 한 초 앞, 소수 초면 그 초 그대로.
|
||||
* ⚠️ 엔진이 RFC 대로 같은 초를 넣게 바뀌면 한 초 앞의 메일까지 맞는다 — 엔진 판을 올릴 때 다시 본다
|
||||
*/
|
||||
export function toJmapAfterInclusive(iso: string): string {
|
||||
const ceiled =
|
||||
Math.ceil(Date.parse(iso) / JMAP_DATE_RESOLUTION_MS) *
|
||||
JMAP_DATE_RESOLUTION_MS;
|
||||
return toJmapUtcDate(
|
||||
new Date(ceiled - JMAP_DATE_RESOLUTION_MS).toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
|
||||
export function pickIdentity(
|
||||
identities: JmapIdentity[],
|
||||
|
||||
Reference in New Issue
Block a user