fix: 화면 · 오류 문구를 사람이 말하듯 다듬고 엔진 검색의 기간 시작 경계를 고친다 #19

Merged
ttipo merged 6 commits from fix/ui-copy-natural into main 2026-11-08 14:38:05 +09:00
3 changed files with 48 additions and 3 deletions
Showing only changes of commit d4ab06183c - Show all commits
@@ -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[],