diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 6578061..62c3a03 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -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([ diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 1079181..e3292bd 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -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) }); } diff --git a/backend/src/modules/mail/jmap/jmap.mapper.ts b/backend/src/modules/mail/jmap/jmap.mapper.ts index 8bc77e3..96db141 100644 --- a/backend/src/modules/mail/jmap/jmap.mapper.ts +++ b/backend/src/modules/mail/jmap/jmap.mapper.ts @@ -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[],