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

이 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:
2026-11-08 12:39:07 +09:00
86 changed files with 655 additions and 398 deletions
@@ -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[],