) => ({
+ id: 'q1',
+ resourceType: 'octets',
+ scope: 'account',
+ types: ['Mail'],
+ used: 900,
+ hardLimit: 10_000,
+ ...extra,
+ });
+
+ it('계정 범위 바이트 쿼터가 있으면 used·hardLimit 을 쓴다 (quota 기능은 이 호출에만 선언)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ [
+ 'Quota/get',
+ {
+ list: [
+ quota({}),
+ quota({ id: 'c', resourceType: 'count', used: 3 }),
+ ],
+ },
+ 'qt',
+ ],
+ ],
+ ]);
+
+ await expect(provider.getUsage(ARA)).resolves.toEqual({
+ usedBytes: 900,
+ limitBytes: 10_000,
+ });
+ expect(recorded[0].options).toEqual({
+ capabilities: ['urn:ietf:params:jmap:quota'],
+ });
+ });
+
+ it('한도가 0 이면 한도 없음(null)으로 본다', async () => {
+ const { provider } = fakeClient([
+ [['Quota/get', { list: [quota({ hardLimit: 0 })] }, 'qt']],
+ ]);
+
+ await expect(provider.getUsage(ARA)).resolves.toEqual({
+ usedBytes: 900,
+ limitBytes: null,
+ });
+ });
+
+ it('계정 쿼터가 없으면(Stalwart 기본) 메일 크기 합계를 쓰고 직원별로 캐시한다', async () => {
+ const page = (count: number, size: number) =>
+ Array.from({ length: count }, (_, i) => ({ id: `m${i}`, size }));
+ const { provider, recorded } = fakeClient([
+ [['Quota/get', { list: [quota({ scope: 'domain' })] }, 'qt']],
+ [
+ [
+ 'Email/query',
+ { ids: page(500, 0).map((e) => e.id), position: 0, total: 600 },
+ 'q',
+ ],
+ ['Email/get', { list: page(500, 10) }, 'g'],
+ ],
+ [
+ [
+ 'Email/query',
+ { ids: page(100, 0).map((e) => e.id), position: 500, total: 600 },
+ 'q',
+ ],
+ ['Email/get', { list: page(100, 10) }, 'g'],
+ ],
+ [['Quota/get', { list: [] }, 'qt']],
+ ]);
+
+ await expect(provider.getUsage(ARA)).resolves.toEqual({
+ usedBytes: 6000,
+ limitBytes: null,
+ });
+ expect(argsOf(recorded[2])).toMatchObject({ position: 500, limit: 500 });
+
+ // 두 번째 조회는 쿼터 확인만 하고 합계는 캐시에서 읽는다
+ await expect(provider.getUsage(ARA)).resolves.toEqual({
+ usedBytes: 6000,
+ limitBytes: null,
+ });
+ expect(recorded).toHaveLength(4);
+ });
+ });
+
+ describe('메일 목록 (listMessages)', () => {
+ const queryResponses = (
+ ids: string[],
+ position: number,
+ total: number,
+ emails = ids.map((id, i) =>
+ summaryEmail(id, `2026-10-26T0${9 - i}:00:00Z`),
+ ),
+ ): JmapInvocation[] => [
+ ['Mailbox/get', { list: [{ id: 'a' }], notFound: [] }, 'mb'],
+ ['Email/query', { ids, position, total, queryState: 's' }, 'q'],
+ // Email/get 결과 순서는 보장되지 않는다 — 일부러 뒤집어 준다
+ ['Email/get', { list: [...emails].reverse(), notFound: [] }, 'g'],
+ ];
+
+ it('받은 시각 내림차순으로 조회하고 query 순서대로 돌려준다', async () => {
+ const { provider, recorded } = fakeClient([
+ queryResponses(['m1', 'm2'], 0, 5),
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 2,
+ });
+
+ expect(argsOf(recorded[0], 1)).toEqual({
+ accountId: 'd',
+ // 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 (계약 2절 "임시 저장")
+ filter: { inMailbox: 'a', notKeyword: '$ara-sending' },
+ sort: [{ property: 'receivedAt', isAscending: false }],
+ position: 0,
+ limit: 2,
+ calculateTotal: true,
+ });
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ expect(page.items.map((m) => m.id)).toEqual(['m1', 'm2']);
+ expect(page.total).toBe(5);
+ expect(page.hasNext).toBe(true);
+ expect(
+ decodeCursor(page.nextCursor as string, { mailboxId: 'a' }),
+ ).toEqual({
+ anchor: 'm2',
+ position: 2,
+ });
+ });
+
+ it('요약 필드를 계약 형태로 매핑한다', async () => {
+ const { provider } = fakeClient([
+ queryResponses(['m1'], 0, 1, [
+ summaryEmail('m1', '2026-10-26T05:12:00Z', {
+ keywords: { $flagged: true },
+ subject: null,
+ // 엔진의 hasAttachment 는 보지 않는다 — 첨부 부분으로 정한다
+ hasAttachment: false,
+ attachments: [
+ {
+ type: 'application/pdf',
+ disposition: 'attachment',
+ cid: null,
+ size: 2048,
+ },
+ ],
+ }),
+ ]),
+ ]);
+
+ const [item] = (
+ await provider.listMessages(ARA, { mailboxId: 'a', limit: 20 })
+ ).items;
+
+ expect(item).toEqual({
+ id: 'm1',
+ threadId: 't-m1',
+ mailboxIds: ['a'],
+ from: { name: '김아라', email: ARA },
+ to: [{ name: null, email: DOYUN }],
+ subject: '',
+ preview: '미리보기',
+ receivedAt: '2026-10-26T05:12:00.000Z',
+ isRead: false,
+ isFlagged: true,
+ hasAttachment: true,
+ size: 1154,
+ });
+ });
+
+ it('첨부 표시는 엔진의 hasAttachment 대신 첨부 부분으로 정한다 — 본문 그림(서명 로고 등)만 있으면 false', async () => {
+ const signature = {
+ type: 'image/png',
+ disposition: 'inline',
+ cid: 'sig-3f1c0a2e@arasystem.test',
+ size: 18_342,
+ };
+ const { provider, recorded } = fakeClient([
+ queryResponses(['m1', 'm2', 'm3'], 0, 3, [
+ // 엔진은 본문 그림도 첨부로 센다 (hasAttachment true)
+ summaryEmail('m1', '2026-10-26T09:00:00Z', {
+ hasAttachment: true,
+ attachments: [signature],
+ }),
+ summaryEmail('m2', '2026-10-26T08:00:00Z', {
+ hasAttachment: true,
+ attachments: [
+ signature,
+ {
+ type: 'application/pdf',
+ disposition: 'attachment',
+ cid: null,
+ size: 20_480,
+ },
+ ],
+ }),
+ summaryEmail('m3', '2026-10-26T07:00:00Z', { attachments: [] }),
+ ]),
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 20,
+ });
+
+ expect(page.items.map((item) => [item.id, item.hasAttachment])).toEqual([
+ ['m1', false],
+ ['m2', true],
+ ['m3', false],
+ ]);
+ // 부분마다 판정에 쓰는 네 가지만 읽는다 (본문·이름·blob 은 읽지 않는다)
+ const get = argsOf(recorded[0], 2) as {
+ properties: string[];
+ bodyProperties: string[];
+ };
+ expect(get.properties).toContain('attachments');
+ expect(get.properties).not.toContain('hasAttachment');
+ expect(get.bodyProperties).toEqual([
+ 'type',
+ 'disposition',
+ 'cid',
+ 'size',
+ ]);
+ });
+
+ it('마지막 페이지면 hasNext=false, nextCursor=null', async () => {
+ const { provider } = fakeClient([queryResponses(['m5'], 4, 5)]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 2,
+ });
+
+ expect(page.hasNext).toBe(false);
+ expect(page.nextCursor).toBeNull();
+ });
+
+ it('커서가 있으면 anchor 다음부터 읽는다', async () => {
+ const cursor = encodeCursor(
+ { anchor: 'm2', position: 2 },
+ { mailboxId: 'a' },
+ );
+ const { provider, recorded } = fakeClient([queryResponses(['m3'], 2, 3)]);
+
+ await provider.listMessages(ARA, { mailboxId: 'a', limit: 2, cursor });
+
+ expect(argsOf(recorded[0], 1)).toMatchObject({
+ anchor: 'm2',
+ anchorOffset: 1,
+ });
+ expect(argsOf(recorded[0], 1)).not.toHaveProperty('position');
+ });
+
+ it('anchor 메일이 사라졌으면(anchorNotFound) 저장해 둔 위치로 다시 읽는다', async () => {
+ const cursor = encodeCursor(
+ { anchor: 'gone', position: 2 },
+ { mailboxId: 'a' },
+ );
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'a' }] }, 'mb'],
+ ['error', { type: 'anchorNotFound' }, 'q'],
+ ['error', { type: 'invalidResultReference' }, 'g'],
+ ],
+ queryResponses(['m3'], 2, 3),
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 2,
+ cursor,
+ });
+
+ expect(argsOf(recorded[1], 1)).toMatchObject({ position: 2 });
+ expect(argsOf(recorded[1], 1)).not.toHaveProperty('anchor');
+ expect(page.items.map((m) => m.id)).toEqual(['m3']);
+ });
+
+ it('검색어는 text 조건으로 보낸다 — 메일함 조건 뒤에 낱말마다 조사를 붙인 형태의 OR 를 AND 로 (subject 필터는 쓰지 않는다)', async () => {
+ const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
+
+ await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 20,
+ text: '서버 이전',
+ });
+
+ const { filter } = argsOf(recorded[0], 1) as {
+ filter: {
+ operator: string;
+ conditions: {
+ operator?: string;
+ conditions?: { text: string }[];
+ }[];
+ };
+ };
+ expect(filter.operator).toBe('AND');
+ expect(filter.conditions[0]).toEqual({
+ inMailbox: 'a',
+ notKeyword: '$ara-sending',
+ });
+ expect(filter.conditions.slice(1).map((group) => group.operator)).toEqual(
+ ['OR', 'OR'],
+ );
+ const forms = filter.conditions[1].conditions?.map((form) => form.text);
+ expect(forms).toEqual(
+ expect.arrayContaining(['서버', '서버가', '서버를', '서버에서']),
+ );
+ // 받침 있는 '이전' 에는 받침 있을 때의 조사
+ expect(filter.conditions[2].conditions?.map((form) => form.text)).toEqual(
+ expect.arrayContaining(['이전', '이전이', '이전을']),
+ );
+ // 메일함 범위는 그 메일함을 함께 확인한다 (없으면 RES_001)
+ expect(recorded[0].calls[0][0]).toBe('Mailbox/get');
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ });
+
+ it('큰따옴표 구절은 조사 없이 구절 그대로 찾는다', async () => {
+ const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
+
+ await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 20,
+ text: '"회의를 잡겠습니다"',
+ });
+
+ expect(argsOf(recorded[0], 1)).toMatchObject({
+ filter: {
+ operator: 'AND',
+ conditions: [
+ { inMailbox: 'a', notKeyword: '$ara-sending' },
+ { text: '"회의를 잡겠습니다"' },
+ ],
+ },
+ });
+ });
+
+ it('문자·숫자가 없는 낱말은 빼고 찾는다 — 남은 낱말만 조건이 된다', async () => {
+ const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
+
+ await provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 20,
+ text: '회의 - …',
+ });
+
+ const { filter } = argsOf(recorded[0], 1) as {
+ filter: { conditions: unknown[] };
+ };
+ expect(filter.conditions).toHaveLength(2);
+ });
+
+ it('찾을 낱말이 하나도 없는 검색어는 엔진에 묻지 않고 빈 결과 — 메일함 범위 · 모든 메일함 모두 (범위 전체를 돌려주지 않는다)', async () => {
+ for (const query of [
+ { mailboxId: 'a', limit: 20, text: '-' },
+ { mailboxId: 'zzzz', limit: 20, text: '"…"' },
+ { limit: 20, text: '""' },
+ { limit: 20, text: '- … !!' },
+ ]) {
+ const { provider, client } = fakeClient([]);
+
+ await expect(provider.listMessages(ARA, query)).resolves.toEqual({
+ items: [],
+ hasNext: false,
+ nextCursor: null,
+ total: 0,
+ });
+ expect(client.call).not.toHaveBeenCalled();
+ }
+ });
+
+ it('찾을 낱말이 없어도 커서는 먼저 본다 — 다른 목록의 커서면 VAL_001', async () => {
+ const cursor = encodeCursor(
+ { anchor: 'm2', position: 2 },
+ { mailboxId: 'a' },
+ );
+ const { provider, client } = fakeClient([]);
+
+ expect(
+ await codeOf(
+ provider.listMessages(ARA, {
+ mailboxId: 'a',
+ limit: 20,
+ text: '-',
+ cursor,
+ }),
+ ),
+ ).toBe('VAL_001');
+ expect(client.call).not.toHaveBeenCalled();
+ });
+
+ describe('메일함을 정하지 않은 검색 (휴지통 · 스팸메일함을 뺀 모든 메일함)', () => {
+ /** 메일함 확인 없는 목록 응답 */
+ const searchResponses = (ids: string[], total: number) =>
+ queryResponses(ids, 0, total).filter(([, , id]) => id !== 'mb');
+ const mailboxRoles: JmapInvocation[] = [
+ [
+ 'Mailbox/get',
+ {
+ list: stalwartMailboxes.map(({ id, role }) => ({ id, role })),
+ },
+ 'm',
+ ],
+ ];
+
+ it('휴지통 · 스팸메일함 id 를 먼저 읽어 inMailboxOtherThan 으로 뺀다 — 메일함 확인 없이, 커서는 "모든 메일함" 범위', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxRoles,
+ searchResponses(['m1', 'm2'], 3),
+ ]);
+
+ const page = await provider.listMessages(ARA, {
+ limit: 2,
+ text: '회의',
+ });
+
+ expect(argsOf(recorded[0])).toEqual({
+ accountId: 'd',
+ ids: null,
+ properties: ['id', 'role'],
+ });
+ expect(recorded[1].calls.map(([method]) => method)).toEqual([
+ 'Email/query',
+ 'Email/get',
+ ]);
+ expect(argsOf(recorded[1])).toMatchObject({
+ filter: {
+ operator: 'AND',
+ conditions: [
+ { inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
+ {
+ operator: 'OR',
+ conditions: expect.arrayContaining([
+ { text: '회의' },
+ { text: '회의를' },
+ ]) as unknown,
+ },
+ ],
+ },
+ sort: [{ property: 'receivedAt', isAscending: false }],
+ calculateTotal: true,
+ });
+ // 사용자가 보낸 값은 검색어·커서뿐이다 — 엔진이 요청을 거부하면 VAL_001
+ expect(recorded[1].options).toEqual({ invalidRequestCode: 'VAL_001' });
+ expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
+ expect(page.total).toBe(3);
+ expect(
+ decodeCursor(page.nextCursor as string, { text: '회의' }),
+ ).toEqual({ anchor: 'm2', position: 2 });
+ });
+
+ it('다음 쪽도 같은 범위로 — 메일함 검색이나 다른 검색어의 커서는 엔진에 묻기 전에 VAL_001', async () => {
+ const cursor = encodeCursor(
+ { anchor: 'm2', position: 2 },
+ { text: '회의' },
+ );
+ const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
+ await next.provider.listMessages(ARA, {
+ limit: 2,
+ text: '회의',
+ cursor,
+ });
+ expect(argsOf(next.recorded[1])).toMatchObject({
+ anchor: 'm2',
+ anchorOffset: 1,
+ });
+
+ for (const query of [
+ { mailboxId: 'a', limit: 2, text: '회의', cursor },
+ { limit: 2, text: '일정', cursor },
+ ]) {
+ const other = fakeClient([]);
+ expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
+ 'VAL_001',
+ );
+ expect(other.client.call).not.toHaveBeenCalled();
+ }
+ });
+
+ it('휴지통 · 스팸메일함이 없으면 모든 메일함에서 찾는다 (빼는 조건 없음)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ [
+ 'Mailbox/get',
+ {
+ list: [
+ { id: 'a', role: 'inbox' },
+ { id: 'x', role: null },
+ ],
+ },
+ 'm',
+ ],
+ ],
+ searchResponses([], 0),
+ ]);
+
+ await provider.listMessages(ARA, { limit: 20, text: 'ERP' });
+
+ const { filter } = argsOf(recorded[1]) as {
+ filter: { conditions: unknown[] };
+ };
+ expect(filter.conditions[0]).toEqual({ notKeyword: '$ara-sending' });
+ });
+ });
+
+ it('없는 메일함이면 404 RES_001 이다 (계약 2절)', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'],
+ ['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
+ ['Email/get', { list: [], notFound: [] }, 'g'],
+ ],
+ ]);
+
+ expect(
+ await codeOf(
+ provider.listMessages(ARA, { mailboxId: 'zzzz', limit: 20 }),
+ ),
+ ).toBe('RES_001');
+ });
+
+ it('엔진이 해석하지 못하는 mailboxId(요청 거부)도 없는 메일함과 같게 RES_001 로 요청한다', async () => {
+ const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
+
+ await provider.listMessages(ARA, { mailboxId: 'a', limit: 20 });
+
+ expect(recorded[0].options).toMatchObject({
+ invalidRequestCode: 'RES_001',
+ });
+ });
+ });
+
+ describe('메일 상세 (getMessage)', () => {
+ const detailEmail = {
+ ...summaryEmail('m1', '2026-10-26T05:12:00Z', { hasAttachment: true }),
+ cc: [{ name: '박서준', email: 'seojun.park@arasystem.test' }],
+ bcc: null,
+ replyTo: [],
+ sentAt: '2026-10-26T14:12:00+09:00',
+ messageId: ['abc@arasystem.test'],
+ references: null,
+ bodyValues: {
+ '1': { value: '텍스트 본문', isTruncated: false },
+ '2': {
+ value: 'HTML 본문
',
+ isTruncated: false,
+ },
+ },
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [{ partId: '2', type: 'text/html' }],
+ attachments: [
+ {
+ partId: '3',
+ blobId: 'blob-xlsx',
+ name: '체크리스트.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ size: 2048,
+ disposition: 'attachment',
+ cid: null,
+ },
+ {
+ partId: '4',
+ blobId: 'blob-logo',
+ name: null,
+ type: 'image/png',
+ size: 512,
+ disposition: 'inline',
+ cid: 'logo@x',
+ },
+ ],
+ };
+
+ it('본문 값·속성을 읽도록 요청한다 (조회만 — 읽음 처리하지 않는다)', async () => {
+ const { provider, recorded } = fakeClient([
+ [['Email/get', { list: [detailEmail] }, 'g']],
+ ]);
+
+ await provider.getMessage(ARA, 'm1');
+
+ const args = argsOf(recorded[0]);
+ expect(args).toMatchObject({
+ ids: ['m1'],
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ // 발송 때 정화한 서식 본문을 막는 바이트 수와 같은 상수 — 우리가 보낸 메일은 다시 읽어도 잘리지 않는다
+ maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
+ });
+ expect(recorded[0].calls.map(([name]) => name)).toEqual(['Email/get']);
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ // 겹치는 속성 이름 없이 (attachments 는 요약·상세 둘 다에 있다)
+ const properties = (args as { properties: string[] }).properties;
+ expect(new Set(properties).size).toBe(properties.length);
+ expect(properties).not.toContain('hasAttachment');
+ });
+
+ it('상세 필드·첨부·내장 이미지 후보를 매핑한다', async () => {
+ const { provider } = fakeClient([
+ [['Email/get', { list: [detailEmail] }, 'g']],
+ ]);
+
+ const detail = await provider.getMessage(ARA, 'm1');
+
+ expect(detail).toMatchObject({
+ id: 'm1',
+ cc: [{ name: '박서준', email: 'seojun.park@arasystem.test' }],
+ bcc: [],
+ replyTo: [],
+ sentAt: '2026-10-26T05:12:00.000Z',
+ messageId: 'abc@arasystem.test',
+ references: [],
+ rawHtmlBody: 'HTML 본문
',
+ isRawHtmlBodyTruncated: false,
+ textBody: '텍스트 본문',
+ isTextBodyTruncated: false,
+ });
+ expect(detail.attachments).toEqual([
+ {
+ id: 'blob-xlsx',
+ name: '체크리스트.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ size: 2048,
+ },
+ {
+ id: 'blob-logo',
+ name: '첨부파일-2.png',
+ type: 'image/png',
+ size: 512,
+ },
+ ]);
+ expect(detail.inlineParts).toEqual([
+ {
+ attachmentId: 'blob-logo',
+ // attachments 의 두 번째 — 본문에 넣으면 그 첨부의 isInline 을 켠다
+ attachmentIndex: 1,
+ cid: 'logo@x',
+ type: 'image/png',
+ size: 512,
+ },
+ ]);
+ // 첨부 표시는 부분으로 — 엑셀(첨부)이 있어 true
+ expect(detail.hasAttachment).toBe(true);
+ });
+
+ it('HTML 부분이 없으면 rawHtmlBody 는 null 이다 (JMAP htmlBody 가 text/plain 으로 채워지는 경우)', async () => {
+ const textOnly = {
+ ...detailEmail,
+ htmlBody: [{ partId: '1', type: 'text/plain' }],
+ attachments: [],
+ };
+ const { provider } = fakeClient([
+ [['Email/get', { list: [textOnly] }, 'g']],
+ ]);
+
+ const detail = await provider.getMessage(ARA, 'm1');
+
+ expect(detail.rawHtmlBody).toBeNull();
+ expect(detail.isRawHtmlBodyTruncated).toBe(false);
+ expect(detail.textBody).toBe('텍스트 본문');
+ });
+
+ /** 엔진이 본문 읽기 한도(maxBodyValueBytes)에서 자른 부분은 isTruncated 가 켜져 온다 (RFC 8621 EmailBodyValue) */
+ it.each([
+ {
+ name: 'HTML 부분이 잘렸다',
+ bodyValues: {
+ '1': { value: '텍스트 본문', isTruncated: false },
+ '2': { value: 'HTML 앞부분', isTruncated: true },
+ },
+ html: true,
+ text: false,
+ },
+ {
+ name: '텍스트 부분이 잘렸다',
+ bodyValues: {
+ '1': { value: '텍스트 앞부분', isTruncated: true },
+ '2': { value: '
HTML 본문
', isTruncated: false },
+ },
+ html: false,
+ text: true,
+ },
+ {
+ name: '잘린 부분이 없다 (isTruncated 가 없는 값 포함)',
+ bodyValues: {
+ '1': { value: '텍스트 본문' },
+ '2': { value: 'HTML 본문
', isTruncated: false },
+ },
+ html: false,
+ text: false,
+ },
+ ])(
+ '본문 잘림은 HTML·텍스트 따로 넘긴다 — $name',
+ async ({ bodyValues, html, text }) => {
+ const { provider } = fakeClient([
+ [['Email/get', { list: [{ ...detailEmail, bodyValues }] }, 'g']],
+ ]);
+
+ const detail = await provider.getMessage(ARA, 'm1');
+
+ expect(detail.isRawHtmlBodyTruncated).toBe(html);
+ expect(detail.isTextBodyTruncated).toBe(text);
+ // 잘린 값도 그대로 넘긴다 — 무엇을 보여 줄지는 서비스가 정한다
+ expect(detail.rawHtmlBody).toBe(bodyValues['2'].value);
+ expect(detail.textBody).toBe(bodyValues['1'].value);
+ },
+ );
+
+ it('HTML 부분이 여럿이면 하나라도 잘렸으면 잘린 본문이다 — 다른 형식의 부분은 세지 않는다', async () => {
+ const multipart = {
+ ...detailEmail,
+ bodyValues: {
+ '1': { value: '텍스트 앞부분', isTruncated: true },
+ '2': { value: '첫 부분
', isTruncated: false },
+ '5': { value: '둘째 부분', isTruncated: true },
+ },
+ // JMAP htmlBody 에는 HTML 이 없는 구간의 text/plain 부분도 들어 있을 수 있다 — 그 잘림은 HTML 의 것이 아니다
+ htmlBody: [
+ { partId: '2', type: 'text/html' },
+ { partId: '1', type: 'text/plain' },
+ { partId: '5', type: 'text/html' },
+ ],
+ };
+ const onlyTextTruncated = {
+ ...multipart,
+ bodyValues: {
+ ...multipart.bodyValues,
+ '5': { value: '
둘째 부분
', isTruncated: false },
+ },
+ };
+ const { provider } = fakeClient([
+ [['Email/get', { list: [multipart] }, 'g']],
+ [['Email/get', { list: [onlyTextTruncated] }, 'g']],
+ ]);
+
+ const first = await provider.getMessage(ARA, 'm1');
+ expect(first.rawHtmlBody).toBe('첫 부분
\n둘째 부분');
+ expect(first.isRawHtmlBodyTruncated).toBe(true);
+
+ const second = await provider.getMessage(ARA, 'm1');
+ expect(second.isRawHtmlBodyTruncated).toBe(false);
+ expect(second.isTextBodyTruncated).toBe(true);
+ });
+
+ it('없는 메일은 RES_001 이다', async () => {
+ const { provider } = fakeClient([
+ [['Email/get', { list: [], notFound: ['nope'] }, 'g']],
+ ]);
+
+ expect(await codeOf(provider.getMessage(ARA, 'nope'))).toBe('RES_001');
+ });
+ });
+
+ describe('읽음·중요·이동 (updateMessage)', () => {
+ it('키워드와 메일함을 JMAP 패치로 바꾼다', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ [
+ 'Email/get',
+ {
+ list: [
+ summaryEmail('m1', '2026-10-26T05:12:00Z', {
+ mailboxIds: { b: true },
+ }),
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+
+ const summary = await provider.updateMessage(ARA, 'm1', {
+ isRead: false,
+ isFlagged: true,
+ mailboxId: 'b',
+ });
+
+ expect(argsOf(recorded[0])).toEqual({
+ accountId: 'd',
+ update: {
+ m1: {
+ 'keywords/$seen': null,
+ 'keywords/$flagged': true,
+ mailboxIds: { b: true },
+ },
+ },
+ });
+ expect(summary.mailboxIds).toEqual(['b']);
+ // 바꾼 뒤의 요약도 목록과 같은 방법으로 첨부 표시를 정한다
+ expect(argsOf(recorded[0], 1)).toMatchObject({
+ bodyProperties: ['type', 'disposition', 'cid', 'size'],
+ });
+ });
+
+ it('없는 메일은 RES_001, 잘못된 값은 VAL_001 이다', async () => {
+ const notFound = fakeClient([
+ [
+ ['Email/set', { notUpdated: { nope: { type: 'notFound' } } }, 'u'],
+ ['Email/get', { list: [], notFound: ['nope'] }, 'g'],
+ ],
+ ]);
+ expect(
+ await codeOf(
+ notFound.provider.updateMessage(ARA, 'nope', { isRead: true }),
+ ),
+ ).toBe('RES_001');
+
+ const invalid = fakeClient([
+ [
+ [
+ 'Email/set',
+ {
+ notUpdated: {
+ m1: { type: 'invalidProperties', properties: ['mailboxIds'] },
+ },
+ },
+ 'u',
+ ],
+ [
+ 'Email/get',
+ { list: [summaryEmail('m1', '2026-10-26T05:12:00Z')] },
+ 'g',
+ ],
+ ],
+ ]);
+ expect(
+ await codeOf(
+ invalid.provider.updateMessage(ARA, 'm1', { mailboxId: 'zzzz' }),
+ ),
+ ).toBe('VAL_001');
+ });
+ });
+
+ describe('삭제 (deleteMessage)', () => {
+ /** 메일 + 메일함 확인 — 그때의 Email 상태 's1' */
+ const lookup = (mailboxIds: Record): JmapInvocation[] => [
+ ['Email/get', { state: 's1', list: [{ id: 'm1', mailboxIds }] }, 'g'],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ];
+ /** 지우기 전에 다시 확인한 메일 — 그때의 상태 */
+ const recheck = (
+ mailboxIds: Record | null,
+ state: string,
+ ): JmapInvocation[] => [
+ [
+ 'Email/get',
+ { state, list: mailboxIds ? [{ id: 'm1', mailboxIds }] : [] },
+ 'g',
+ ],
+ ];
+ const mismatch: JmapInvocation[] = [
+ ['error', { type: 'stateMismatch' }, 'd'],
+ ];
+ const destroyed: JmapInvocation[] = [
+ ['Email/set', { destroyed: ['m1'] }, 'd'],
+ ];
+
+ it('휴지통이 아니면 휴지통으로 옮긴다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ a: true }),
+ [['Email/set', { updated: { m1: null } }, 'd']],
+ ]);
+
+ await provider.deleteMessage(ARA, 'm1');
+
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ update: { m1: { mailboxIds: { b: true } } },
+ });
+ });
+
+ it('휴지통에만 있으면 확인한 상태 그대로일 때만 영구 삭제한다 (ifInState)', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true }),
+ destroyed,
+ ]);
+
+ await provider.deleteMessage(ARA, 'm1');
+
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['m1'],
+ });
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('휴지통과 다른 메일함에 함께 있으면 지우지 않고 휴지통에서만 뺀다 — 다른 메일함의 사본은 남는다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true, a: true }),
+ [['Email/set', { updated: { m1: null } }, 'd']],
+ ]);
+
+ await provider.deleteMessage(ARA, 'm1');
+
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: { m1: { 'mailboxIds/b': null } },
+ });
+ });
+
+ it('어긋나면 다시 확인하고 그 상태로 다시 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true }),
+ mismatch,
+ recheck({ b: true }, 's2'),
+ destroyed,
+ ]);
+
+ await provider.deleteMessage(ARA, 'm1');
+
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ ifInState: 's2',
+ destroy: ['m1'],
+ });
+ });
+
+ it('확인하는 사이 휴지통 밖으로 되돌려졌거나 이미 없으면 아무것도 하지 않는다 — 지금 상태를 존중한다 (204 그대로)', async () => {
+ for (const restored of [{ a: true }, null]) {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true }),
+ mismatch,
+ recheck(restored, 's2'),
+ ]);
+
+ await expect(provider.deleteMessage(ARA, 'm1')).resolves.toBe(
+ undefined,
+ );
+ expect(recorded).toHaveLength(3);
+ }
+ });
+
+ it('다시 보니 다른 메일함에도 들어 있으면 휴지통에서만 뺀다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true }),
+ mismatch,
+ recheck({ b: true, a: true }, 's2'),
+ [['Email/set', { updated: { m1: null } }, 'd']],
+ ]);
+
+ await provider.deleteMessage(ARA, 'm1');
+
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ ifInState: 's2',
+ update: { m1: { 'mailboxIds/b': null } },
+ });
+ });
+
+ it('어긋남이 세 번 이어지면 지우지 않고 MAIL_001 — 다시 시도하면 된다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup({ b: true }),
+ mismatch,
+ recheck({ b: true }, 's2'),
+ mismatch,
+ recheck({ b: true }, 's3'),
+ mismatch,
+ ]);
+
+ expect(await codeOf(provider.deleteMessage(ARA, 'm1'))).toBe('MAIL_001');
+ expect(recorded).toHaveLength(6);
+ });
+
+ it('지우는 사이 없어졌으면(notFound) 이미 처리된 것이다 · 그 밖의 거절은 그 코드', async () => {
+ const raced = fakeClient([
+ lookup({ b: true }),
+ [['Email/set', { notDestroyed: { m1: { type: 'notFound' } } }, 'd']],
+ ]);
+ await expect(raced.provider.deleteMessage(ARA, 'm1')).resolves.toBe(
+ undefined,
+ );
+
+ const refused = fakeClient([
+ lookup({ b: true }),
+ [['Email/set', { notDestroyed: { m1: { type: 'forbidden' } } }, 'd']],
+ ]);
+ expect(await codeOf(refused.provider.deleteMessage(ARA, 'm1'))).toBe(
+ 'AUTH_003',
+ );
+ });
+
+ it('없는 메일은 RES_001 이다', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Email/get', { list: [], notFound: ['nope'] }, 'g'],
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ],
+ ]);
+
+ expect(await codeOf(provider.deleteMessage(ARA, 'nope'))).toBe('RES_001');
+ });
+ });
+
+ describe('새 메일 확인 (getMailState)', () => {
+ const states = (
+ mailboxState: unknown,
+ emailState: unknown,
+ ): JmapInvocation[] => [
+ ['Mailbox/get', { state: mailboxState, list: [], notFound: [] }, 'm'],
+ ['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
+ ];
+
+ it('메일을 읽지 않는 조회(ids: []) 두 개를 한 요청으로 — 메일함 · 메일 상태를 이어 붙인 불투명 값', async () => {
+ const { provider, recorded } = fakeClient([states('sb2', 'saj')]);
+
+ const { state } = await provider.getMailState(ARA);
+
+ expect(recorded).toHaveLength(1);
+ expect(recorded[0].calls).toEqual([
+ ['Mailbox/get', { accountId: 'd', ids: [] }, 'm'],
+ ['Email/get', { accountId: 'd', ids: [] }, 'e'],
+ ]);
+ expect(state).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
+ });
+
+ it('둘 중 하나만 바뀌어도 값이 바뀌고, 다른 두 상태가 같은 값이 되지 않는다', async () => {
+ const read = async (mailboxState: string, emailState: string) =>
+ (
+ await fakeClient([
+ states(mailboxState, emailState),
+ ]).provider.getMailState(ARA)
+ ).state;
+
+ const base = await read('sb2', 'saj');
+ expect(await read('sb2', 'saj')).toBe(base);
+ expect(await read('sb3', 'saj')).not.toBe(base);
+ expect(await read('sb2', 'sak')).not.toBe(base);
+ // 경계가 다른 두 쌍 ('ab' + 'c' 와 'a' + 'bc')
+ expect(await read('ab', 'c')).not.toBe(await read('a', 'bc'));
+ });
+
+ it('엔진이 상태를 알려 주지 않으면 MAIL_001 · 메서드 오류도 그 코드', async () => {
+ for (const [mailboxState, emailState] of [
+ [undefined, 's1'],
+ ['s1', null],
+ ['', 's1'],
+ ]) {
+ const { provider } = fakeClient([states(mailboxState, emailState)]);
+ expect(await codeOf(provider.getMailState(ARA))).toBe('MAIL_001');
+ }
+
+ const failed = fakeClient([
+ [
+ ['error', { type: 'serverFail' }, 'm'],
+ ['Email/get', { state: 's1', list: [] }, 'e'],
+ ],
+ ]);
+ expect(await codeOf(failed.provider.getMailState(ARA))).toBe('MAIL_001');
+ });
+ });
+
+ describe('실시간 알림 (watchMail)', () => {
+ /** 처음 상태 응답 — 모든 메일함 · 메일 상태 · 발신 ID */
+ const snapshotAnswer = (
+ mailboxState = 'sb2',
+ emailState = 'saj',
+ ): JmapInvocation[] => [
+ [
+ 'Mailbox/get',
+ { state: mailboxState, list: stalwartMailboxes, notFound: [] },
+ 'm',
+ ],
+ ['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
+ [
+ 'Identity/get',
+ {
+ list: [
+ { id: 'i1', email: ARA },
+ { id: 'i2', email: 'Ara@Alias.ArasYstem.test' },
+ ],
+ },
+ 'i',
+ ],
+ ];
+
+ /** 변화 응답 — Email/changes · 생긴 메일 · 받은편지함 · 메일 상태 */
+ const changesAnswer = (
+ changes: JmapInvocation,
+ created: unknown[],
+ mailboxState: string,
+ emailState: string,
+ inboxUnread = 3,
+ ): JmapInvocation[] => [
+ changes,
+ ['Email/get', { state: emailState, list: created, notFound: [] }, 'n'],
+ [
+ 'Mailbox/get',
+ {
+ state: mailboxState,
+ list: [{ id: 'a', role: 'inbox', unreadEmails: inboxUnread }],
+ notFound: [],
+ },
+ 'm',
+ ],
+ ['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
+ ];
+
+ const changed = (
+ newState: string,
+ created: string[],
+ hasMoreChanges = false,
+ ): JmapInvocation => [
+ 'Email/changes',
+ {
+ oldState: 'old',
+ newState,
+ hasMoreChanges,
+ created,
+ updated: [],
+ destroyed: [],
+ },
+ 'c',
+ ];
+
+ const delivered = (id: string, extra: Record = {}) => ({
+ id,
+ mailboxIds: { a: true },
+ keywords: {},
+ from: [{ name: '이도윤', email: DOYUN }],
+ subject: `제목 ${id}`,
+ receivedAt: '2026-10-28T05:42:00Z',
+ ...extra,
+ });
+
+ type Event = [string, unknown];
+
+ /** 엔진 대역 위에서 연결을 열고, 알리는 것을 모은다 */
+ async function watch(responses: JmapInvocation[][]) {
+ const fake = fakeClient(responses);
+ const streams: FakeEventStream[] = [];
+ fake.client.openEventSource.mockImplementation(
+ (_user: string, _options: unknown, signal: AbortSignal) => {
+ const stream = new FakeEventStream(signal);
+ streams.push(stream);
+ return Promise.resolve({ accountId: 'd', body: stream.body() });
+ },
+ );
+ const events: Event[] = [];
+ const listener: MailWatchListener = {
+ state: (live) => events.push(['state', live]),
+ newMail: (notice) => events.push(['new-mail', notice]),
+ upstream: (connected) => events.push(['upstream', connected]),
+ ended: () => events.push(['ended', null]),
+ };
+ const opened = await fake.provider.watchMail(ARA, listener);
+ opens.push(opened);
+ await settleAsync();
+ return { ...fake, opened, streams, events };
+ }
+
+ /** 이 계정의 변화 신호를 보내고 모으는 시간이 지나기를 기다린다 */
+ async function signal(stream: FakeEventStream): Promise {
+ stream.send(stateChangeEvent('d'));
+ await jest.advanceTimersByTimeAsync(WATCH_DEBOUNCE_MS);
+ await settleAsync();
+ }
+
+ const opens: MailWatch[] = [];
+
+ beforeEach(() => {
+ // 변화를 보는 시각 — delivered() 의 받은 시각(05:42)은 그 3분 전이다 (10분 넘게 앞선 메일은 알리지 않는다)
+ jest.useFakeTimers({
+ now: Date.parse('2026-10-28T05:45:00Z'),
+ doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
+ });
+ });
+
+ afterEach(() => {
+ for (const opened of opens.splice(0)) opened.close();
+ jest.useRealTimers();
+ });
+
+ it('대리 접근 스트림(types=Mailbox,Email · ping 30)을 연 뒤 요청 한 번으로 처음 상태를 묻는다 — state 는 GET /api/mail-state 와 같은 값', async () => {
+ const { client, recorded, opened } = await watch([snapshotAnswer()]);
+
+ expect(client.openEventSource).toHaveBeenCalledWith(
+ ARA,
+ { types: ['Mailbox', 'Email'], pingSeconds: 30 },
+ expect.any(AbortSignal),
+ );
+ expect(recorded).toHaveLength(1);
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Mailbox/get',
+ {
+ accountId: 'd',
+ ids: null,
+ properties: ['id', 'role', 'unreadEmails'],
+ },
+ 'm',
+ ],
+ ['Email/get', { accountId: 'd', ids: [] }, 'e'],
+ [
+ 'Identity/get',
+ { accountId: 'd', ids: null, properties: ['email'] },
+ 'i',
+ ],
+ ]);
+ // 처음 연결의 남은 시간 안에서 묻는다 (클라이언트가 응답 머리를 기다린다)
+ const { timeoutMs } = recorded[0].options as { timeoutMs: number };
+ expect(timeoutMs).toBeGreaterThan(0);
+ expect(timeoutMs).toBeLessThanOrEqual(WATCH_START_TIMEOUT_MS);
+
+ const polled = await fakeClient([
+ [
+ ['Mailbox/get', { state: 'sb2', list: [] }, 'm'],
+ ['Email/get', { state: 'saj', list: [] }, 'e'],
+ ],
+ ]).provider.getMailState(ARA);
+ expect(opened.initial).toEqual({ state: polled.state, inboxUnread: 2 });
+ });
+
+ it('이 계정의 변화 신호 → 지난 메일 상태부터 Email/changes · 생긴 메일 · 받은편지함 · 메일 상태를 한 요청으로 — 새 메일만 골라 state 다음 new-mail', async () => {
+ const { recorded, streams, events } = await watch([
+ snapshotAnswer(),
+ changesAnswer(
+ changed('sak', ['m1', 'm2', 'm3', 'm4']),
+ [
+ delivered('m1'),
+ delivered('m2', { keywords: { $draft: true } }),
+ delivered('m3', {
+ from: [{ name: '나', email: 'ara@alias.arasystem.test' }],
+ }),
+ delivered('m4', { keywords: { $seen: true } }),
+ ],
+ 'sb3',
+ 'sal',
+ ),
+ changesAnswer(changed('sam', []), [], 'sb3', 'sam'),
+ ]);
+
+ await signal(streams[0]);
+
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Email/changes',
+ { accountId: 'd', sinceState: 'saj', maxChanges: 500 },
+ 'c',
+ ],
+ [
+ 'Email/get',
+ {
+ accountId: 'd',
+ '#ids': { resultOf: 'c', name: 'Email/changes', path: '/created' },
+ properties: [
+ 'id',
+ 'mailboxIds',
+ 'keywords',
+ 'from',
+ 'subject',
+ 'receivedAt',
+ ],
+ },
+ 'n',
+ ],
+ // 받은편지함은 처음에 찾아 두었다
+ [
+ 'Mailbox/get',
+ {
+ accountId: 'd',
+ ids: ['a'],
+ properties: ['id', 'role', 'unreadEmails'],
+ },
+ 'm',
+ ],
+ ['Email/get', { accountId: 'd', ids: [] }, 'e'],
+ ]);
+ const polled = await fakeClient([
+ [
+ ['Mailbox/get', { state: 'sb3', list: [] }, 'm'],
+ ['Email/get', { state: 'sal', list: [] }, 'e'],
+ ],
+ ]).provider.getMailState(ARA);
+ expect(events).toEqual([
+ ['state', { state: polled.state, inboxUnread: 3 }],
+ [
+ 'new-mail',
+ {
+ count: 1,
+ messages: [
+ {
+ id: 'm1',
+ from: { name: '이도윤', email: DOYUN },
+ subject: '제목 m1',
+ receivedAt: '2026-10-28T05:42:00.000Z',
+ },
+ ],
+ },
+ ],
+ ]);
+
+ // 다음 기준은 Email/changes 의 newState — 같은 요청에서 그 뒤에 생긴 메일을 다음에 센다
+ await signal(streams[0]);
+ expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sak' });
+ });
+
+ it('변화를 본 시각보다 10분 넘게 앞서 받은 메일은 알리지 않는다(IMAP 으로 올린 옛 메일 · imapsync) — state 는 그대로', async () => {
+ const { streams, events } = await watch([
+ snapshotAnswer(),
+ changesAnswer(
+ changed('sak', ['old', 'fresh']),
+ [
+ delivered('old', { receivedAt: '2019-05-01T09:00:00Z' }),
+ delivered('fresh'),
+ ],
+ 'sb3',
+ 'sal',
+ ),
+ ]);
+
+ await signal(streams[0]);
+
+ expect(events.map(([type]) => type)).toEqual(['state', 'new-mail']);
+ expect(events[1][1]).toMatchObject({
+ count: 1,
+ messages: [{ id: 'fresh' }],
+ });
+ });
+
+ it('셀 수 없으면(cannotCalculateChanges) new-mail 없이 state 만 — 다음 기준은 지금 메일 상태', async () => {
+ const { recorded, streams, events } = await watch([
+ snapshotAnswer(),
+ changesAnswer(
+ ['error', { type: 'cannotCalculateChanges' }, 'c'],
+ [],
+ 'sb3',
+ 'sal',
+ ).map((invocation) =>
+ invocation[2] === 'n'
+ ? ([
+ 'error',
+ { type: 'invalidResultReference' },
+ 'n',
+ ] as JmapInvocation)
+ : invocation,
+ ),
+ changesAnswer(changed('sam', []), [], 'sb3', 'sam'),
+ ]);
+
+ await signal(streams[0]);
+ expect(events.map(([type]) => type)).toEqual(['state']);
+
+ await signal(streams[0]);
+ expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sal' });
+ });
+
+ it('엔진이 요청 전체를 거부하면(해석하지 못하는 옛 기준 — Stalwart 는 400 notRequest) 셀 수 없는 것과 같게: 처음 상태를 다시 잡아 새 기준으로, new-mail 없이', async () => {
+ const { client, recorded, streams, events } = await watch([
+ snapshotAnswer(),
+ snapshotAnswer('sb9', 'fresh'),
+ changesAnswer(changed('fresh2', []), [], 'sb9', 'fresh2'),
+ ]);
+ client.call.mockImplementationOnce(
+ (
+ _user: string,
+ build: (accountId: string) => JmapInvocation[],
+ options?: unknown,
+ ) => {
+ // 요청 모양은 기록하고(준비한 응답은 쓰지 않는다), 엔진은 요청 전체를 거부한다 (jmap.client.ts 가 invalidRequestCode 로 던진다)
+ recorded.push({ calls: build(session.accountId), options });
+ return Promise.reject(
+ new BusinessException('MAIL_001', {
+ context: { reason: 'request-rejected', jmapType: 'notRequest' },
+ }),
+ );
+ },
+ );
+
+ await signal(streams[0]);
+
+ // 거부된 요청은 거부를 가를 수 있게 invalidRequestCode 를 단다
+ expect(recorded[1].calls[0][0]).toBe('Email/changes');
+ expect(recorded[1].options).toEqual({ invalidRequestCode: 'MAIL_001' });
+ // 처음 상태를 다시 잡는다 (모든 메일함 · 메일 상태 · 발신 ID)
+ expect(recorded[2].calls.map(([name]) => name)).toEqual([
+ 'Mailbox/get',
+ 'Email/get',
+ 'Identity/get',
+ ]);
+ expect(events.map(([type]) => type)).toEqual(['state']);
+ expect(events).not.toContainEqual(['upstream', false]);
+
+ await signal(streams[0]);
+ expect(argsOf(recorded[3], 0)).toMatchObject({ sinceState: 'fresh' });
+ });
+
+ it('변화가 한 번에 다 오지 않으면(hasMoreChanges) newState 부터 이어 묻는다 — 4번을 넘으면 이번에는 new-mail 없이', async () => {
+ const paged = await watch([
+ snapshotAnswer(),
+ changesAnswer(
+ changed('p1', ['m1'], true),
+ [delivered('m1')],
+ 'sb3',
+ 'p9',
+ ),
+ changesAnswer(changed('p2', ['m2']), [delivered('m2')], 'sb3', 'p9'),
+ ]);
+ await signal(paged.streams[0]);
+ expect(argsOf(paged.recorded[2], 0)).toMatchObject({ sinceState: 'p1' });
+ expect(paged.events[1]).toEqual([
+ 'new-mail',
+ expect.objectContaining({ count: 2 }),
+ ]);
+
+ const flooded = await watch([
+ snapshotAnswer(),
+ ...['f1', 'f2', 'f3', 'f4'].map((state) =>
+ changesAnswer(
+ changed(state, ['m1'], true),
+ [delivered('m1')],
+ 'sb3',
+ 'now',
+ ),
+ ),
+ changesAnswer(changed('later', []), [], 'sb3', 'later'),
+ ]);
+ await signal(flooded.streams[0]);
+ expect(flooded.recorded).toHaveLength(5);
+ expect(flooded.events.map(([type]) => type)).toEqual(['state']);
+ await signal(flooded.streams[0]);
+ expect(argsOf(flooded.recorded[5], 0)).toMatchObject({
+ sinceState: 'now',
+ });
+ });
+
+ it('받은편지함을 찾지 못하면(id 가 바뀜) 모든 메일함에서 다시 찾아 안 읽은 수와 새 메일을 그것으로 센다', async () => {
+ const { recorded, streams, events } = await watch([
+ snapshotAnswer(),
+ changesAnswer(
+ changed('sak', ['m1']),
+ [delivered('m1', { mailboxIds: { z: true } })],
+ 'sb3',
+ 'sal',
+ ).map((invocation) =>
+ invocation[2] === 'm'
+ ? ([
+ 'Mailbox/get',
+ { state: 'sb3', list: [], notFound: ['a'] },
+ 'm',
+ ] as JmapInvocation)
+ : invocation,
+ ),
+ [
+ [
+ 'Mailbox/get',
+ {
+ state: 'sb3',
+ list: [
+ { id: 'z', name: 'Inbox', role: 'inbox', unreadEmails: 7 },
+ ],
+ },
+ 'm',
+ ],
+ ],
+ ]);
+
+ await signal(streams[0]);
+
+ expect(recorded[2].calls[0][0]).toBe('Mailbox/get');
+ expect(argsOf(recorded[2])).toMatchObject({ ids: null });
+ expect(events).toEqual([
+ ['state', expect.objectContaining({ inboxUnread: 7 })],
+ ['new-mail', expect.objectContaining({ count: 1 })],
+ ]);
+ });
+ });
+
+ describe('여러 통 바꾸기 (updateMessages)', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest
+ .spyOn(Logger.prototype, 'warn')
+ .mockImplementation(() => undefined);
+ });
+
+ afterEach(() => warnSpy.mockRestore());
+
+ it('한 요청의 Email/set 하나로 — 메일마다 한 통 PATCH 와 같은 패치, 바꾸지 못한 것은 failedIds (요청 순서)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ [
+ 'Email/set',
+ {
+ updated: { m1: null, m3: null },
+ notUpdated: { m2: { type: 'notFound' } },
+ },
+ 'u',
+ ],
+ ],
+ ]);
+
+ const result = await provider.updateMessages(ARA, ['m1', 'm2', 'm3'], {
+ isRead: true,
+ isFlagged: false,
+ });
+
+ expect(recorded).toHaveLength(1);
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Email/set',
+ {
+ accountId: 'd',
+ update: {
+ m1: { 'keywords/$seen': true, 'keywords/$flagged': null },
+ m2: { 'keywords/$seen': true, 'keywords/$flagged': null },
+ m3: { 'keywords/$seen': true, 'keywords/$flagged': null },
+ },
+ },
+ 'u',
+ ],
+ ]);
+ // id 는 요청 본문에서 왔다 — 엔진이 요청을 거부하면 VAL_001
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
+ expect(result).toEqual({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] });
+ // 그사이 지워진 것(notFound)은 흔한 경합이라 경고하지 않는다
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('이동은 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'b' }], notFound: [] }, 'mb'],
+ ['Email/set', { updated: { m1: null, m2: null } }, 'u'],
+ ],
+ ]);
+
+ const result = await provider.updateMessages(ARA, ['m1', 'm2'], {
+ mailboxId: 'b',
+ });
+
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Mailbox/get',
+ { accountId: 'd', ids: ['b'], properties: ['id'] },
+ 'mb',
+ ],
+ [
+ 'Email/set',
+ {
+ accountId: 'd',
+ update: {
+ m1: { mailboxIds: { b: true } },
+ m2: { mailboxIds: { b: true } },
+ },
+ },
+ 'u',
+ ],
+ ]);
+ expect(result).toEqual({ updatedIds: ['m1', 'm2'], failedIds: [] });
+ });
+
+ it('옮길 메일함이 없으면 404 RES_001 — 엔진이 메일마다 이동을 거절해 아무것도 바뀌지 않았다', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'],
+ [
+ 'Email/set',
+ {
+ notUpdated: {
+ m1: { type: 'invalidProperties', properties: ['mailboxIds'] },
+ m2: { type: 'invalidProperties', properties: ['mailboxIds'] },
+ },
+ },
+ 'u',
+ ],
+ ],
+ ]);
+
+ expect(
+ await codeOf(
+ provider.updateMessages(ARA, ['m1', 'm2'], {
+ isRead: true,
+ mailboxId: 'zzzz',
+ }),
+ ),
+ ).toBe('RES_001');
+ // 읽음 표시도 같은 패치라 메일마다 통째로 거절된다 — 이동만 빠지고 읽음만 바뀌는 일은 없다
+ const set = argsOf(recorded[0], 1) as {
+ update: Record>;
+ };
+ expect(set.update.m1).toEqual({
+ 'keywords/$seen': true,
+ mailboxIds: { zzzz: true },
+ });
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('없는 메일함인데 엔진이 바꿨다고 답하면(있을 수 없는 경우) 사실대로 돌려주고 경고한다', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: [], notFound: ['k'] }, 'mb'],
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ ],
+ ]);
+
+ await expect(
+ provider.updateMessages(ARA, ['m1'], { mailboxId: 'k' }),
+ ).resolves.toEqual({ updatedIds: ['m1'], failedIds: [] });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('메일마다의 거절(notFound 밖)은 failedIds 로 — 종류와 개수만 경고한다 (id 는 남기지 않는다)', async () => {
+ const { provider } = fakeClient([
+ [
+ [
+ 'Email/set',
+ {
+ updated: { m1: null },
+ notUpdated: {
+ m2: { type: 'forbidden' },
+ m3: { type: 'forbidden' },
+ m4: { type: 'notFound' },
+ },
+ },
+ 'u',
+ ],
+ ],
+ ]);
+
+ const result = await provider.updateMessages(
+ ARA,
+ ['m1', 'm2', 'm3', 'm4'],
+ { isFlagged: true },
+ );
+
+ expect(result).toEqual({
+ updatedIds: ['m1'],
+ failedIds: ['m2', 'm3', 'm4'],
+ });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ const [line] = warnSpy.mock.calls[0] as [string];
+ expect(line).toContain('jmapType=forbidden');
+ expect(line).toContain('2통');
+ expect(line).not.toMatch(/m2|m3/);
+ expect(line).not.toContain(ARA);
+ });
+
+ it('엔진이 바꿨다고 알려 준 것만 updatedIds — 응답에 없는 것은 failedIds', async () => {
+ const { provider } = fakeClient([
+ [['Email/set', { updated: { m1: null } }, 'u']],
+ ]);
+
+ await expect(
+ provider.updateMessages(ARA, ['m1', 'm2'], { isRead: false }),
+ ).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
+ });
+
+ it('메서드 단위 실패는 그 코드 — 엔진 장애 MAIL_001', async () => {
+ const { provider } = fakeClient([
+ [['error', { type: 'serverFail' }, 'u']],
+ ]);
+
+ expect(
+ await codeOf(provider.updateMessages(ARA, ['m1'], { isRead: true })),
+ ).toBe('MAIL_001');
+ });
+ });
+
+ describe('휴지통에서 영구 삭제 (deleteFromTrash)', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest
+ .spyOn(Logger.prototype, 'warn')
+ .mockImplementation(() => undefined);
+ });
+
+ afterEach(() => warnSpy.mockRestore());
+
+ /** 확인 요청이 돌려주는 메일 (id · 들어 있는 메일함) */
+ type TrashCandidate = { id: string; mailboxIds: Record };
+
+ /** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다) */
+ const lookup = (
+ emails: TrashCandidate[],
+ state: string | null = 's1',
+ mailboxes: unknown[] = stalwartMailboxes,
+ ): JmapInvocation[] => [
+ [
+ 'Email/get',
+ { ...(state === null ? {} : { state }), list: emails, notFound: [] },
+ 'g',
+ ],
+ ['Mailbox/get', { list: mailboxes }, 'm'],
+ ];
+ const mismatch: JmapInvocation[] = [
+ ['error', { type: 'stateMismatch' }, 'd'],
+ ];
+ /** 휴지통에만 (t1) · 휴지통과 받은편지함에 (t2) · 받은편지함에만 (i1) — 'gone' 은 없다 */
+ const mixed: TrashCandidate[] = [
+ { id: 't1', mailboxIds: { b: true } },
+ { id: 't2', mailboxIds: { b: true, a: true } },
+ { id: 'i1', mailboxIds: { a: true } },
+ ];
+
+ it('휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 — 확인한 상태 그대로일 때 한 번에 (ifInState)', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(mixed),
+ [['Email/set', { destroyed: ['t1'], updated: { t2: null } }, 'd']],
+ ]);
+
+ const result = await provider.deleteFromTrash(ARA, [
+ 't1',
+ 'gone',
+ 't2',
+ 'i1',
+ ]);
+
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Email/get',
+ {
+ accountId: 'd',
+ ids: ['t1', 'gone', 't2', 'i1'],
+ properties: ['id', 'mailboxIds'],
+ },
+ 'g',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId: 'd', ids: null, properties: ['id', 'role'] },
+ 'm',
+ ],
+ ]);
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['t1'],
+ update: { t2: { 'mailboxIds/b': null } },
+ });
+ expect(result).toEqual({
+ deletedIds: ['t1', 't2'],
+ skippedIds: ['gone', 'i1'],
+ });
+ });
+
+ it('휴지통 밖의 메일은 옮기지 않는다 — 지울 것이 없으면 지우는 요청 없이 모두 건너뛴다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([{ id: 'i1', mailboxIds: { a: true } }]),
+ ]);
+
+ await expect(
+ provider.deleteFromTrash(ARA, ['i1', 'gone']),
+ ).resolves.toEqual({ deletedIds: [], skippedIds: ['i1', 'gone'] });
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('휴지통이 없으면 아무것도 지우지 않고 MAIL_001', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(
+ [{ id: 't1', mailboxIds: { a: true } }],
+ 's1',
+ stalwartMailboxes.filter((mailbox) => mailbox.role !== 'trash'),
+ ),
+ ]);
+
+ expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('상태가 어긋나면(그사이 다른 창이 바꿈) 다시 확인해 그때의 대상 · 상태로 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(mixed),
+ mismatch,
+ // 다시 보니 t2 는 휴지통 밖으로 되돌려졌다 — 지우지 않는다
+ lookup(
+ [
+ { id: 't1', mailboxIds: { b: true } },
+ { id: 't2', mailboxIds: { a: true } },
+ ],
+ 's2',
+ ),
+ [['Email/set', { destroyed: ['t1'] }, 'd']],
+ ]);
+
+ const result = await provider.deleteFromTrash(ARA, ['t1', 't2']);
+
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ ifInState: 's2',
+ destroy: ['t1'],
+ });
+ expect(result).toEqual({ deletedIds: ['t1'], skippedIds: ['t2'] });
+ });
+
+ it('세 번 모두 어긋나면 아무것도 지우지 않고 MAIL_001 — 다시 시도하면 된다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(mixed),
+ mismatch,
+ lookup(mixed, 's2'),
+ mismatch,
+ lookup(mixed, 's3'),
+ mismatch,
+ ]);
+
+ expect(await codeOf(provider.deleteFromTrash(ARA, ['t1', 't2']))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(6);
+ // 지우는 요청은 모두 조건부였다 — 엔진이 거절해 아무것도 지워지지 않았다
+ for (const index of [1, 3, 5]) {
+ expect(argsOf(recorded[index])).toHaveProperty('ifInState');
+ }
+ });
+
+ it('지우는 순간 없어졌으면(notFound) 건너뛴 것으로 — 그 밖의 거절도 건너뛰고 종류 · 개수만 경고한다', async () => {
+ const { provider } = fakeClient([
+ lookup([
+ { id: 't1', mailboxIds: { b: true } },
+ { id: 't2', mailboxIds: { b: true } },
+ { id: 't3', mailboxIds: { b: true, a: true } },
+ { id: 't4', mailboxIds: { b: true } },
+ ]),
+ [
+ [
+ 'Email/set',
+ {
+ destroyed: ['t1'],
+ notDestroyed: {
+ t2: { type: 'notFound' },
+ t4: { type: 'forbidden' },
+ },
+ notUpdated: { t3: { type: 'forbidden' } },
+ },
+ 'd',
+ ],
+ ],
+ ]);
+
+ const result = await provider.deleteFromTrash(ARA, [
+ 't1',
+ 't2',
+ 't3',
+ 't4',
+ ]);
+
+ expect(result).toEqual({
+ deletedIds: ['t1'],
+ skippedIds: ['t2', 't3', 't4'],
+ });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ const [line] = warnSpy.mock.calls[0] as [string];
+ expect(line).toContain('[AUTH_003]');
+ expect(line).toContain('jmapType=forbidden');
+ expect(line).toContain('2통');
+ expect(line).not.toMatch(/t3|t4/);
+ });
+
+ it('엔진이 돌려준 id 가 요청한 id 와 다르면(정규화) 지우지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([{ id: 'eaaaaab', mailboxIds: { b: true } }]),
+ ]);
+
+ await expect(provider.deleteFromTrash(ARA, ['EAAAAAB'])).resolves.toEqual(
+ { deletedIds: [], skippedIds: ['EAAAAAB'] },
+ );
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('상태를 모르면 조건 없이 지우지 않는다 (MAIL_001) — 지울 것이 없으면 묻지 않는다', async () => {
+ const unknown = fakeClient([lookup(mixed, null)]);
+ expect(await codeOf(unknown.provider.deleteFromTrash(ARA, ['t1']))).toBe(
+ 'MAIL_001',
+ );
+ expect(unknown.recorded).toHaveLength(1);
+
+ const nothing = fakeClient([
+ lookup([{ id: 'i1', mailboxIds: { a: true } }], null),
+ ]);
+ await expect(
+ nothing.provider.deleteFromTrash(ARA, ['i1']),
+ ).resolves.toEqual({ deletedIds: [], skippedIds: ['i1'] });
+ });
+
+ it('지우기의 그 밖의 메서드 오류는 그 코드 — 조건 없이 다시 지우지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup(mixed),
+ [['error', { type: 'serverFail' }, 'd']],
+ ]);
+
+ expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+ });
+
+ describe('발송 (sendMessage)', () => {
+ const outgoing: OutgoingMessage = {
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [],
+ bcc: [{ name: null, email: SEOJUN }],
+ subject: 'Re: 메일 서버 이전 일정 공유',
+ body: '도윤님, 확인했습니다.',
+ attachments: [
+ {
+ id: 'blob-xlsx',
+ name: '체크리스트.xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ },
+ ],
+ inReplyTo: 'orig',
+ };
+
+ const prepared: JmapInvocation[] = [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ [
+ 'Identity/get',
+ { list: [{ id: 'b', name: '김아라', email: ARA }] },
+ 'i',
+ ],
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'orig',
+ messageId: ['orig@arasystem.test'],
+ references: ['root@arasystem.test'],
+ },
+ ],
+ },
+ 'r',
+ ],
+ ];
+
+ const sent: JmapInvocation[] = [
+ [
+ 'Email/set',
+ { created: { draft: { id: 'maaaaad', blobId: 'x', size: 100 } } },
+ 'e',
+ ],
+ ['EmailSubmission/set', { created: { send: { id: 'b' } } }, 's'],
+ ['Email/set', { updated: { maaaaad: null } }, 's'],
+ ];
+
+ /** 받는 사람별 결과 (EmailSubmission/get — 제출 다음에 따로 읽는다). 개발 엔진이 돌려준 모양 그대로 (2026-10-28) */
+ const deliveryOf = (
+ deliveryStatus: Record>,
+ ): JmapInvocation[] => [
+ [
+ 'EmailSubmission/get',
+ { state: 'sb1', list: [{ id: 'b', deliveryStatus }], notFound: [] },
+ 'd',
+ ],
+ ];
+ const QUEUED = {
+ delivered: 'unknown',
+ smtpReply: '250 2.1.5 Queued',
+ displayed: 'unknown',
+ };
+ const MAILBOX_MISSING = {
+ delivered: 'no',
+ smtpReply: '550 5.1.2 Mailbox does not exist.',
+ displayed: 'unknown',
+ };
+ const RATE_LIMITED = {
+ delivered: 'no',
+ smtpReply: '452 4.4.5 Rate limit exceeded, try again later.',
+ displayed: 'unknown',
+ };
+ /** 받는 사람 · 숨은참조 모두 대기열에 들어갔다 (받은 것) */
+ const queued = deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED });
+
+ /** 첨부 크기 확인 응답 (Blob/get) */
+ const blobSizes = (size: number): JmapInvocation[] => [
+ ['Blob/get', { list: [{ id: 'blob-xlsx', size }], notFound: [] }, 'b'],
+ ];
+ const fits = blobSizes(1000);
+
+ it('초안 생성 → 같은 요청에서 제출 → 보낸편지함 이동 (검증된 형태)', async () => {
+ const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
+
+ await expect(provider.sendMessage(ARA, outgoing)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [],
+ });
+
+ const draft = (argsOf(recorded[2], 0).create as Record)
+ .draft;
+ expect(draft).toEqual({
+ mailboxIds: { d: true },
+ // 발송 중 표시 — 사용자의 임시 저장본 목록에 보이지 않게 (보낸편지함으로 옮길 때 뗀다)
+ keywords: { $draft: true, $seen: true, '$ara-sending': true },
+ from: [{ name: '김아라', email: ARA }],
+ to: [{ name: '이도윤', email: DOYUN }],
+ bcc: [{ name: null, email: 'seojun.park@arasystem.test' }],
+ subject: 'Re: 메일 서버 이전 일정 공유',
+ inReplyTo: ['orig@arasystem.test'],
+ references: ['root@arasystem.test', 'orig@arasystem.test'],
+ bodyValues: { body: { value: '도윤님, 확인했습니다.' } },
+ textBody: [{ partId: 'body', type: 'text/plain' }],
+ attachments: [
+ {
+ blobId: 'blob-xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ name: '체크리스트.xlsx',
+ disposition: 'attachment',
+ },
+ ],
+ });
+ expect(argsOf(recorded[2], 1)).toEqual({
+ accountId: 'd',
+ create: { send: { identityId: 'b', emailId: '#draft' } },
+ onSuccessUpdateEmail: {
+ '#send': {
+ 'mailboxIds/d': null,
+ 'mailboxIds/e': true,
+ 'keywords/$draft': null,
+ 'keywords/$ara-sending': null,
+ },
+ },
+ });
+ // 발송은 큰 요청이라 제한 시간을 늘린다
+ expect(recorded[2].options).toMatchObject({
+ timeoutMs: JMAP_SEND_TIMEOUT_MS,
+ });
+ });
+
+ it('서식 본문이 있으면 textBody + htmlBody 로 만든다 — 엔진이 multipart/alternative 로 보낸다', async () => {
+ const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
+ const html =
+ '';
+
+ await provider.sendMessage(ARA, { ...outgoing, htmlBody: html });
+
+ const draft = (argsOf(recorded[2], 0).create as Record)
+ .draft as Record;
+ expect(draft.bodyValues).toEqual({
+ body: { value: '도윤님, 확인했습니다.' },
+ html: { value: html },
+ });
+ expect(draft.textBody).toEqual([{ partId: 'body', type: 'text/plain' }]);
+ expect(draft.htmlBody).toEqual([{ partId: 'html', type: 'text/html' }]);
+ // 첨부는 그대로 — 엔진이 multipart/mixed 안에 alternative 를 넣는다
+ expect(draft.attachments).toHaveLength(1);
+ });
+
+ it('서식 본문이 없으면 htmlBody 를 만들지 않는다 (텍스트 메일)', async () => {
+ const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
+
+ await provider.sendMessage(ARA, outgoing);
+
+ const draft = (argsOf(recorded[2], 0).create as Record)
+ .draft as Record;
+ expect(draft).not.toHaveProperty('htmlBody');
+ expect(draft.bodyValues).toEqual({
+ body: { value: '도윤님, 확인했습니다.' },
+ });
+ });
+
+ it('중복 방지 Message-ID 와 내용 지문을 초안에 싣는다 (지문은 사용자 헤더)', async () => {
+ const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
+
+ await provider.sendMessage(ARA, {
+ ...outgoing,
+ messageId: 'ara.0123abcd@arasystem.test',
+ fingerprint: 'v1:feedface',
+ });
+
+ const draft = (
+ argsOf(recorded[2], 0).create as Record>
+ ).draft;
+ expect(draft.messageId).toEqual(['ara.0123abcd@arasystem.test']);
+ expect(draft['header:X-Ara-Payload-Hash:asText']).toBe('v1:feedface');
+ });
+
+ it('첨부 크기는 Blob/get 으로 잰다 (blob 기능은 이 호출에만, 해석 못 하는 id 는 MAIL_004)', async () => {
+ const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
+
+ await provider.sendMessage(ARA, outgoing);
+
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Blob/get',
+ { accountId: 'd', ids: ['blob-xlsx'], properties: ['size'] },
+ 'b',
+ ],
+ ]);
+ expect(recorded[1].options).toEqual({
+ capabilities: ['urn:ietf:params:jmap:blob'],
+ invalidRequestCode: 'MAIL_004',
+ });
+ });
+
+ it('첨부가 없으면 크기를 재지 않는다', async () => {
+ const { provider, recorded } = fakeClient([prepared, sent, queued]);
+
+ await provider.sendMessage(ARA, { ...outgoing, attachments: [] });
+
+ // 준비 · 보내기 · 받는 사람별 결과
+ expect(recorded).toHaveLength(3);
+ });
+
+ it('첨부 합계가 설정 한도(25MB)를 넘으면 메일을 만들지 않고 413 FILE_002 — 같은 첨부 두 번은 두 번 센다', async () => {
+ const { provider, recorded } = fakeClient([prepared, blobSizes(13 * MB)]);
+ const twice = {
+ ...outgoing,
+ attachments: [outgoing.attachments[0], outgoing.attachments[0]],
+ };
+
+ const error = await provider
+ .sendMessage(ARA, twice)
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect(error).toBeInstanceOf(BusinessException);
+ expect((error as BusinessException).code).toBe('FILE_002');
+ expect((error as BusinessException).context).toMatchObject({
+ totalBytes: 26 * MB,
+ limitBytes: 25 * MB,
+ });
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('mail-quota 가 알려 주는 합계 한도와 발송 검사의 한도가 같다 (한 함수로 정한다)', async () => {
+ const small = { ...session, maxAttachmentsBytes: 3 * MB };
+ const { provider } = fakeClient([prepared, blobSizes(3 * MB + 1)], {
+ session: small,
+ });
+
+ const advertised = await provider.getAttachmentsTotalLimit(ARA);
+ const error = await provider
+ .sendMessage(ARA, outgoing)
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect(advertised).toBe(3 * MB);
+ expect((error as BusinessException).code).toBe('FILE_002');
+ expect((error as BusinessException).context).toMatchObject({
+ limitBytes: advertised,
+ });
+ });
+
+ it('엔진의 첨부 한도가 더 작으면 그 값을 쓴다', async () => {
+ const { provider } = fakeClient([prepared, blobSizes(2 * MB)], {
+ session: { ...session, maxAttachmentsBytes: MB },
+ });
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'FILE_002',
+ );
+ });
+
+ it('엔진에서 찾을 수 없는 첨부가 있으면 메일을 만들지 않고 MAIL_004', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ [['Blob/get', { list: [], notFound: ['blob-xlsx'] }, 'b']],
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'MAIL_004',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('제출이 거부되면(받는 사람 없음) 초안을 지우고 MAIL_002', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ fits,
+ [
+ ['Email/set', { created: { draft: { id: 'maaaaad' } } }, 'e'],
+ [
+ 'EmailSubmission/set',
+ { notCreated: { send: { type: 'noRecipients' } } },
+ 's',
+ ],
+ ],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'MAIL_002',
+ );
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ destroy: ['maaaaad'],
+ });
+ });
+
+ it('제출 메서드 자체가 실패해도 초안을 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ fits,
+ [
+ ['Email/set', { created: { draft: { id: 'maaaaad' } } }, 'e'],
+ ['error', { type: 'forbiddenToSend' }, 's'],
+ ],
+ [['Email/set', { destroyed: ['maaaaad'] }, 'x']],
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'AUTH_003',
+ );
+ expect(recorded).toHaveLength(4);
+ });
+
+ it('초안 생성이 거부되면(용량 초과) 지울 것 없이 MAIL_003', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ fits,
+ [
+ ['Email/set', { notCreated: { draft: { type: 'overQuota' } } }, 'e'],
+ [
+ 'EmailSubmission/set',
+ { notCreated: { send: { type: 'invalidProperties' } } },
+ 's',
+ ],
+ ],
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'MAIL_003',
+ );
+ expect(recorded).toHaveLength(3);
+ });
+
+ const draftRejected = (
+ error: Record,
+ ): JmapInvocation[] => [
+ ['Email/set', { notCreated: { draft: error } }, 'e'],
+ [
+ 'EmailSubmission/set',
+ { notCreated: { send: { type: 'invalidProperties' } } },
+ 's',
+ ],
+ ];
+
+ it('엔진이 첨부 blob 을 찾지 못하면(만료된 업로드 등) MAIL_004 — Stalwart 는 invalidProperties 로 알린다', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ fits,
+ draftRejected({
+ type: 'invalidProperties',
+ properties: ['attachments/blobId'],
+ }),
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'MAIL_004',
+ );
+ });
+
+ it('표준 blobNotFound 도 MAIL_004', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ fits,
+ draftRejected({ type: 'blobNotFound', notFound: ['blob-xlsx'] }),
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe(
+ 'MAIL_004',
+ );
+ });
+
+ it('첨부와 무관한 속성 오류는 VAL_001 그대로', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ fits,
+ draftRejected({
+ type: 'invalidProperties',
+ properties: ['to/0/email'],
+ }),
+ ]);
+
+ expect(await codeOf(provider.sendMessage(ARA, outgoing))).toBe('VAL_001');
+ });
+
+ it('답장할 원 메일이 없으면 스레드 헤더 없이 보낸다', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ prepared[0],
+ prepared[1],
+ ['Email/get', { list: [], notFound: ['orig'] }, 'r'],
+ ],
+ fits,
+ sent,
+ queued,
+ ]);
+ const warn = jest
+ .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
+ .mockImplementation(() => undefined);
+
+ await provider.sendMessage(ARA, outgoing);
+
+ const draft = (
+ argsOf(recorded[2], 0).create as Record>
+ ).draft;
+ expect(draft).not.toHaveProperty('inReplyTo');
+ expect(warn).toHaveBeenCalled();
+ });
+
+ /** 계약 2절 "받는 사람별 결과" (6단계 앞 수정) — 엔진이 그 자리에서 거절한 받는 사람 */
+ describe('받는 사람별 결과', () => {
+ // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
+ afterEach(() => jest.restoreAllMocks());
+
+ const textOnly: OutgoingMessage = { ...outgoing, attachments: [] };
+ /** 보낸 사본 지우기 응답 */
+ const discarded: JmapInvocation[] = [
+ ['Email/set', { destroyed: ['maaaaad'] }, 'x'],
+ ];
+ const everyoneMissing = deliveryOf({
+ [DOYUN]: MAILBOX_MISSING,
+ [SEOJUN]: MAILBOX_MISSING,
+ });
+
+ const spyLogger = (provider: JmapMailProvider, level: 'log' | 'warn') =>
+ jest
+ .spyOn(
+ Reflect.get(provider, 'logger') as Record<
+ 'log' | 'warn',
+ (message: string) => void
+ >,
+ level,
+ )
+ .mockImplementation(() => undefined);
+ const logged = (spy: jest.SpyInstance) =>
+ spy.mock.calls.map(([message]) => String(message)).join('\n');
+
+ /** 엔진 호출마다 시간이 흐르게 한다 — n 번째 호출이 끝나면 delays[n - 1] 만큼 */
+ const elapseOnCalls = (
+ fake: ReturnType,
+ delays: readonly number[],
+ clock: { now: number },
+ ) => {
+ const answer = fake.client.call.getMockImplementation();
+ if (!answer) throw new Error('호출 대역이 없습니다');
+ fake.client.call.mockImplementation((...args) => {
+ const reply = answer(...args);
+ clock.now += delays[fake.recorded.length - 1] ?? 0;
+ return reply;
+ });
+ jest.spyOn(Date, 'now').mockImplementation(() => clock.now);
+ };
+
+ it('제출 다음에 따로 읽는다 — 제출 id 로 deliveryStatus 만, 짧은 제한 시간(5초)으로 (같은 요청 안의 참조는 엔진이 풀지 못한다)', async () => {
+ const { provider, recorded } = fakeClient([prepared, sent, queued]);
+
+ await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [],
+ });
+
+ expect(recorded[1].calls.map(([name]) => name)).toEqual([
+ 'Email/set',
+ 'EmailSubmission/set',
+ ]);
+ expect(recorded[2].calls).toEqual([
+ [
+ 'EmailSubmission/get',
+ { accountId: 'd', ids: ['b'], properties: ['deliveryStatus'] },
+ 'd',
+ ],
+ ]);
+ expect(recorded[2].options).toEqual({ timeoutMs: 5_000 });
+ expect(recorded).toHaveLength(3);
+ });
+
+ it('일부만 받지 않았으면 보낸 것이다 — 받지 않은 사람만 돌려주고 보낸 사본은 그대로, log 에는 가린 주소와 상태 코드만', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sent,
+ deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: QUEUED }),
+ ]);
+ const log = spyLogger(provider, 'log');
+
+ await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [{ email: DOYUN, reason: 'not-found' }],
+ });
+
+ // 지우지 않았다
+ expect(recorded).toHaveLength(3);
+ expect(logged(log)).toContain('받지 않은 사람 1명');
+ expect(logged(log)).toContain('d***@arasystem.test(550 5.1.2)');
+ expect(logged(log)).not.toMatch(/doyun|Mailbox does not exist/);
+ });
+
+ it('분류 표 — 5.1.1 · 5.1.2 는 not-found, 5.7.1 은 refused, 452 4.4.5 는 temporary, 250(대기열) · 결과 없음은 받은 것, 대소문자가 다른 주소도 요청에 쓴 그대로', async () => {
+ const many: OutgoingMessage = {
+ ...textOnly,
+ to: [
+ { name: null, email: 'a@arasystem.test' },
+ { name: null, email: 'b@arasystem.test' },
+ ],
+ cc: [
+ { name: null, email: 'c@example.com' },
+ { name: null, email: 'd@example.com' },
+ ],
+ bcc: [
+ { name: null, email: 'e@example.com' },
+ { name: null, email: 'f@example.com' },
+ { name: '김지', email: 'G.Kim@AraSystem.Test' },
+ ],
+ };
+ const { provider } = fakeClient([
+ prepared,
+ sent,
+ deliveryOf({
+ 'a@arasystem.test': {
+ delivered: 'no',
+ smtpReply: '550 5.1.1 User unknown',
+ },
+ 'b@arasystem.test': MAILBOX_MISSING,
+ 'c@example.com': {
+ delivered: 'no',
+ smtpReply: '554 5.7.1 Relay access denied',
+ },
+ 'd@example.com': RATE_LIMITED,
+ 'e@example.com': QUEUED,
+ 'g.kim@arasystem.test': MAILBOX_MISSING,
+ }),
+ ]);
+ spyLogger(provider, 'log');
+
+ await expect(provider.sendMessage(ARA, many)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [
+ { email: 'a@arasystem.test', reason: 'not-found' },
+ { email: 'b@arasystem.test', reason: 'not-found' },
+ { email: 'c@example.com', reason: 'refused' },
+ { email: 'd@example.com', reason: 'temporary' },
+ { email: 'G.Kim@AraSystem.Test', reason: 'not-found' },
+ ],
+ });
+ });
+
+ it('아무도 받지 않았으면(5xx 가 섞임) 이 요청이 만든 보낸 사본을 조건 없이 지우고 400 MAIL_010 — warn 에 코드 · 가린 주소 · 상태 코드만', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sent,
+ deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: RATE_LIMITED }),
+ discarded,
+ ]);
+ const warn = spyLogger(provider, 'warn');
+
+ const error = await provider
+ .sendMessage(ARA, textOnly)
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect(error).toBeInstanceOf(BusinessException);
+ expect((error as BusinessException).code).toBe('MAIL_010');
+ expect((error as BusinessException).context).toEqual({
+ reason: 'all-recipients-rejected',
+ rejected: 2,
+ copyDiscarded: true,
+ });
+ // 방금 만든 메일이라 조건(ifInState) 없이 — 만들기와 다른 요청으로
+ expect(recorded[3].calls).toEqual([
+ ['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
+ ]);
+ expect(recorded).toHaveLength(4);
+ const warned = logged(warn);
+ expect(warned).toContain('[MAIL_010]');
+ expect(warned).toContain('d***@arasystem.test(550 5.1.2)');
+ expect(warned).toContain('s***@arasystem.test(452 4.4.5)');
+ expect(warned).not.toMatch(/doyun|seojun|Mailbox does not exist|Rate/);
+ });
+
+ it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 보낸 사본을 지우는 것은 같다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared,
+ sent,
+ deliveryOf({ [DOYUN]: RATE_LIMITED, [SEOJUN]: RATE_LIMITED }),
+ discarded,
+ ]);
+ const warn = spyLogger(provider, 'warn');
+
+ expect(await codeOf(provider.sendMessage(ARA, textOnly))).toBe(
+ 'MAIL_011',
+ );
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ destroy: ['maaaaad'],
+ });
+ expect(logged(warn)).toContain('[MAIL_011]');
+ });
+
+ it('같은 주소가 받는 사람과 숨은참조에 함께 있어도 한 사람이다 — 그 사람이 받지 않았으면 모두 거절', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ sent,
+ deliveryOf({ [DOYUN]: MAILBOX_MISSING }),
+ discarded,
+ ]);
+ spyLogger(provider, 'warn');
+
+ expect(
+ await codeOf(
+ provider.sendMessage(ARA, {
+ ...textOnly,
+ bcc: [{ name: null, email: DOYUN.toUpperCase() }],
+ }),
+ ),
+ ).toBe('MAIL_010');
+ });
+
+ it('보낸 사본을 지우지 못해도 MAIL_010 그대로 — 경고만 (보낸편지함에 한 통이 남는다)', async () => {
+ const { provider } = fakeClient([
+ prepared,
+ sent,
+ everyoneMissing,
+ [
+ [
+ 'Email/set',
+ { notDestroyed: { maaaaad: { type: 'forbidden' } } },
+ 'x',
+ ],
+ ],
+ ]);
+ const warn = spyLogger(provider, 'warn');
+
+ const error = await provider
+ .sendMessage(ARA, textOnly)
+ .catch((caught: unknown) => caught as BusinessException);
+
+ expect((error as BusinessException).code).toBe('MAIL_010');
+ expect((error as BusinessException).context).toMatchObject({
+ copyDiscarded: false,
+ });
+ expect(logged(warn)).toContain(
+ '받는 사람이 모두 받지 않은 보낸 사본을 지우지 못했습니다 — 보낸편지함에 한 통이 남습니다',
+ );
+ });
+
+ it('결과를 읽지 못하면(메서드 오류 · 호출 실패 · 제출 id 없음) 받은 것으로 본다 — 빈 배열, 경고 (제출은 이미 되었다)', async () => {
+ const methodError: JmapInvocation[] = [
+ ['error', { type: 'serverFail' }, 'd'],
+ ];
+ const withoutId: JmapInvocation[] = [
+ sent[0],
+ ['EmailSubmission/set', { created: { send: {} } }, 's'],
+ sent[2],
+ ];
+
+ for (const [responses, calls] of [
+ [[prepared, sent, methodError], 3],
+ // 준비한 응답이 없으면 대역의 호출이 실패한다
+ [[prepared, sent], 3],
+ [[prepared, withoutId], 2],
+ ] as const) {
+ const { provider, recorded } = fakeClient([...responses]);
+ const warn = spyLogger(provider, 'warn');
+
+ await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
+ id: 'maaaaad',
+ rejectedRecipients: [],
+ });
+ expect(recorded).toHaveLength(calls);
+ expect(logged(warn)).toContain(
+ '받는 사람별 발송 결과를 읽지 못해 모두 받은 것으로 봅니다',
+ );
+ }
+ });
+
+ it('결과 읽기는 발송 전체 시간(55초) 안에서 보낸 사본을 지울 3초를 남기고 — 지우기는 남은 시간으로', async () => {
+ const fake = fakeClient([prepared, sent, everyoneMissing, discarded]);
+ // 준비에 20초, 보내기(초안 + 제출)에 31초가 걸렸다 — 남은 시간은 4초
+ elapseOnCalls(fake, [20_000, 31_000], { now: 1_000_000 });
+ spyLogger(fake.provider, 'warn');
+
+ expect(await codeOf(fake.provider.sendMessage(ARA, textOnly))).toBe(
+ 'MAIL_010',
+ );
+ // 보내기 = 55 - 20 = 35초, 결과 읽기 = 4 - 3 = 1초, 지우기 = 남은 4초
+ expect(fake.recorded[1].options).toMatchObject({ timeoutMs: 35_000 });
+ expect(fake.recorded[2].options).toEqual({ timeoutMs: 1_000 });
+ expect(fake.recorded[3].options).toEqual({ timeoutMs: 4_000 });
+ });
+
+ it('결과를 읽을 시간이 남지 않았으면 묻지 않고 받은 것으로 본다 — 경고', async () => {
+ const fake = fakeClient([prepared, sent]);
+ // 준비에 20초, 보내기에 33초 — 남은 2초에서 지울 시간 3초를 남기면 읽을 시간이 없다
+ elapseOnCalls(fake, [20_000, 33_000], { now: 1_000_000 });
+ const warn = spyLogger(fake.provider, 'warn');
+
+ await expect(fake.provider.sendMessage(ARA, textOnly)).resolves.toEqual(
+ { id: 'maaaaad', rejectedRecipients: [] },
+ );
+ expect(fake.recorded).toHaveLength(2);
+ expect(logged(warn)).toContain('reason=no-time');
+ });
+ });
+
+ describe('서명 그림 (inlineImages — multipart/related)', () => {
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
+ const html =
+ '김아라 드림
';
+ const signatureImage = {
+ cid: 'sig-1@arasystem.test',
+ type: 'image/png',
+ name: 'signature-1.png',
+ data: png,
+ };
+ const withImage: OutgoingMessage = {
+ ...outgoing,
+ htmlBody: html,
+ inlineImages: [signatureImage],
+ };
+
+ // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
+ afterEach(() => jest.restoreAllMocks());
+ const alternative = {
+ type: 'multipart/alternative',
+ subParts: [
+ { partId: 'body', type: 'text/plain' },
+ { partId: 'html', type: 'text/html' },
+ ],
+ };
+ const inlinePart = {
+ blobId: 'blob-sig',
+ type: 'image/png',
+ name: 'signature-1.png',
+ cid: 'sig-1@arasystem.test',
+ disposition: 'inline',
+ };
+
+ it('그림을 엔진에 올리고 mixed[related[alternative[text, html], 그림], 첨부] 로 만든다', async () => {
+ const { provider, recorded, client } = fakeClient([
+ prepared,
+ fits,
+ sent,
+ queued,
+ ]);
+ client.upload.mockResolvedValue({
+ blobId: 'blob-sig',
+ type: 'image/png',
+ size: png.length,
+ });
+
+ await provider.sendMessage(ARA, withImage);
+
+ expect(client.upload).toHaveBeenCalledTimes(1);
+ const [who, blob, type] = client.upload.mock.calls[0] as [
+ string,
+ Blob,
+ string,
+ ];
+ expect(who).toBe(ARA);
+ expect(type).toBe('image/png');
+ expect(blob.type).toBe('image/png');
+ expect(Buffer.from(await blob.arrayBuffer())).toEqual(png);
+
+ const draft = (
+ argsOf(recorded[2], 0).create as Record<
+ string,
+ Record
+ >
+ ).draft;
+ // 구조를 직접 적을 때는 textBody·htmlBody·attachments 를 함께 쓰지 않는다 (RFC 8621 §4.6)
+ expect(draft).not.toHaveProperty('textBody');
+ expect(draft).not.toHaveProperty('htmlBody');
+ expect(draft).not.toHaveProperty('attachments');
+ expect(draft.bodyValues).toEqual({
+ body: { value: '도윤님, 확인했습니다.' },
+ html: { value: html },
+ });
+ expect(draft.bodyStructure).toEqual({
+ type: 'multipart/mixed',
+ subParts: [
+ {
+ type: 'multipart/related',
+ subParts: [alternative, inlinePart],
+ },
+ {
+ blobId: 'blob-xlsx',
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ name: '체크리스트.xlsx',
+ disposition: 'attachment',
+ },
+ ],
+ });
+ });
+
+ it('첨부가 없으면 related 가 맨 위다 — 그림은 첨부 합계에 넣지 않는다 (Blob/get 없음)', async () => {
+ const { provider, recorded, client } = fakeClient([
+ prepared,
+ sent,
+ queued,
+ ]);
+ client.upload.mockResolvedValue({
+ blobId: 'blob-sig',
+ type: 'image/png',
+ size: png.length,
+ });
+
+ await provider.sendMessage(ARA, { ...withImage, attachments: [] });
+
+ expect(recorded).toHaveLength(3);
+ const draft = (
+ argsOf(recorded[1], 0).create as Record<
+ string,
+ Record
+ >
+ ).draft;
+ expect(draft.bodyStructure).toEqual({
+ type: 'multipart/related',
+ subParts: [alternative, inlinePart],
+ });
+ });
+
+ it('서명 그림은 한꺼번에, 짧은 제한 시간으로 올린다 — 일반 업로드의 120초를 쓰지 않는다', async () => {
+ const { provider, client } = fakeClient([prepared, fits, sent, queued]);
+ let inFlight = 0;
+ let maxInFlight = 0;
+ client.upload.mockImplementation(async () => {
+ inFlight += 1;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ await new Promise((resolve) => setImmediate(resolve));
+ inFlight -= 1;
+ return { blobId: `blob-${inFlight}`, type: 'image/png', size: 8 };
+ });
+
+ await provider.sendMessage(ARA, {
+ ...withImage,
+ inlineImages: [
+ signatureImage,
+ { ...signatureImage, cid: 'sig-2@arasystem.test' },
+ { ...signatureImage, cid: 'sig-3@arasystem.test' },
+ ],
+ });
+
+ expect(maxInFlight).toBe(3);
+ const options = (client.upload.mock.calls as unknown[][]).map(
+ (call) => call[3],
+ );
+ expect(options).toEqual([
+ { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS },
+ { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS },
+ { timeoutMs: JMAP_INLINE_UPLOAD_TIMEOUT_MS },
+ ]);
+ });
+
+ it('발송 전체 시간(55초) 안에서 남은 만큼만 기다린다 — 보내기에 10초도 남지 않으면 보내지 않고 MAIL_001', async () => {
+ let now = 1_000_000;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ const { provider, recorded, client } = fakeClient([prepared, fits]);
+ client.upload.mockImplementation(() => {
+ // 그림을 올리는 데 50초가 걸렸다 — 보내기에 남은 시간은 5초
+ now += 50_000;
+ return Promise.resolve({
+ blobId: 'blob-sig',
+ type: 'image/png',
+ size: 8,
+ });
+ });
+
+ expect(await codeOf(provider.sendMessage(ARA, withImage))).toBe(
+ 'MAIL_001',
+ );
+ // 초안을 만들지도(보내지도) 않았다 — 같은 키로 다시 보내면 된다
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('앞 단계가 늦었으면 보내기 제한 시간도 남은 시간으로 줄인다', async () => {
+ let now = 1_000_000;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ const { provider, recorded, client } = fakeClient([
+ prepared,
+ fits,
+ sent,
+ queued,
+ ]);
+ client.upload.mockImplementation(() => {
+ now += 25_000;
+ return Promise.resolve({
+ blobId: 'blob-sig',
+ type: 'image/png',
+ size: 8,
+ });
+ });
+
+ await provider.sendMessage(ARA, withImage);
+
+ // 55초 - 25초 = 30초 (기본 40초보다 짧다)
+ expect(recorded[2].options).toMatchObject({ timeoutMs: 30_000 });
+ });
+
+ it('그림을 올리지 못하면 메일을 만들지 않는다', async () => {
+ const { provider, recorded, client } = fakeClient([prepared, fits]);
+ client.upload.mockRejectedValue(
+ new BusinessException('MAIL_001', { context: { reason: 'upload' } }),
+ );
+
+ expect(await codeOf(provider.sendMessage(ARA, withImage))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+ });
+ });
+
+ describe('이미 보낸 메일 찾기 (findSentMessage — 중복 발송 방지)', () => {
+ const MESSAGE_ID = 'ara.0123abcd@arasystem.test';
+ const mailboxes: JmapInvocation[] = [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ];
+ const recent = (list: Record[]): JmapInvocation[] => [
+ ['Email/query', { ids: list.map((email) => email.id) }, 'q'],
+ ['Email/get', { list }, 'g'],
+ ];
+
+ it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다 — 받는 사람별 결과는 읽지 않는다 (지문이 맞을 때 따로)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ recent([
+ { id: 'other', messageId: ['someone@else'], keywords: {} },
+ {
+ id: 'sent1',
+ messageId: [MESSAGE_ID],
+ keywords: { $seen: true },
+ 'header:X-Ara-Payload-Hash:asText': ' v1:feedface ',
+ },
+ ]),
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'sent1',
+ fingerprint: 'v1:feedface',
+ });
+ expect(argsOf(recorded[1], 0)).toEqual({
+ accountId: 'd',
+ filter: {
+ operator: 'OR',
+ conditions: [{ inMailbox: 'e' }, { inMailbox: 'd' }],
+ },
+ sort: [{ property: 'receivedAt', isAscending: false }],
+ limit: 200,
+ });
+ expect(argsOf(recorded[1], 1)).toMatchObject({
+ properties: [
+ 'id',
+ 'messageId',
+ 'keywords',
+ 'header:X-Ara-Payload-Hash:asText',
+ ],
+ });
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('없으면 null', async () => {
+ const { provider } = fakeClient([
+ mailboxes,
+ recent([{ id: 'other', messageId: ['someone@else'] }]),
+ ]);
+
+ await expect(
+ provider.findSentMessage(ARA, MESSAGE_ID),
+ ).resolves.toBeNull();
+ });
+
+ it('지문 헤더가 없으면 fingerprint: null', async () => {
+ const { provider } = fakeClient([
+ mailboxes,
+ recent([{ id: 'sent1', messageId: [MESSAGE_ID], keywords: {} }]),
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'sent1',
+ fingerprint: null,
+ });
+ });
+
+ it('제출되지 못하고 남은 초안은 지우고 없는 것으로 본다 (다시 보내도 된다)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ recent([
+ {
+ id: 'draft1',
+ messageId: [MESSAGE_ID],
+ keywords: { $draft: true },
+ },
+ ]),
+ [['EmailSubmission/query', { ids: [] }, 's']],
+ [['Email/set', { destroyed: ['draft1'] }, 'x']],
+ ]);
+
+ await expect(
+ provider.findSentMessage(ARA, MESSAGE_ID),
+ ).resolves.toBeNull();
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ filter: { emailIds: ['draft1'] },
+ });
+ expect(argsOf(recorded[3])).toEqual({
+ accountId: 'd',
+ destroy: ['draft1'],
+ });
+ });
+
+ it('초안이라도 제출 기록이 있으면 보낸 메일이다 (보낸편지함 이동만 실패한 경우)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ recent([
+ {
+ id: 'draft1',
+ messageId: [MESSAGE_ID],
+ keywords: { $draft: true },
+ 'header:X-Ara-Payload-Hash:asText': 'v1:feedface',
+ },
+ ]),
+ [['EmailSubmission/query', { ids: ['sub1'] }, 's']],
+ ]);
+
+ await expect(provider.findSentMessage(ARA, MESSAGE_ID)).resolves.toEqual({
+ id: 'draft1',
+ fingerprint: 'v1:feedface',
+ });
+ expect(recorded).toHaveLength(3);
+ });
+ });
+
+ /** 계약 2절 "받는 사람별 결과" — 같은 키의 재시도가 처음 결과를 돌려줄 때 */
+ describe('이미 보낸 메일의 받지 않은 사람 (getRejectedRecipients)', () => {
+ /** 이 요청(같은 키의 재시도)의 받는 사람 */
+ const RECIPIENTS = {
+ to: [{ name: '이도윤', email: 'DoYun.Lee@AraSystem.Test' }],
+ cc: [],
+ bcc: [{ name: null, email: SEOJUN }],
+ };
+ /** 처음 발송의 제출 기록 — 찾기(query)와 읽기(get)를 한 요청으로 */
+ const submissionsOf = (
+ deliveryStatus: Record>,
+ ): JmapInvocation[] => [
+ ['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
+ ['EmailSubmission/get', { list: [{ id: 'sb1', deliveryStatus }] }, 'g'],
+ ];
+ const QUEUED_REPLY = {
+ delivered: 'unknown',
+ smtpReply: '250 2.1.5 Queued',
+ };
+ const MISSING_REPLY = {
+ delivered: 'no',
+ smtpReply: '550 5.1.2 Mailbox does not exist.',
+ };
+
+ it('그 메일의 제출을 찾아(emailIds) 같은 요청에서 결과를 읽고, 이 요청의 받는 사람과 맞춘다 — 요청에 쓴 주소 그대로', async () => {
+ const { provider, recorded } = fakeClient([
+ submissionsOf({ [DOYUN]: MISSING_REPLY, [SEOJUN]: QUEUED_REPLY }),
+ ]);
+
+ await expect(
+ provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
+ ).resolves.toEqual([
+ { email: 'DoYun.Lee@AraSystem.Test', reason: 'not-found' },
+ ]);
+ expect(recorded[0].calls).toEqual([
+ [
+ 'EmailSubmission/query',
+ { accountId: 'd', filter: { emailIds: ['sent1'] } },
+ 'q',
+ ],
+ [
+ 'EmailSubmission/get',
+ {
+ accountId: 'd',
+ '#ids': {
+ resultOf: 'q',
+ name: 'EmailSubmission/query',
+ path: '/ids',
+ },
+ properties: ['deliveryStatus'],
+ },
+ 'g',
+ ],
+ ]);
+ });
+
+ it('모두 받았으면 빈 배열', async () => {
+ const { provider } = fakeClient([
+ submissionsOf({ [DOYUN]: QUEUED_REPLY, [SEOJUN]: QUEUED_REPLY }),
+ ]);
+
+ await expect(
+ provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
+ ).resolves.toEqual([]);
+ });
+
+ it('제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다, 읽지 못한 것은 경고)', async () => {
+ const missing: JmapInvocation[] = [
+ ['EmailSubmission/query', { ids: [] }, 'q'],
+ ['EmailSubmission/get', { list: [], notFound: [] }, 'g'],
+ ];
+ const unresolved: JmapInvocation[] = [
+ ['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
+ ['error', { type: 'invalidResultReference' }, 'g'],
+ ];
+ const cases: [JmapInvocation[] | null, string | null][] = [
+ [missing, null],
+ [unresolved, 'reason=MAIL_001/invalidResultReference'],
+ // 준비한 응답이 없으면 대역의 호출이 실패한다
+ [null, 'reason=Error'],
+ ];
+
+ for (const [reading, warned] of cases) {
+ const { provider } = fakeClient(reading ? [reading] : []);
+ const warn = jest
+ .spyOn(
+ Reflect.get(provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+
+ await expect(
+ provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
+ ).resolves.toEqual([]);
+ if (warned) {
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(warned));
+ } else {
+ expect(warn).not.toHaveBeenCalled();
+ }
+ }
+ });
+
+ it('모두 거절된 사본을 지우지 못한 채 남았으면 — 같은 키 · 같은 내용의 재시도는 그 사본을 찾고(지문이 같다) 받는 사람 전원을 받지 않은 사람으로 돌려준다', async () => {
+ const outgoing: OutgoingMessage = {
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [],
+ bcc: [],
+ subject: '제목',
+ body: '본문',
+ attachments: [],
+ messageId: 'ara.0123abcd@arasystem.test',
+ fingerprint: 'v2:feedface',
+ };
+ const { provider } = fakeClient([
+ // 처음 발송 — 모두 거절, 보낸 사본은 지우지 못했다
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ [
+ 'Identity/get',
+ { list: [{ id: 'b', name: '김아라', email: ARA }] },
+ 'i',
+ ],
+ ],
+ [
+ ['Email/set', { created: { draft: { id: 'maaaaad' } } }, 'e'],
+ ['EmailSubmission/set', { created: { send: { id: 'b' } } }, 's'],
+ ['Email/set', { updated: { maaaaad: null } }, 's'],
+ ],
+ [
+ [
+ 'EmailSubmission/get',
+ { list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
+ 'd',
+ ],
+ ],
+ [['error', { type: 'serverFail' }, 'x']],
+ // 같은 키의 재시도 — 보낸편지함에 남은 사본을 찾는다
+ [['Mailbox/get', { list: stalwartMailboxes }, 'm']],
+ [
+ ['Email/query', { ids: ['maaaaad'] }, 'q'],
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'maaaaad',
+ messageId: ['ara.0123abcd@arasystem.test'],
+ keywords: { $seen: true },
+ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface',
+ },
+ ],
+ },
+ 'g',
+ ],
+ ],
+ [
+ ['EmailSubmission/query', { ids: ['b'] }, 'q'],
+ [
+ 'EmailSubmission/get',
+ { list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
+ 'g',
+ ],
+ ],
+ ]);
+ jest
+ .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
+ .mockImplementation(() => undefined);
+
+ const first = await provider
+ .sendMessage(ARA, outgoing)
+ .catch((caught: unknown) => caught as BusinessException);
+ expect((first as BusinessException).code).toBe('MAIL_010');
+ expect((first as BusinessException).context).toMatchObject({
+ copyDiscarded: false,
+ });
+
+ await expect(
+ provider.findSentMessage(ARA, 'ara.0123abcd@arasystem.test'),
+ ).resolves.toEqual({ id: 'maaaaad', fingerprint: 'v2:feedface' });
+ await expect(
+ provider.getRejectedRecipients(ARA, 'maaaaad', outgoing),
+ ).resolves.toEqual([{ email: DOYUN, reason: 'not-found' }]);
+ });
+ });
+
+ /** 계약 2절 "임시 저장" — 사용자의 임시 저장본 */
+ describe('임시 저장 (saveDraft)', () => {
+ const XLSX =
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ const content: DraftContent = {
+ to: [],
+ cc: [{ name: '이도윤', email: DOYUN }],
+ bcc: [],
+ subject: '쓰는 중',
+ body: '본문',
+ htmlBody: '본문
',
+ attachments: [{ id: 'blob-xlsx', name: '체크리스트.xlsx', type: XLSX }],
+ };
+
+ const prepared = (...extra: JmapInvocation[]): JmapInvocation[] => [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ [
+ 'Identity/get',
+ { list: [{ id: 'b', name: '김아라', email: ARA }] },
+ 'i',
+ ],
+ ...extra,
+ ];
+ /** 바꿔 저장할 옛 초안 확인 — 준비와 따로 묻는다 (그때의 Email 상태 's1') */
+ const oldCheck = (list: Record[]): JmapInvocation[] => [
+ [
+ 'Email/get',
+ { state: 's1', list, notFound: list.length > 0 ? [] : ['old1'] },
+ 'old',
+ ],
+ ];
+ const userDraft = (extra: Record = {}) => ({
+ id: 'old1',
+ mailboxIds: { d: true },
+ keywords: { $draft: true, $seen: true },
+ ...extra,
+ });
+ const fits: JmapInvocation[] = [
+ [
+ 'Blob/get',
+ { list: [{ id: 'blob-xlsx', size: 1000 }], notFound: [] },
+ 'b',
+ ],
+ ];
+ /** 만들기 — 응답의 상태('s9')는 지우기 조건으로 쓰지 않는다 */
+ const created: JmapInvocation[] = [
+ [
+ 'Email/set',
+ { oldState: 's1', newState: 's9', created: { draft: { id: 'new1' } } },
+ 'c',
+ ],
+ ];
+ const readBack: JmapInvocation[] = [
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'new1',
+ receivedAt: '2026-10-27T01:00:00Z',
+ attachments: [
+ {
+ partId: '3',
+ blobId: 'blob-new',
+ name: '체크리스트.xlsx',
+ type: XLSX,
+ size: 1000,
+ disposition: 'attachment',
+ },
+ ],
+ },
+ ],
+ },
+ 'g',
+ ],
+ ];
+ const destroyed = (id: string): JmapInvocation[] => [
+ ['Email/set', { destroyed: [id] }, 'x'],
+ ];
+ const notDestroyed = (id: string, type: string): JmapInvocation[] => [
+ ['Email/set', { notDestroyed: { [id]: { type } } }, 'x'],
+ ];
+ /** 지우기가 거절됐다 — 확인한 뒤 계정의 메일이 바뀌었다 (ifInState) */
+ const mismatch: JmapInvocation[] = [
+ ['error', { type: 'stateMismatch' }, 'x'],
+ ];
+ /** 지우기 전에 다시 확인한 옛 초안 — 그때의 상태 */
+ const recheck = (
+ list: Record[],
+ state: string,
+ ): JmapInvocation[] => [
+ [
+ 'Email/get',
+ { state, list, notFound: list.length > 0 ? [] : ['old1'] },
+ 'g',
+ ],
+ ];
+ const conditionalDestroy = (id: string, ifInState: string) => ({
+ accountId: 'd',
+ ifInState,
+ destroy: [id],
+ });
+ const saved = {
+ id: 'new1',
+ attachments: [
+ { id: 'blob-new', name: '체크리스트.xlsx', type: XLSX, size: 1000 },
+ ],
+ updatedAt: '2026-10-27T01:00:00.000Z',
+ };
+
+ const silenceWarnings = (provider: JmapMailProvider) =>
+ jest
+ .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
+ .mockImplementation(() => undefined);
+
+ it('처음 저장 — 임시보관함에 $draft 로 만들고, 다시 읽어 새 첨부 id 와 저장 시각을 돌려준다 (중복 방지 헤더·발송 표시 없음)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ fits,
+ created,
+ readBack,
+ ]);
+
+ await expect(provider.saveDraft(ARA, content)).resolves.toEqual(saved);
+
+ const draft = (argsOf(recorded[2]).create as Record)
+ .draft;
+ // 받는 사람이 없으면 to 를 쓰지 않는다 — 발송 지문·Message-ID·발송 중 표시도 없다
+ expect(draft).toEqual({
+ mailboxIds: { d: true },
+ keywords: { $draft: true, $seen: true },
+ from: [{ name: '김아라', email: ARA }],
+ cc: [{ name: '이도윤', email: DOYUN }],
+ subject: '쓰는 중',
+ bodyValues: {
+ body: { value: '본문' },
+ html: { value: '본문
' },
+ },
+ textBody: [{ partId: 'body', type: 'text/plain' }],
+ htmlBody: [{ partId: 'html', type: 'text/html' }],
+ attachments: [
+ {
+ blobId: 'blob-xlsx',
+ type: XLSX,
+ name: '체크리스트.xlsx',
+ disposition: 'attachment',
+ },
+ ],
+ });
+ // 저장은 첨부를 복사하는 큰 요청이다
+ expect(recorded[2].options).toMatchObject({
+ timeoutMs: JMAP_SEND_TIMEOUT_MS,
+ });
+ expect(argsOf(recorded[3])).toMatchObject({
+ ids: ['new1'],
+ properties: ['id', 'receivedAt', 'attachments'],
+ });
+ // 바꾸는 것이 아니면 아무것도 지우지 않는다
+ expect(recorded).toHaveLength(4);
+ });
+
+ it('답장 초안이면 스레드 헤더와 답장 대상 id 를 남긴다 — 원 메일이 없으면 둘 다 없다', async () => {
+ const original: JmapInvocation = [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'orig',
+ messageId: ['orig@arasystem.test'],
+ references: ['root@arasystem.test'],
+ },
+ ],
+ },
+ 'r',
+ ];
+ const reply = { ...content, attachments: [], inReplyTo: 'orig' };
+ const first = fakeClient([prepared(original), created, readBack]);
+
+ await first.provider.saveDraft(ARA, reply);
+
+ const draft = (
+ argsOf(first.recorded[1]).create as Record<
+ string,
+ Record
+ >
+ ).draft;
+ expect(draft).toMatchObject({
+ inReplyTo: ['orig@arasystem.test'],
+ references: ['root@arasystem.test', 'orig@arasystem.test'],
+ 'header:X-Ara-Reply-To-Id:asText': 'orig',
+ });
+
+ const gone = fakeClient([
+ prepared(['Email/get', { list: [], notFound: ['orig'] }, 'r']),
+ created,
+ readBack,
+ ]);
+ await gone.provider.saveDraft(ARA, reply);
+ const orphan = (
+ argsOf(gone.recorded[1]).create as Record<
+ string,
+ Record
+ >
+ ).draft;
+ expect(orphan).not.toHaveProperty('inReplyTo');
+ expect(orphan).not.toHaveProperty('header:X-Ara-Reply-To-Id:asText');
+ });
+
+ it('바꿔 저장 — 옛것을 따로 확인하고, 새것을 만들어 다시 읽은 뒤, 지우기 직전에 다시 확인한 상태로 옛것을 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ destroyed('old1'),
+ ]);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+
+ expect(argsOf(recorded[1])).toMatchObject({
+ ids: ['old1'],
+ properties: [
+ 'id',
+ 'mailboxIds',
+ 'keywords',
+ 'header:X-Ara-Payload-Hash:asText',
+ ],
+ });
+ expect(recorded[3].calls.map(([name]) => name)).toEqual(['Email/set']);
+ expect(argsOf(recorded[3])).not.toHaveProperty('destroy');
+ // 만들기 응답의 newState('s9')는 조건으로 쓰지 않는다 — 그 사이 다른 요청의 변경이 끼었을 수 있다 (RFC 8620 은 보장하지 않는다)
+ expect(argsOf(recorded[5])).toMatchObject({
+ ids: ['old1'],
+ properties: [
+ 'id',
+ 'mailboxIds',
+ 'keywords',
+ 'header:X-Ara-Payload-Hash:asText',
+ ],
+ });
+ expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2'));
+ expect(recorded).toHaveLength(7);
+ });
+
+ it('옛 초안 확인은 준비 요청과 따로 묻는다 — 엔진이 그 id 를 거부하면 VAL_001 이 아니라 RES_001 (클라이언트는 새로 POST 한다)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ destroyed('old1'),
+ ]);
+
+ await provider.saveDraft(ARA, content, 'old1');
+
+ expect(recorded[0].calls.map(([name]) => name)).toEqual([
+ 'Mailbox/get',
+ 'Identity/get',
+ ]);
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
+ expect(recorded[1].calls.map(([name]) => name)).toEqual(['Email/get']);
+ expect(recorded[1].options).toEqual({ invalidRequestCode: 'RES_001' });
+ });
+
+ it('만드는 사이 옛것이 휴지통으로 옮겨졌으면 지우지 않는다 — 새 저장본은 그대로, 저장은 성공 (경고)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ // 큰 첨부를 붙여 만드는 동안 다른 탭이 옛것을 휴지통으로 옮겼다 — 그 이동이 newState 에 들어 있다
+ created,
+ readBack,
+ recheck([userDraft({ mailboxIds: { b: true } })], 's2'),
+ ]);
+ const warn = silenceWarnings(provider);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+ // 지우기 요청을 보내지 않았다 (휴지통의 메일을 영구 삭제하지 않는다)
+ expect(recorded).toHaveLength(6);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '옛 임시 저장본이 그사이 다른 곳으로 옮겨져 지우지 않았습니다',
+ ),
+ );
+ });
+
+ it('지우기가 어긋나면(stateMismatch) 다시 확인하고, 여전히 사용자 초안이면 그 상태로 다시 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ mismatch,
+ recheck([userDraft()], 's3'),
+ destroyed('old1'),
+ ]);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+ expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2'));
+ expect(argsOf(recorded[8])).toEqual(conditionalDestroy('old1', 's3'));
+ expect(recorded).toHaveLength(9);
+ });
+
+ it('어긋난 뒤 다시 보니 옛것이 없어졌으면 방금 만든 것을 지우고 409 MAIL_008', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ mismatch,
+ recheck([], 's3'),
+ destroyed('new1'),
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_008',
+ );
+ // 방금 만든 것은 이 요청이 만든 초안이라 조건 없이 지운다
+ expect(argsOf(recorded[8])).toEqual({
+ accountId: 'd',
+ destroy: ['new1'],
+ });
+ });
+
+ it('어긋난 뒤 다시 보니 옛것이 휴지통으로 옮겨졌으면 지우지 않는다 — 저장은 성공 (경고)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ mismatch,
+ recheck([userDraft({ mailboxIds: { b: true } })], 's3'),
+ ]);
+ const warn = silenceWarnings(provider);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+ expect(recorded).toHaveLength(8);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '옛 임시 저장본이 그사이 다른 곳으로 옮겨져 지우지 않았습니다',
+ ),
+ );
+ });
+
+ it('지우기가 세 번 모두 어긋나면 더 지우지 않는다 — 저장은 성공 (경고, 두 통이 남는다)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ mismatch,
+ recheck([userDraft()], 's3'),
+ mismatch,
+ recheck([userDraft()], 's4'),
+ mismatch,
+ ]);
+ const warn = silenceWarnings(provider);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+ const destroys = recorded.filter(
+ (record) =>
+ record.calls[0][0] === 'Email/set' && 'destroy' in argsOf(record),
+ );
+ expect(destroys.map((record) => argsOf(record).ifInState)).toEqual([
+ 's2',
+ 's3',
+ 's4',
+ ]);
+ expect(recorded).toHaveLength(11);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '확인하는 사이 메일함이 계속 바뀌어 옛 임시 저장본을 지우지 않았습니다',
+ ),
+ );
+ });
+
+ it('옛 초안이 이미 없으면 아무것도 만들지 않고 409 MAIL_008', async () => {
+ const { provider, recorded } = fakeClient([prepared(), oldCheck([])]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_008',
+ );
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('임시보관함의 사용자 초안이 아니면 아무것도 지우지 않고 404 RES_001 — 휴지통·보낸 메일·발송 중인 내부 초안·다른 메일함에도 있는 초안', async () => {
+ for (const email of [
+ userDraft({ mailboxIds: { b: true } }),
+ // 임시보관함과 받은편지함에 함께 — 지우면 받은편지함의 사본까지 사라진다
+ userDraft({ mailboxIds: { d: true, a: true } }),
+ userDraft({ keywords: { $seen: true } }),
+ userDraft({
+ keywords: { $draft: true, $seen: true, '$ara-sending': true },
+ }),
+ userDraft({ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface' }),
+ ]) {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([email]),
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'RES_001',
+ );
+ expect(recorded).toHaveLength(2);
+ }
+ });
+
+ it('새것을 만들지 못하면 옛것을 지우지 않는다 — 용량 초과 MAIL_003 · 없는 첨부 MAIL_004', async () => {
+ const cases: [Record, string][] = [
+ [{ type: 'overQuota' }, 'MAIL_003'],
+ [
+ { type: 'invalidProperties', properties: ['attachments/blobId'] },
+ 'MAIL_004',
+ ],
+ ];
+ for (const [error, code] of cases) {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ [['Email/set', { notCreated: { draft: error } }, 'c']],
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ code,
+ );
+ expect(recorded).toHaveLength(4);
+ }
+ });
+
+ it('첨부 합계가 한도를 넘으면 만들지 않는다 (413 FILE_002)', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ [
+ [
+ 'Blob/get',
+ { list: [{ id: 'blob-xlsx', size: 26 * MB }], notFound: [] },
+ 'b',
+ ],
+ ],
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content))).toBe('FILE_002');
+ expect(recorded).toHaveLength(2);
+ });
+
+ it('옛것이 지우기 직전 확인 뒤 없어졌으면(notFound) 방금 만든 것을 지우고 409 MAIL_008', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ notDestroyed('old1', 'notFound'),
+ destroyed('new1'),
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_008',
+ );
+ expect(argsOf(recorded[6])).toEqual(conditionalDestroy('old1', 's2'));
+ expect(argsOf(recorded[7])).toEqual({
+ accountId: 'd',
+ destroy: ['new1'],
+ });
+ });
+
+ it('지우기 직전 확인에서 옛것이 이미 없으면 방금 만든 것을 지우고 409 MAIL_008', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([], 's2'),
+ destroyed('new1'),
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_008',
+ );
+ expect(argsOf(recorded[6])).toEqual({
+ accountId: 'd',
+ destroy: ['new1'],
+ });
+ expect(recorded).toHaveLength(7);
+ });
+
+ it('방금 만든 것을 지우지 못해도 MAIL_008 — 경고만 남긴다 (임시보관함에 한 통이 더 남는다)', async () => {
+ const { provider } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ notDestroyed('old1', 'notFound'),
+ notDestroyed('new1', 'forbidden'),
+ ]);
+ const warn = silenceWarnings(provider);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_008',
+ );
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('방금 만든 임시 저장본을 지우지 못했습니다'),
+ );
+ });
+
+ it('옛것을 다른 이유로 지우지 못하면 저장은 성공이다 — 경고만 (두 통이 남을 수 있다)', async () => {
+ const { provider } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ readBack,
+ recheck([userDraft()], 's2'),
+ notDestroyed('old1', 'forbidden'),
+ ]);
+ const warn = silenceWarnings(provider);
+
+ await expect(provider.saveDraft(ARA, content, 'old1')).resolves.toEqual(
+ saved,
+ );
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('옛 임시 저장본을 지우지 못해'),
+ );
+ });
+
+ it('만든 것을 다시 읽지 못하면(엔진 이상 — MAIL_001) 그것을 지우고, 옛것은 그대로 둔다', async () => {
+ const { provider, recorded } = fakeClient([
+ prepared(),
+ oldCheck([userDraft()]),
+ fits,
+ created,
+ [['Email/get', { list: [], notFound: ['new1'] }, 'g']],
+ destroyed('new1'),
+ ]);
+
+ expect(await codeOf(provider.saveDraft(ARA, content, 'old1'))).toBe(
+ 'MAIL_001',
+ );
+ expect(argsOf(recorded[5])).toEqual({
+ accountId: 'd',
+ destroy: ['new1'],
+ });
+ expect(recorded).toHaveLength(6);
+ });
+ });
+
+ describe('이어 쓸 임시 저장본 (getDraft)', () => {
+ const draftEmail = (extra: Record = {}) => ({
+ ...summaryEmail('dr1', '2026-10-27T01:00:00Z', {
+ mailboxIds: { d: true },
+ keywords: { $draft: true, $seen: true },
+ }),
+ cc: [],
+ bcc: [{ name: null, email: 'seojun.park@arasystem.test' }],
+ replyTo: [],
+ sentAt: null,
+ messageId: ['dr1@arasystem.test'],
+ references: null,
+ bodyValues: {
+ '1': { value: '텍스트 본문', isTruncated: false },
+ '2': { value: '서식 본문
', isTruncated: false },
+ },
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [{ partId: '2', type: 'text/html' }],
+ attachments: [],
+ ...extra,
+ });
+ const read = (email: Record): JmapInvocation[] => [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [email] }, 'g'],
+ ];
+
+ it('초안의 받는 사람·제목·본문 원문·첨부와 저장 시각을 돌려준다 (본문 값을 함께 읽는다)', async () => {
+ const { provider, recorded } = fakeClient([read(draftEmail())]);
+
+ await expect(provider.getDraft(ARA, 'dr1')).resolves.toEqual({
+ id: 'dr1',
+ to: [{ name: null, email: DOYUN }],
+ cc: [],
+ bcc: [{ name: null, email: 'seojun.park@arasystem.test' }],
+ subject: '제목 dr1',
+ textBody: '텍스트 본문',
+ rawHtmlBody: '서식 본문
',
+ attachments: [],
+ inReplyTo: null,
+ updatedAt: '2026-10-27T01:00:00.000Z',
+ });
+ expect(argsOf(recorded[0], 1)).toMatchObject({
+ ids: ['dr1'],
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
+ });
+ expect(
+ (argsOf(recorded[0], 1) as { properties: string[] }).properties,
+ ).toEqual(
+ expect.arrayContaining([
+ 'keywords',
+ 'mailboxIds',
+ 'header:X-Ara-Payload-Hash:asText',
+ 'header:X-Ara-Reply-To-Id:asText',
+ ]),
+ );
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ });
+
+ it('답장 대상은 그 메일이 아직 있을 때만 돌려준다', async () => {
+ const replying = draftEmail({
+ 'header:X-Ara-Reply-To-Id:asText': ' orig ',
+ });
+ const found = fakeClient([
+ read(replying),
+ [['Email/get', { list: [{ id: 'orig' }], notFound: [] }, 'r']],
+ ]);
+ await expect(found.provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({
+ inReplyTo: 'orig',
+ });
+ expect(argsOf(found.recorded[1])).toEqual({
+ accountId: 'd',
+ ids: ['orig'],
+ properties: ['id'],
+ });
+
+ const gone = fakeClient([
+ read(replying),
+ [['Email/get', { list: [], notFound: ['orig'] }, 'r']],
+ ]);
+ await expect(gone.provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({
+ inReplyTo: null,
+ });
+
+ // 형식이 틀린 값은 엔진에 묻지 않는다
+ const malformed = fakeClient([
+ read(draftEmail({ 'header:X-Ara-Reply-To-Id:asText': '../x' })),
+ ]);
+ await expect(
+ malformed.provider.getDraft(ARA, 'dr1'),
+ ).resolves.toMatchObject({ inReplyTo: null });
+ expect(malformed.recorded).toHaveLength(1);
+ });
+
+ it('임시보관함의 사용자 초안이 아니면 RES_001 — 보낸 메일·휴지통의 초안·다른 메일함에도 있는 초안·발송 중인 내부 초안', async () => {
+ for (const email of [
+ draftEmail({ mailboxIds: { e: true }, keywords: { $seen: true } }),
+ draftEmail({ mailboxIds: { b: true } }),
+ // 임시보관함과 다른 메일함에 함께 있는 메일은 사용자 초안으로 보지 않는다
+ draftEmail({ mailboxIds: { d: true, a: true } }),
+ draftEmail({
+ keywords: { $draft: true, $seen: true, '$ara-sending': true },
+ }),
+ draftEmail({ 'header:X-Ara-Payload-Hash:asText': 'v2:feedface' }),
+ ]) {
+ const { provider } = fakeClient([read(email)]);
+
+ expect(await codeOf(provider.getDraft(ARA, 'dr1'))).toBe('RES_001');
+ }
+ });
+
+ it('없는 메일은 RES_001', async () => {
+ const { provider } = fakeClient([
+ [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { list: [], notFound: ['dr1'] }, 'g'],
+ ],
+ ]);
+
+ expect(await codeOf(provider.getDraft(ARA, 'dr1'))).toBe('RES_001');
+ });
+ });
+
+ describe('보낸 초안 지우기 (deleteDraft)', () => {
+ /** 메일함 + 초안 확인 — 그때의 Email 상태 's1' */
+ const lookup = (list: Record[]): JmapInvocation[] => [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ['Email/get', { state: 's1', list }, 'g'],
+ ];
+ const draft = {
+ id: 'dr1',
+ mailboxIds: { d: true },
+ keywords: { $draft: true },
+ };
+ const mismatch: JmapInvocation[] = [
+ ['error', { type: 'stateMismatch' }, 'x'],
+ ];
+ const recheck = (
+ list: Record[],
+ state: string,
+ ): JmapInvocation[] => [['Email/get', { state, list }, 'g']];
+ const destroyedDraft: JmapInvocation[] = [
+ ['Email/set', { destroyed: ['dr1'] }, 'x'],
+ ];
+ const silence = (provider: JmapMailProvider) =>
+ jest
+ .spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
+ .mockImplementation(() => undefined);
+
+ it('사용자 초안이면 확인한 상태 그대로일 때만 지운다 (ifInState)', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([draft]),
+ destroyedDraft,
+ ]);
+
+ await provider.deleteDraft(ARA, 'dr1');
+
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['dr1'],
+ });
+ });
+
+ it('어긋나면 다시 확인하고 그 상태로 다시 지운다 — 그사이 옮겨졌거나 없어졌으면 지우지 않는다', async () => {
+ const retried = fakeClient([
+ lookup([draft]),
+ mismatch,
+ recheck([draft], 's2'),
+ destroyedDraft,
+ ]);
+ await retried.provider.deleteDraft(ARA, 'dr1');
+ expect(argsOf(retried.recorded[3])).toEqual({
+ accountId: 'd',
+ ifInState: 's2',
+ destroy: ['dr1'],
+ });
+
+ const gone = fakeClient([lookup([draft]), mismatch, recheck([], 's2')]);
+ await expect(gone.provider.deleteDraft(ARA, 'dr1')).resolves.toBe(
+ undefined,
+ );
+ expect(gone.recorded).toHaveLength(3);
+
+ const moved = fakeClient([
+ lookup([draft]),
+ mismatch,
+ recheck([{ ...draft, mailboxIds: { b: true } }], 's2'),
+ ]);
+ const warn = silence(moved.provider);
+ await expect(moved.provider.deleteDraft(ARA, 'dr1')).resolves.toBe(
+ undefined,
+ );
+ expect(moved.recorded).toHaveLength(3);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '그사이 다른 곳으로 옮겨진 메일이라 지우지 않습니다',
+ ),
+ );
+ });
+
+ it('세 번 모두 어긋나면 포기한다 — 던지지 않고 경고만 (발송은 성공)', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([draft]),
+ mismatch,
+ recheck([draft], 's2'),
+ mismatch,
+ recheck([draft], 's3'),
+ mismatch,
+ ]);
+ const warn = silence(provider);
+
+ await expect(provider.deleteDraft(ARA, 'dr1')).resolves.toBe(undefined);
+ expect(recorded).toHaveLength(6);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /^\[MAIL_001\].*확인하는 사이 메일함이 계속 바뀌어/,
+ ),
+ );
+ });
+
+ it('다른 메일함에도 들어 있는 초안은 지우지 않는다 — 지우면 그 메일함의 사본까지 사라진다', async () => {
+ const { provider, recorded } = fakeClient([
+ lookup([{ ...draft, mailboxIds: { d: true, a: true } }]),
+ ]);
+ const warn = silence(provider);
+
+ await provider.deleteDraft(ARA, 'dr1');
+
+ expect(recorded).toHaveLength(1);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('임시 저장본이 아닌 메일이라 지우지 않습니다'),
+ );
+ });
+
+ it('이미 없으면 아무것도 하지 않고, 초안이 아니면(보낸 메일 id 등) 지우지 않는다 — 경고만', async () => {
+ const gone = fakeClient([lookup([])]);
+ await expect(gone.provider.deleteDraft(ARA, 'dr1')).resolves.toBe(
+ undefined,
+ );
+ expect(gone.recorded).toHaveLength(1);
+
+ const sentMail = fakeClient([
+ lookup([{ ...draft, mailboxIds: { e: true }, keywords: {} }]),
+ ]);
+ const warn = jest
+ .spyOn(
+ Reflect.get(sentMail.provider, 'logger') as { warn: () => void },
+ 'warn',
+ )
+ .mockImplementation(() => undefined);
+ await sentMail.provider.deleteDraft(ARA, 'sent1');
+ expect(sentMail.recorded).toHaveLength(1);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('임시 저장본이 아닌 메일이라 지우지 않습니다'),
+ );
+ });
+
+ it('지우는 사이 없어졌으면(notFound) 성공, 다른 거절은 실패를 던진다', async () => {
+ const raced = fakeClient([
+ lookup([draft]),
+ [['Email/set', { notDestroyed: { dr1: { type: 'notFound' } } }, 'x']],
+ ]);
+ await expect(raced.provider.deleteDraft(ARA, 'dr1')).resolves.toBe(
+ undefined,
+ );
+
+ const refused = fakeClient([
+ lookup([draft]),
+ [['Email/set', { notDestroyed: { dr1: { type: 'forbidden' } } }, 'x']],
+ ]);
+ expect(await codeOf(refused.provider.deleteDraft(ARA, 'dr1'))).toBe(
+ 'AUTH_003',
+ );
+ });
+ });
+
+ /** 계약 2절 "받는 사람 추천" — 그 직원 자신의 메일함에서만 */
+ describe('주고받은 주소 (listCorrespondents)', () => {
+ const mailboxes: JmapInvocation[] = [
+ ['Mailbox/get', { list: stalwartMailboxes }, 'm'],
+ ];
+
+ it('보낸편지함 최근 메일의 받는 사람·참조·숨은참조와 받은편지함 최근 메일의 보낸 사람을 한 요청으로 모은다 (본문은 읽지 않는다)', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxes,
+ [
+ ['Email/query', { ids: ['s1'] }, 'sq'],
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 's1',
+ receivedAt: '2026-10-26T05:00:00Z',
+ to: [{ name: '이도윤', email: DOYUN }],
+ cc: [{ name: null, email: 'seojun.park@arasystem.test' }],
+ bcc: null,
+ },
+ ],
+ },
+ 'sg',
+ ],
+ ['Email/query', { ids: ['r1'] }, 'rq'],
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'r1',
+ receivedAt: '2026-10-25T05:00:00+09:00',
+ from: [{ name: '고객', email: 'client@partner.test' }],
+ },
+ ],
+ },
+ 'rg',
+ ],
+ ],
+ ]);
+
+ await expect(
+ provider.listCorrespondents(ARA, { sent: 500, received: 300 }),
+ ).resolves.toEqual([
+ { name: '이도윤', email: DOYUN, at: '2026-10-26T05:00:00.000Z' },
+ {
+ name: null,
+ email: 'seojun.park@arasystem.test',
+ at: '2026-10-26T05:00:00.000Z',
+ },
+ {
+ name: '고객',
+ email: 'client@partner.test',
+ at: '2026-10-24T20:00:00.000Z',
+ },
+ ]);
+
+ expect(recorded).toHaveLength(2);
+ expect(argsOf(recorded[1], 0)).toEqual({
+ accountId: 'd',
+ filter: { inMailbox: 'e' },
+ sort: [{ property: 'receivedAt', isAscending: false }],
+ limit: 500,
+ });
+ expect(argsOf(recorded[1], 1)).toMatchObject({
+ properties: ['to', 'cc', 'bcc', 'receivedAt'],
+ });
+ expect(argsOf(recorded[1], 2)).toMatchObject({
+ filter: { inMailbox: 'a' },
+ limit: 300,
+ });
+ expect(argsOf(recorded[1], 3)).toMatchObject({
+ properties: ['from', 'receivedAt'],
+ });
+ });
+
+ it('역할이 있는 메일함이 없으면 묻지 않는다', async () => {
+ const { provider, recorded } = fakeClient([
+ [['Mailbox/get', { list: [stalwartMailboxes[0]] }, 'm']],
+ ]);
+
+ await expect(
+ provider.listCorrespondents(ARA, { sent: 500, received: 500 }),
+ ).resolves.toEqual([]);
+ expect(recorded).toHaveLength(1);
+ });
+ });
+
+ describe('스팸 신고 · 스팸 아님 (isJunk)', () => {
+ it('true 면 $junk 를 붙이고 $notjunk 를 뗀다 — 이동과 같은 패치 하나로 (한 통)', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Email/set', { updated: { m1: null } }, 'u'],
+ [
+ 'Email/get',
+ {
+ list: [
+ summaryEmail('m1', '2026-10-28T01:00:00Z', {
+ mailboxIds: { c: true },
+ }),
+ ],
+ },
+ 'g',
+ ],
+ ],
+ ]);
+
+ await provider.updateMessage(ARA, 'm1', { mailboxId: 'c', isJunk: true });
+
+ expect(argsOf(recorded[0])).toEqual({
+ accountId: 'd',
+ update: {
+ m1: {
+ 'keywords/$junk': true,
+ 'keywords/$notjunk': null,
+ mailboxIds: { c: true },
+ },
+ },
+ });
+ });
+
+ it('false 면 반대 — 여러 통도 메일마다 같은 패치', async () => {
+ const { provider, recorded } = fakeClient([
+ [
+ ['Mailbox/get', { list: [{ id: 'a' }] }, 'mb'],
+ ['Email/set', { updated: { m1: null, m2: null } }, 'u'],
+ ],
+ ]);
+
+ await provider.updateMessages(ARA, ['m1', 'm2'], {
+ mailboxId: 'a',
+ isJunk: false,
+ });
+
+ const patch = {
+ 'keywords/$junk': null,
+ 'keywords/$notjunk': true,
+ mailboxIds: { a: true },
+ };
+ expect(argsOf(recorded[0], 1)).toEqual({
+ accountId: 'd',
+ update: { m1: patch, m2: patch },
+ });
+ });
+ });
+
+ describe('메일함 관리 (계약 2절 "메일함 관리")', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest
+ .spyOn(Logger.prototype, 'warn')
+ .mockImplementation(() => undefined);
+ });
+
+ // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
+ afterEach(() => jest.restoreAllMocks());
+
+ const userMailbox = (
+ id: string,
+ name: string,
+ extra: Record = {},
+ ) => ({
+ id,
+ name,
+ role: null,
+ sortOrder: 0,
+ parentId: null,
+ totalEmails: 0,
+ unreadEmails: 0,
+ ...extra,
+ });
+ /** 기본 메일함 + 보관함(f) + 내 메일함 — u1 아래에 u2 가 있다, u4 에 메일이 있다 */
+ const mailboxes = [
+ ...stalwartMailboxes,
+ userMailbox('f', 'Archive', { role: 'archive' }),
+ userMailbox('u1', '프로젝트', { totalEmails: 2, unreadEmails: 1 }),
+ userMailbox('u2', '하위', { parentId: 'u1' }),
+ userMailbox('u3', '빈 메일함'),
+ userMailbox('u4', '보관 자료', { totalEmails: 2, unreadEmails: 2 }),
+ ];
+ const mailboxList = (list: unknown[] = mailboxes): JmapInvocation[] => [
+ ['Mailbox/get', { list }, 'm'],
+ ];
+
+ /** 메일함 한 차례 — 조회(전체 수) + 메일(들어 있는 메일함)과 그때의 Email 상태 */
+ type Candidate = { id: string; mailboxIds: Record };
+ const batch = (
+ emails: Candidate[],
+ state: string | null = 's1',
+ total: number = emails.length,
+ ): JmapInvocation[] => [
+ ['Email/query', { ids: emails.map((email) => email.id), total }, 'q'],
+ [
+ 'Email/get',
+ { ...(state === null ? {} : { state }), list: emails, notFound: [] },
+ 'g',
+ ],
+ ];
+ const only = (id: string, mailboxId: string): Candidate => ({
+ id,
+ mailboxIds: { [mailboxId]: true },
+ });
+ const mismatch: JmapInvocation[] = [
+ ['error', { type: 'stateMismatch' }, 's'],
+ ];
+ const ids = (prefix: string, count: number) =>
+ Array.from({ length: count }, (_, index) => `${prefix}${index}`);
+
+ /** n 번째 엔진 호출이 끝난 뒤 시계를 넘긴다 — 한 요청의 시간(25초) 확인용 */
+ function advanceClockAfterCall(
+ fake: ReturnType,
+ callNumber: number,
+ ms: number,
+ ): void {
+ let now = 1_000_000;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ const call = fake.client.call.getMockImplementation();
+ if (!call) throw new Error('call 구현이 없다');
+ fake.client.call.mockImplementation((...args) => {
+ const answer = call(...args);
+ if (fake.recorded.length === callNumber) now += ms;
+ return answer;
+ });
+ }
+
+ /** n 번째 엔진 호출이 시계를 ms 만큼 넘긴 뒤 error 로 끝난다 — 남은 시간 때문에 끊긴 호출 확인용 (그 호출은 기록되지 않는다) */
+ function failCallWith(
+ fake: ReturnType,
+ callNumber: number,
+ ms: number,
+ error: Error,
+ ): void {
+ let now = 1_000_000;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ const call = fake.client.call.getMockImplementation();
+ if (!call) throw new Error('call 구현이 없다');
+ let count = 0;
+ fake.client.call.mockImplementation((...args) => {
+ count += 1;
+ if (count === callNumber) {
+ now += ms;
+ return Promise.reject(error);
+ }
+ return call(...args);
+ });
+ }
+
+ describe('만들기 (createMailbox)', () => {
+ const created = userMailbox('n1', '거래처');
+
+ it('메일함 목록으로 상위를 확인하고, 구독한 메일함으로 만든 뒤 같은 요청에서 다시 읽어 돌려준다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'],
+ ['Mailbox/get', { list: [...mailboxes, created] }, 'm'],
+ ],
+ ]);
+
+ await expect(
+ provider.createMailbox(ARA, { name: '거래처', parentId: null }),
+ ).resolves.toEqual({
+ id: 'n1',
+ name: '거래처',
+ role: null,
+ parentId: null,
+ sortOrder: 0,
+ totalCount: 0,
+ unreadCount: 0,
+ });
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Mailbox/get',
+ { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES },
+ 'm',
+ ],
+ ]);
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Mailbox/set',
+ {
+ accountId: 'd',
+ create: {
+ mailbox: { name: '거래처', parentId: null, isSubscribed: true },
+ },
+ },
+ 'c',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES },
+ 'm',
+ ],
+ ]);
+ });
+
+ it('받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에 만들 수 있다', async () => {
+ for (const parentId of ['a', 'f', 'u1']) {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'],
+ [
+ 'Mailbox/get',
+ { list: [...mailboxes, { ...created, parentId }] },
+ 'm',
+ ],
+ ],
+ ]);
+
+ const mailbox = await provider.createMailbox(ARA, {
+ name: '거래처',
+ parentId,
+ });
+
+ expect(mailbox.parentId).toBe(parentId);
+ expect(argsOf(recorded[1])).toMatchObject({
+ create: { mailbox: { parentId } },
+ });
+ }
+ });
+
+ it('보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 만들지 않고 VAL_001, 없는 상위는 RES_001', async () => {
+ for (const parentId of ['e', 'd', 'c', 'b']) {
+ const { provider, recorded } = fakeClient([mailboxList()]);
+ expect(
+ await codeOf(
+ provider.createMailbox(ARA, { name: '거래처', parentId }),
+ ),
+ ).toBe('VAL_001');
+ expect(recorded).toHaveLength(1);
+ }
+
+ const missing = fakeClient([mailboxList()]);
+ expect(
+ await codeOf(
+ missing.provider.createMailbox(ARA, {
+ name: '거래처',
+ parentId: 'nope',
+ }),
+ ),
+ ).toBe('RES_001');
+ expect(missing.recorded).toHaveLength(1);
+ });
+
+ it('같은 상위 안에 같은 이름(alreadyExists)은 MAIL_009, 그사이 상위가 없어짐(parentId 거절)은 RES_001, 이름 거절은 VAL_001', async () => {
+ const cases = [
+ [{ type: 'alreadyExists' }, 'MAIL_009'],
+ [{ type: 'invalidProperties', properties: ['parentId'] }, 'RES_001'],
+ [{ type: 'invalidProperties', properties: ['name'] }, 'VAL_001'],
+ [{ type: 'serverFail' }, 'MAIL_001'],
+ ] as const;
+
+ for (const [setError, code] of cases) {
+ const { provider } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { notCreated: { mailbox: setError } }, 'c'],
+ ['Mailbox/get', { list: mailboxes }, 'm'],
+ ],
+ ]);
+ expect(
+ await codeOf(
+ provider.createMailbox(ARA, { name: '프로젝트', parentId: null }),
+ ),
+ ).toBe(code);
+ }
+ });
+
+ it('다시 읽은 목록에 없으면(있을 수 없는 경우) 보낸 값으로 답한다 — 만들기는 성공했다', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { created: { mailbox: { id: 'n1' } } }, 'c'],
+ ['Mailbox/get', { list: mailboxes }, 'm'],
+ ],
+ ]);
+
+ await expect(
+ provider.createMailbox(ARA, { name: '거래처', parentId: 'a' }),
+ ).resolves.toMatchObject({ id: 'n1', name: '거래처', parentId: 'a' });
+ });
+ });
+
+ describe('이름 바꾸기 (renameMailbox)', () => {
+ it('그 메일함만 이름을 바꾸고 같은 요청에서 다시 읽어 돌려준다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { updated: { u1: null } }, 'u'],
+ [
+ 'Mailbox/get',
+ {
+ list: [
+ userMailbox('u1', '새 이름', {
+ totalEmails: 2,
+ unreadEmails: 1,
+ }),
+ ],
+ },
+ 'm',
+ ],
+ ],
+ ]);
+
+ await expect(
+ provider.renameMailbox(ARA, 'u1', '새 이름'),
+ ).resolves.toMatchObject({ id: 'u1', name: '새 이름', totalCount: 2 });
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Mailbox/set',
+ { accountId: 'd', update: { u1: { name: '새 이름' } } },
+ 'u',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId: 'd', ids: ['u1'], properties: MAILBOX_PROPERTIES },
+ 'm',
+ ],
+ ]);
+ });
+
+ it('기본 메일함(역할 있음 — 우리가 모르는 역할 포함)은 VAL_001, 없는 메일함은 RES_001 — 엔진에 바꾸기를 보내지 않는다', async () => {
+ const withUnknownRole = [
+ ...mailboxes,
+ userMailbox('i1', 'Important', { role: 'important' }),
+ ];
+ for (const [id, code] of [
+ ['a', 'VAL_001'],
+ ['f', 'VAL_001'],
+ ['i1', 'VAL_001'],
+ ['nope', 'RES_001'],
+ ] as const) {
+ const { provider, recorded } = fakeClient([
+ mailboxList(withUnknownRole),
+ ]);
+ expect(await codeOf(provider.renameMailbox(ARA, id, '새 이름'))).toBe(
+ code,
+ );
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => {
+ const { provider, recorded } = fakeClient([mailboxList()]);
+
+ await expect(
+ provider.renameMailbox(ARA, 'u1', '프로젝트'),
+ ).resolves.toMatchObject({ id: 'u1', name: '프로젝트' });
+ expect(recorded).toHaveLength(1);
+ });
+
+ it('같은 이름이 있으면(alreadyExists) MAIL_009, 그사이 없어졌으면 RES_001', async () => {
+ for (const [setError, code] of [
+ [{ type: 'alreadyExists' }, 'MAIL_009'],
+ [{ type: 'notFound' }, 'RES_001'],
+ ] as const) {
+ const { provider } = fakeClient([
+ mailboxList(),
+ [
+ ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'],
+ ['Mailbox/get', { list: [] }, 'm'],
+ ],
+ ]);
+ expect(
+ await codeOf(provider.renameMailbox(ARA, 'u1', '보관 자료')),
+ ).toBe(code);
+ }
+ });
+ });
+
+ describe('지우기 (deleteMailbox)', () => {
+ const destroyed = (id: string): JmapInvocation[] => [
+ ['Mailbox/set', { destroyed: [id] }, 'x'],
+ ];
+ const hasEmail: JmapInvocation[] = [
+ [
+ 'Mailbox/set',
+ { notDestroyed: { u4: { type: 'mailboxHasEmail' } } },
+ 'x',
+ ],
+ ];
+
+ it('빈 메일함은 메일을 지우지 않는 옵션(onDestroyRemoveEmails: false)으로 지운다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([]),
+ destroyed('u3'),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u3')).resolves.toEqual({
+ deleted: true,
+ processed: 0,
+ movedToTrash: 0,
+ remaining: 0,
+ });
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Email/query',
+ {
+ accountId: 'd',
+ filter: { inMailbox: 'u3' },
+ limit: 500,
+ calculateTotal: true,
+ },
+ 'q',
+ ],
+ [
+ 'Email/get',
+ {
+ accountId: 'd',
+ '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
+ properties: ['id', 'mailboxIds'],
+ },
+ 'g',
+ ],
+ ]);
+ expect(recorded[2].calls).toEqual([
+ [
+ 'Mailbox/set',
+ { accountId: 'd', destroy: ['u3'], onDestroyRemoveEmails: false },
+ 'x',
+ ],
+ ]);
+ });
+
+ it('안의 메일은 확인한 상태 그대로일 때만 옮긴다 — 이 메일함에만 있으면 휴지통으로, 다른 메일함에도 있으면 이 메일함에서만 뺀다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([
+ only('m1', 'u4'),
+ { id: 'm2', mailboxIds: { u4: true, a: true } },
+ ]),
+ [['Email/set', { updated: { m1: null, m2: null } }, 's']],
+ batch([]),
+ destroyed('u4'),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: true,
+ processed: 2,
+ movedToTrash: 1,
+ remaining: 0,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: {
+ m1: { 'mailboxIds/u4': null, 'mailboxIds/b': true },
+ m2: { 'mailboxIds/u4': null },
+ },
+ });
+ // 메일을 영구 삭제하는 요청은 하나도 없다
+ for (const record of recorded) {
+ for (const [name, args] of record.calls) {
+ if (name === 'Email/set')
+ expect(args).not.toHaveProperty('destroy');
+ }
+ }
+ });
+
+ it('기본 메일함 · 하위 메일함이 있는 메일함은 VAL_001, 없는 메일함은 RES_001 — 아무것도 옮기지 않는다', async () => {
+ for (const [id, code] of [
+ ['a', 'VAL_001'],
+ ['b', 'VAL_001'],
+ ['u1', 'VAL_001'],
+ ['nope', 'RES_001'],
+ ] as const) {
+ const { provider, recorded } = fakeClient([mailboxList()]);
+ expect(await codeOf(provider.deleteMailbox(ARA, id))).toBe(code);
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('어긋나면(그사이 다른 창이 바꿈) 다시 읽어 그때의 상태로 옮긴다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4')], 's1'),
+ mismatch,
+ batch([only('m1', 'u4')], 's2'),
+ [['Email/set', { updated: { m1: null } }, 's']],
+ batch([]),
+ destroyed('u4'),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: true,
+ processed: 1,
+ movedToTrash: 1,
+ remaining: 0,
+ });
+ expect(argsOf(recorded[4])).toMatchObject({ ifInState: 's2' });
+ });
+
+ it('이어서 세 번 어긋나 하나도 옮기지 못하면 MAIL_001 — 옮기는 요청은 모두 조건부였다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4')], 's1'),
+ mismatch,
+ batch([only('m1', 'u4')], 's2'),
+ mismatch,
+ batch([only('m1', 'u4')], 's3'),
+ mismatch,
+ ]);
+
+ expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ expect(recorded).toHaveLength(7);
+ for (const index of [2, 4, 6]) {
+ expect(argsOf(recorded[index])).toHaveProperty('ifInState');
+ }
+ });
+
+ it('지우는 사이 메일이 들어오면(mailboxHasEmail) 그 메일도 옮긴 뒤 다시 지운다', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ batch([]),
+ hasEmail,
+ batch([only('m9', 'u4')]),
+ [['Email/set', { updated: { m9: null } }, 's']],
+ batch([]),
+ destroyed('u4'),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: true,
+ processed: 1,
+ movedToTrash: 1,
+ remaining: 0,
+ });
+ });
+
+ it('읽은 목록이 비어 있는데도 이어서 세 번 거절되면(엔진 이상) MAIL_001', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ batch([]),
+ hasEmail,
+ batch([]),
+ hasEmail,
+ batch([]),
+ hasEmail,
+ ]);
+
+ expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ });
+
+ it('그사이 하위 메일함이 생겼으면(mailboxHasChild) VAL_001, 이미 없으면 지운 것으로 본다', async () => {
+ const child = fakeClient([
+ mailboxList(),
+ batch([]),
+ [
+ [
+ 'Mailbox/set',
+ { notDestroyed: { u3: { type: 'mailboxHasChild' } } },
+ 'x',
+ ],
+ ],
+ ]);
+ expect(await codeOf(child.provider.deleteMailbox(ARA, 'u3'))).toBe(
+ 'VAL_001',
+ );
+
+ const gone = fakeClient([
+ mailboxList(),
+ batch([]),
+ [
+ [
+ 'Mailbox/set',
+ { notDestroyed: { u3: { type: 'notFound' } } },
+ 'x',
+ ],
+ ],
+ ]);
+ await expect(gone.provider.deleteMailbox(ARA, 'u3')).resolves.toEqual({
+ deleted: true,
+ processed: 0,
+ movedToTrash: 0,
+ remaining: 0,
+ });
+ });
+
+ it('옮길 메일이 있는데 휴지통이 없으면 옮기지 않고 MAIL_001 · 상태를 모르면 조건 없이 옮기지 않는다', async () => {
+ const noTrash = fakeClient([
+ mailboxList(mailboxes.filter((mailbox) => mailbox.role !== 'trash')),
+ batch([only('m1', 'u4')]),
+ ]);
+ expect(await codeOf(noTrash.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ expect(noTrash.recorded).toHaveLength(2);
+
+ const unknownState = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4')], null),
+ ]);
+ expect(
+ await codeOf(unknownState.provider.deleteMailbox(ARA, 'u4')),
+ ).toBe('MAIL_001');
+ expect(unknownState.recorded).toHaveLength(2);
+ });
+
+ it('한 요청의 시간(25초)이 모자라면 옮긴 만큼만 하고 deleted: false 와 남은 수 — 클라이언트가 다시 보낸다', async () => {
+ const first = ids('m', 500).map((id) => only(id, 'u4'));
+ const fake = fakeClient([
+ mailboxList(),
+ batch(first, 's1', 1_200),
+ [
+ [
+ 'Email/set',
+ {
+ updated: Object.fromEntries(first.map(({ id }) => [id, null])),
+ },
+ 's',
+ ],
+ ],
+ ]);
+ // 첫 차례를 옮긴 뒤 21초가 지났다 — 남은 시간이 5초보다 적어 다음 차례를 시작하지 않는다
+ advanceClockAfterCall(fake, 3, 21_000);
+
+ await expect(fake.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: false,
+ processed: 500,
+ movedToTrash: 500,
+ remaining: 700,
+ });
+ expect(fake.recorded).toHaveLength(3);
+ });
+
+ it('엔진이 옮기기를 모두 거절하면 멈춘다 — 이 요청에서 옮긴 것이 없으면 그 거절의 코드', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4')]),
+ [['Email/set', { notUpdated: { m1: { type: 'forbidden' } } }, 's']],
+ ]);
+
+ expect(await codeOf(provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'AUTH_003',
+ );
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('jmapType=forbidden'),
+ );
+ });
+
+ it('다른 메일함에도 든 메일만 남았으면 휴지통으로 옮긴 수는 0 이어도 진행 수(processed)는 빠진 수다', async () => {
+ const both = (id: string): Candidate => ({
+ id,
+ mailboxIds: { u4: true, a: true },
+ });
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([both('m1'), both('m2')]),
+ [['Email/set', { updated: { m1: null, m2: null } }, 's']],
+ batch([]),
+ destroyed('u4'),
+ ]);
+
+ await expect(provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: true,
+ processed: 2,
+ movedToTrash: 0,
+ remaining: 0,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ update: {
+ m1: { 'mailboxIds/u4': null },
+ m2: { 'mailboxIds/u4': null },
+ },
+ });
+ });
+
+ it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 옮긴 것이 없으면 MAIL_001, 있으면 거기까지', async () => {
+ const vanished: JmapInvocation[] = [
+ ['Email/set', { notUpdated: { m2: { type: 'notFound' } } }, 's'],
+ ];
+ const idle = fakeClient([
+ mailboxList(),
+ batch([only('m2', 'u4')]),
+ vanished,
+ batch([only('m2', 'u4')]),
+ vanished,
+ batch([only('m2', 'u4')]),
+ vanished,
+ ]);
+ expect(await codeOf(idle.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ expect(idle.recorded).toHaveLength(7);
+
+ const some = fakeClient([
+ mailboxList(),
+ batch([only('m1', 'u4'), only('m2', 'u4')]),
+ [
+ [
+ 'Email/set',
+ {
+ updated: { m1: null },
+ notUpdated: { m2: { type: 'notFound' } },
+ },
+ 's',
+ ],
+ ],
+ batch([only('m2', 'u4')]),
+ vanished,
+ batch([only('m2', 'u4')]),
+ vanished,
+ batch([only('m2', 'u4')]),
+ vanished,
+ ]);
+ await expect(some.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: false,
+ processed: 1,
+ movedToTrash: 1,
+ remaining: 1,
+ });
+ });
+
+ it('시계는 메일함 목록을 읽기 전부터 간다 — 목록을 읽는 데 21초가 걸리면 차례를 시작하지 않고 MAIL_001 (옮긴 것이 없다)', async () => {
+ const fake = fakeClient([mailboxList()]);
+ advanceClockAfterCall(fake, 1, 21_000);
+
+ expect(await codeOf(fake.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ expect(fake.recorded).toHaveLength(1);
+ });
+
+ it('남은 시간 때문에 끊긴 엔진 호출 — 옮긴 것이 있으면 거기까지로 답하고, 없으면 MAIL_001 · 시간이 남았는데 끊긴 것은 엔진 장애 그대로', async () => {
+ const timeout = new BusinessException('MAIL_001', {
+ context: { reason: 'timeout' },
+ });
+ const firstRound = (): JmapInvocation[][] => [
+ mailboxList(),
+ batch([only('m1', 'u4')], 's1', 2),
+ [['Email/set', { updated: { m1: null } }, 's']],
+ ];
+
+ // 넷째 호출(다음 차례의 읽기)이 시간을 다 쓰고 끊겼다 — 첫 차례의 결과로 답한다
+ const cut = fakeClient(firstRound());
+ failCallWith(cut, 4, 30_000, timeout);
+ await expect(cut.provider.deleteMailbox(ARA, 'u4')).resolves.toEqual({
+ deleted: false,
+ processed: 1,
+ movedToTrash: 1,
+ remaining: 1,
+ });
+
+ // 첫 차례의 읽기가 끊겼다 — 옮긴 것이 없다
+ const early = fakeClient([mailboxList()]);
+ failCallWith(early, 2, 30_000, timeout);
+ expect(await codeOf(early.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+
+ // 시간이 남았는데 끊겼다(엔진이 늦다) — 옮겼어도 엔진 장애로 답한다
+ const slow = fakeClient(firstRound());
+ failCallWith(slow, 4, 0, timeout);
+ expect(await codeOf(slow.provider.deleteMailbox(ARA, 'u4'))).toBe(
+ 'MAIL_001',
+ );
+ });
+ });
+
+ describe('전체 읽음 (markMailboxRead)', () => {
+ const unread = (emailIds: string[], total = emailIds.length) => [
+ ['Email/query', { ids: emailIds, total }, 'q'] as JmapInvocation,
+ ];
+ const marked = (emailIds: string[]): JmapInvocation[] => [
+ [
+ 'Email/set',
+ { updated: Object.fromEntries(emailIds.map((id) => [id, null])) },
+ 'u',
+ ],
+ ];
+
+ it('안 읽은 메일을 500통씩 찾아 읽음으로 바꾼다 — 남은 것이 없을 때까지', async () => {
+ const firstIds = ids('m', 500);
+ const secondIds = ids('n', 200);
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ unread(firstIds, 700),
+ marked(firstIds),
+ unread(secondIds, 200),
+ marked(secondIds),
+ unread([]),
+ ]);
+
+ await expect(provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 700,
+ remaining: 0,
+ });
+ expect(recorded[1].calls).toEqual([
+ [
+ 'Email/query',
+ {
+ accountId: 'd',
+ filter: { inMailbox: 'a', notKeyword: '$seen' },
+ limit: 500,
+ calculateTotal: true,
+ },
+ 'q',
+ ],
+ ]);
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ update: Object.fromEntries(
+ firstIds.map((id) => [id, { 'keywords/$seen': true }]),
+ ),
+ });
+ // 읽음 표시는 되돌릴 수 있는 일이라 조건 없이 바꾼다
+ expect(argsOf(recorded[2])).not.toHaveProperty('ifInState');
+ });
+
+ it('한 요청의 시간이 모자라면 바꾼 만큼과 남은 수를 돌려준다', async () => {
+ const firstIds = ids('m', 500);
+ const fake = fakeClient([
+ mailboxList(),
+ unread(firstIds, 1_234),
+ marked(firstIds),
+ ]);
+ advanceClockAfterCall(fake, 3, 21_000);
+
+ await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 500,
+ remaining: 734,
+ });
+ });
+
+ it('엔진이 모두 거절하면 멈춘다 — 바꾼 것이 없으면 그 거절의 코드, 바꾼 것이 있으면 거기까지와 남은 수 (끝없이 다시 보내지 않게)', async () => {
+ const refused = (emailIds: string[]): JmapInvocation[] => [
+ [
+ 'Email/set',
+ {
+ notUpdated: Object.fromEntries(
+ emailIds.map((id) => [id, { type: 'forbidden' }]),
+ ),
+ },
+ 'u',
+ ],
+ ];
+
+ const nothing = fakeClient([
+ mailboxList(),
+ unread(['m1', 'm2']),
+ refused(['m1', 'm2']),
+ ]);
+ expect(await codeOf(nothing.provider.markMailboxRead(ARA, 'a'))).toBe(
+ 'AUTH_003',
+ );
+
+ const some = fakeClient([
+ mailboxList(),
+ unread(['m1', 'm2']),
+ marked(['m1']),
+ unread(['m2']),
+ refused(['m2']),
+ ]);
+ await expect(some.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 1,
+ remaining: 1,
+ });
+ });
+
+ it('그사이 지워진 메일(notFound)만 남았으면 다시 찾는다 — 없는 메일함은 RES_001', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ unread(['m1']),
+ [['Email/set', { notUpdated: { m1: { type: 'notFound' } } }, 'u']],
+ unread([]),
+ ]);
+ await expect(provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 0,
+ remaining: 0,
+ });
+
+ const missing = fakeClient([mailboxList()]);
+ expect(
+ await codeOf(missing.provider.markMailboxRead(ARA, 'nope')),
+ ).toBe('RES_001');
+ expect(missing.recorded).toHaveLength(1);
+ });
+
+ it('엔진 호출마다 제한 시간은 기본 10초와 남은 시간 중 짧은 쪽이다 — 메일함 목록부터', async () => {
+ const firstIds = ids('m', 3);
+ const fake = fakeClient([
+ mailboxList(),
+ unread(firstIds, 5),
+ marked(firstIds),
+ unread([]),
+ ]);
+ // 둘째 호출(첫 찾기) 뒤 17초가 지났다 — 남은 시간 8초
+ advanceClockAfterCall(fake, 2, 17_000);
+
+ await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 3,
+ remaining: 0,
+ });
+ expect(fake.recorded.map((record) => record.options)).toEqual([
+ { timeoutMs: 10_000 },
+ { timeoutMs: 10_000 },
+ { timeoutMs: 8_000 },
+ { timeoutMs: 8_000 },
+ ]);
+ });
+
+ it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 바꾼 것이 없으면 MAIL_001, 있으면 거기까지', async () => {
+ const vanished: JmapInvocation[] = [
+ ['Email/set', { notUpdated: { m2: { type: 'notFound' } } }, 'u'],
+ ];
+ const idle = fakeClient([
+ mailboxList(),
+ unread(['m2']),
+ vanished,
+ unread(['m2']),
+ vanished,
+ unread(['m2']),
+ vanished,
+ ]);
+ expect(await codeOf(idle.provider.markMailboxRead(ARA, 'a'))).toBe(
+ 'MAIL_001',
+ );
+ expect(idle.recorded).toHaveLength(7);
+
+ const some = fakeClient([
+ mailboxList(),
+ unread(['m1', 'm2']),
+ marked(['m1']),
+ unread(['m2']),
+ vanished,
+ unread(['m2']),
+ vanished,
+ unread(['m2']),
+ vanished,
+ ]);
+ await expect(some.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 1,
+ remaining: 1,
+ });
+ });
+
+ it('남은 시간 때문에 끊긴 읽음 표시 — 바꾼 것이 있으면 거기까지와 그때의 남은 수 (끊긴 요청은 엔진이 적용했을 수 있다 — 다음 요청이 새로 찾는다)', async () => {
+ const firstIds = ids('m', 500);
+ const fake = fakeClient([
+ mailboxList(),
+ unread(firstIds, 900),
+ marked(firstIds),
+ unread(ids('n', 400), 400),
+ ]);
+ failCallWith(
+ fake,
+ 5,
+ 30_000,
+ new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
+ );
+
+ await expect(fake.provider.markMailboxRead(ARA, 'a')).resolves.toEqual({
+ processed: 500,
+ remaining: 400,
+ });
+ });
+ });
+
+ describe('비우기 (emptyMailbox)', () => {
+ it('휴지통 — 확인한 상태 그대로일 때만, 휴지통에만 있으면 영구 삭제 · 다른 메일함에도 있으면 휴지통에서만 뺀다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([
+ only('t1', 'b'),
+ { id: 't2', mailboxIds: { b: true, a: true } },
+ ]),
+ [['Email/set', { destroyed: ['t1'], updated: { t2: null } }, 's']],
+ batch([]),
+ ]);
+
+ await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 2,
+ remaining: 0,
+ });
+ expect(recorded[1].calls[0][1]).toMatchObject({
+ filter: { inMailbox: 'b' },
+ limit: 500,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['t1'],
+ update: { t2: { 'mailboxIds/b': null } },
+ });
+ });
+
+ it('스팸메일함도 같은 규칙으로 비운다', async () => {
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('j1', 'c')]),
+ [['Email/set', { destroyed: ['j1'] }, 's']],
+ batch([]),
+ ]);
+
+ await expect(provider.emptyMailbox(ARA, 'c')).resolves.toEqual({
+ processed: 1,
+ remaining: 0,
+ });
+ expect(argsOf(recorded[2])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ destroy: ['j1'],
+ });
+ });
+
+ it('휴지통 · 스팸메일함이 아니면 VAL_001, 없는 메일함은 RES_001 — 메일을 읽지도 않는다', async () => {
+ for (const [id, code] of [
+ ['a', 'VAL_001'],
+ ['d', 'VAL_001'],
+ ['u4', 'VAL_001'],
+ ['nope', 'RES_001'],
+ ] as const) {
+ const { provider, recorded } = fakeClient([mailboxList()]);
+ expect(await codeOf(provider.emptyMailbox(ARA, id))).toBe(code);
+ expect(recorded).toHaveLength(1);
+ }
+ });
+
+ it('어긋나면 다시 읽어 그때의 상태로 — 이어서 세 번 어긋나 하나도 못 지우면 MAIL_001 (지우지 않았다)', async () => {
+ const retried = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')], 's1'),
+ mismatch,
+ batch([only('t1', 'b')], 's2'),
+ [['Email/set', { destroyed: ['t1'] }, 's']],
+ batch([]),
+ ]);
+ await expect(retried.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 1,
+ remaining: 0,
+ });
+ expect(argsOf(retried.recorded[4])).toMatchObject({ ifInState: 's2' });
+
+ const contended = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')], 's1'),
+ mismatch,
+ batch([only('t1', 'b')], 's2'),
+ mismatch,
+ batch([only('t1', 'b')], 's3'),
+ mismatch,
+ ]);
+ expect(await codeOf(contended.provider.emptyMailbox(ARA, 'b'))).toBe(
+ 'MAIL_001',
+ );
+ });
+
+ it('지운 뒤에 계속 어긋나면 거기까지 지운 수와 남은 수를 돌려준다', async () => {
+ const { provider } = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b'), only('t2', 'b')], 's1', 3),
+ [['Email/set', { destroyed: ['t1', 't2'] }, 's']],
+ batch([only('t3', 'b')], 's2'),
+ mismatch,
+ batch([only('t3', 'b')], 's3'),
+ mismatch,
+ batch([only('t3', 'b')], 's4'),
+ mismatch,
+ ]);
+
+ await expect(provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 2,
+ remaining: 1,
+ });
+ });
+
+ it('상태를 모르면 조건 없이 지우지 않는다 (MAIL_001) · 시간이 모자라면 지운 만큼과 남은 수', async () => {
+ const unknownState = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')], null),
+ ]);
+ expect(await codeOf(unknownState.provider.emptyMailbox(ARA, 'b'))).toBe(
+ 'MAIL_001',
+ );
+ expect(unknownState.recorded).toHaveLength(2);
+
+ const first = ids('t', 500).map((id) => only(id, 'b'));
+ const slow = fakeClient([
+ mailboxList(),
+ batch(first, 's1', 501),
+ [['Email/set', { destroyed: first.map(({ id }) => id) }, 's']],
+ ]);
+ advanceClockAfterCall(slow, 3, 21_000);
+ await expect(slow.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 500,
+ remaining: 1,
+ });
+ });
+
+ it('엔진이 모두 거절하면 멈춘다 — 지운 것이 없으면 그 거절의 코드, 지운 것이 있으면 거기까지와 남은 수', async () => {
+ const refused = (emailId: string): JmapInvocation[] => [
+ [
+ 'Email/set',
+ { notDestroyed: { [emailId]: { type: 'forbidden' } } },
+ 's',
+ ],
+ ];
+
+ const nothing = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')]),
+ refused('t1'),
+ ]);
+ expect(await codeOf(nothing.provider.emptyMailbox(ARA, 'b'))).toBe(
+ 'AUTH_003',
+ );
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('jmapType=forbidden'),
+ );
+
+ const some = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b'), only('t2', 'b')], 's1', 2),
+ [
+ [
+ 'Email/set',
+ {
+ destroyed: ['t1'],
+ notDestroyed: { t2: { type: 'forbidden' } },
+ },
+ 's',
+ ],
+ ],
+ batch([only('t2', 'b')], 's2', 1),
+ refused('t2'),
+ ]);
+ await expect(some.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 1,
+ remaining: 1,
+ });
+ expect(some.recorded).toHaveLength(5);
+ });
+
+ it('바뀐 것도 거절도 없는 차례가 이어서 세 번이면 멈춘다 — 지운 것이 없으면 MAIL_001', async () => {
+ const vanished: JmapInvocation[] = [
+ ['Email/set', { notDestroyed: { t1: { type: 'notFound' } } }, 's'],
+ ];
+ const { provider, recorded } = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')], 's1'),
+ vanished,
+ batch([only('t1', 'b')], 's2'),
+ vanished,
+ batch([only('t1', 'b')], 's3'),
+ vanished,
+ ]);
+
+ expect(await codeOf(provider.emptyMailbox(ARA, 'b'))).toBe('MAIL_001');
+ expect(recorded).toHaveLength(7);
+ });
+
+ it('남은 시간 때문에 끊긴 지우기 — 지운 것이 있으면 거기까지로 답한다 (끊긴 지우기는 엔진이 적용했을 수 있다 — 다음 요청이 새로 읽는다)', async () => {
+ const fake = fakeClient([
+ mailboxList(),
+ batch([only('t1', 'b')], 's1', 3),
+ [['Email/set', { destroyed: ['t1'] }, 's']],
+ batch([only('t2', 'b'), only('t3', 'b')], 's2', 2),
+ ]);
+ failCallWith(
+ fake,
+ 5,
+ 30_000,
+ new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
+ );
+
+ await expect(fake.provider.emptyMailbox(ARA, 'b')).resolves.toEqual({
+ processed: 1,
+ remaining: 2,
+ });
+ });
+ });
+ });
+
+ describe('부재중 자동 응답 · 메일 필터 — 엔진의 Sieve 스크립트 하나(arasystem)에 함께 둔다', () => {
+ // 읽기 · 쓰기 · 다시 시도의 규칙은 jmap-filter-script.spec.ts, 만드는 Sieve 는 sieve-script.spec.ts 가 본다 — 여기서는 어댑터가 그 저장 자리를 쓰는지만
+ const stored = {
+ id: 'singleton',
+ isEnabled: true,
+ fromDate: '2026-11-02T15:00:00Z',
+ toDate: '2026-11-07T15:00:00Z',
+ subject: '부재중입니다',
+ textBody: '11월 7일까지 휴가입니다.',
+ htmlBody: null,
+ };
+ const vacation = {
+ isEnabled: true,
+ fromDate: '2026-11-02T15:00:00.000Z',
+ toDate: '2026-11-07T15:00:00.000Z',
+ subject: '부재중입니다',
+ textBody: '11월 7일까지 휴가입니다.',
+ };
+ beforeEach(() => {
+ jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
+ });
+ afterEach(() => jest.restoreAllMocks());
+
+ /** 우리 스크립트가 아직 없는 계정 — 엔진의 VacationResponse 만 있다 */
+ const noScript: JmapInvocation[] = [
+ ['SieveScript/get', { state: 's1', list: [] }, 'g'],
+ ['VacationResponse/get', { list: [stored] }, 'v'],
+ ];
+
+ it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => {
+ const { provider, recorded } = fakeClient([noScript]);
+
+ await expect(provider.getVacation(ARA)).resolves.toEqual(vacation);
+ expect(recorded[0].calls.map(([method]) => method)).toEqual([
+ 'SieveScript/get',
+ 'VacationResponse/get',
+ ]);
+ expect(recorded[0].options).toEqual({
+ capabilities: [
+ 'urn:ietf:params:jmap:sieve',
+ 'urn:ietf:params:jmap:vacationresponse',
+ ],
+ });
+ });
+
+ it('부재중 저장은 VacationResponse 가 아니라 우리 스크립트를 만들고 켠다 — 저장한 값을 돌려준다', async () => {
+ const { provider, recorded, client } = fakeClient([
+ noScript,
+ [
+ ['SieveScript/set', { created: { script: { id: 'sc1' } } }, 's'],
+ [
+ 'SieveScript/get',
+ { list: [{ id: 'sc1', name: 'arasystem', isActive: true }] },
+ 'g',
+ ],
+ ],
+ ]);
+ client.upload.mockResolvedValue({
+ blobId: 'blob1',
+ type: 'application/sieve',
+ size: 100,
+ });
+ const off = { ...vacation, isEnabled: false };
+
+ await expect(provider.setVacation(ARA, off)).resolves.toEqual(off);
+ expect((client.upload.mock.calls[0] as unknown[])[2]).toBe(
+ 'application/sieve',
+ );
+ expect(argsOf(recorded[1])).toEqual({
+ accountId: 'd',
+ ifInState: 's1',
+ create: { script: { name: 'arasystem', blobId: 'blob1' } },
+ onSuccessActivateScript: '#script',
+ });
+ expect(
+ recorded.flatMap(({ calls }) => calls.map(([method]) => method)),
+ ).not.toContain('VacationResponse/set');
+ });
+
+ it('메일 필터도 같은 저장 자리에서 읽는다 — 스크립트가 없으면 규칙 없음 · 동작함', async () => {
+ const { provider } = fakeClient([noScript]);
+
+ await expect(provider.getMailFilters(ARA)).resolves.toEqual({
+ rules: [],
+ version: expect.any(String) as string,
+ isActive: true,
+ });
+ });
+ });
+
+ describe('원문 (openMessageSource)', () => {
+ it('메일의 원본 blob 을 message/rfc822 로 내려받아 흘려보낸다 — 제목을 함께 돌려준다', async () => {
+ const { provider, recorded, client } = fakeClient([
+ [
+ [
+ 'Email/get',
+ { list: [{ id: 'm1', blobId: 'raw-m1', subject: '견적서' }] },
+ 'g',
+ ],
+ ],
+ ]);
+ const stream = Readable.from([Buffer.from('From: a\r\n\r\nbody')]);
+ client.download.mockResolvedValue({ stream, length: 15 });
+
+ await expect(provider.openMessageSource(ARA, 'm1')).resolves.toEqual({
+ stream,
+ subject: '견적서',
+ size: 15,
+ });
+ expect(recorded[0].calls).toEqual([
+ [
+ 'Email/get',
+ {
+ accountId: 'd',
+ ids: ['m1'],
+ properties: ['id', 'blobId', 'subject'],
+ },
+ 'g',
+ ],
+ ]);
+ expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
+ expect(client.download).toHaveBeenCalledWith(
+ ARA,
+ 'raw-m1',
+ 'message.eml',
+ 'message/rfc822',
+ );
+ });
+
+ it('제목이 없으면 빈 문자열 · 없는 메일은 RES_001 · 엔진이 원본 blob 을 알려 주지 않으면 MAIL_001 (내려받지 않는다)', async () => {
+ const untitled = fakeClient([
+ [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1' }] }, 'g']],
+ ]);
+ untitled.client.download.mockResolvedValue({
+ stream: Readable.from([]),
+ length: null,
+ });
+ await expect(
+ untitled.provider.openMessageSource(ARA, 'm1'),
+ ).resolves.toMatchObject({ subject: '', size: null });
+
+ const missing = fakeClient([
+ [['Email/get', { list: [], notFound: ['m1'] }, 'g']],
+ ]);
+ expect(await codeOf(missing.provider.openMessageSource(ARA, 'm1'))).toBe(
+ 'RES_001',
+ );
+ expect(missing.client.download).not.toHaveBeenCalled();
+
+ const noBlob = fakeClient([
+ [['Email/get', { list: [{ id: 'm1' }] }, 'g']],
+ ]);
+ expect(await codeOf(noBlob.provider.openMessageSource(ARA, 'm1'))).toBe(
+ 'MAIL_001',
+ );
+ expect(noBlob.client.download).not.toHaveBeenCalled();
+ });
+ });
+
+ it('비밀번호 바꾸기는 클라이언트의 본인 자격 변경에 그대로 맡긴다 (대리 접근 세션을 쓰지 않는다)', async () => {
+ const { provider, client } = fakeClient([]);
+ client.changePassword.mockResolvedValue('changed');
+
+ await expect(
+ provider.changePassword(ARA, 'current-pw', 'new-password-1'),
+ ).resolves.toBe('changed');
+ expect(client.changePassword).toHaveBeenCalledWith(
+ ARA,
+ 'current-pw',
+ 'new-password-1',
+ );
+ expect(client.getSession).not.toHaveBeenCalled();
+ expect(client.call).not.toHaveBeenCalled();
+ });
+
+ describe('첨부 업로드 (uploadAttachment)', () => {
+ let dir: string;
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'provider-upload-'));
+ });
+
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ it('임시 파일을 Blob 으로 열어(메모리에 다 올리지 않고) 엔진에 올린다', async () => {
+ const path = join(dir, 'upload');
+ writeFileSync(path, 'pdf-bytes');
+ const { provider, client } = fakeClient([]);
+ client.upload.mockResolvedValue({
+ blobId: 'blob-new',
+ type: 'application/pdf',
+ size: 9,
+ });
+
+ await expect(
+ provider.uploadAttachment(ARA, {
+ path,
+ size: 9,
+ name: '보고서.pdf',
+ type: 'application/pdf',
+ }),
+ ).resolves.toEqual({
+ id: 'blob-new',
+ name: '보고서.pdf',
+ type: 'application/pdf',
+ size: 9,
+ });
+
+ const [user, content, type] = client.upload.mock.calls[0] as [
+ string,
+ Blob,
+ string,
+ ];
+ expect(user).toBe(ARA);
+ expect(type).toBe('application/pdf');
+ expect(content.size).toBe(9);
+ await expect(content.text()).resolves.toBe('pdf-bytes');
+ });
+ });
+
+ describe('첨부 (openAttachment)', () => {
+ const withAttachment: JmapInvocation[] = [
+ [
+ 'Email/get',
+ {
+ list: [
+ {
+ id: 'm1',
+ attachments: [
+ {
+ blobId: 'blob-xlsx',
+ name: '체크리스트.xlsx',
+ type: 'application/zip',
+ size: 2048,
+ },
+ ],
+ },
+ ],
+ },
+ 'g',
+ ],
+ ];
+
+ it('그 메일에 있는 첨부만 내려받는다', async () => {
+ const { provider, client } = fakeClient([withAttachment]);
+ client.download.mockResolvedValue({
+ stream: Readable.from(['x']),
+ length: 2048,
+ });
+
+ const content = await provider.openAttachment(ARA, 'm1', 'blob-xlsx');
+
+ expect(client.download).toHaveBeenCalledWith(
+ ARA,
+ 'blob-xlsx',
+ '체크리스트.xlsx',
+ 'application/octet-stream',
+ );
+ expect(content).toMatchObject({ name: '체크리스트.xlsx', size: 2048 });
+ });
+
+ it('다른 blob id 로는 받을 수 없다 (RES_001, 다운로드 시도 없음)', async () => {
+ const { provider, client } = fakeClient([withAttachment]);
+
+ expect(
+ await codeOf(provider.openAttachment(ARA, 'm1', 'other-blob')),
+ ).toBe('RES_001');
+ expect(client.download).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('buildThreadHeaders', () => {
+ it('원 메일의 Message-ID 를 References 끝에 이어 붙이고 중복을 없앤다', () => {
+ expect(
+ buildThreadHeaders({ messageId: ['b@x'], references: ['a@x', 'b@x'] }),
+ ).toEqual({ inReplyTo: ['b@x'], references: ['a@x', 'b@x'] });
+ });
+
+ it('References 는 최근 20개까지만 둔다', () => {
+ const references = Array.from({ length: 30 }, (_, i) => `${i}@x`);
+
+ const headers = buildThreadHeaders({ messageId: ['new@x'], references });
+
+ expect(headers?.references).toHaveLength(20);
+ expect(headers?.references.at(-1)).toBe('new@x');
+ });
+
+ it('원 메일에 Message-ID 가 없으면 null', () => {
+ expect(
+ buildThreadHeaders({ messageId: null, references: null }),
+ ).toBeNull();
+ expect(buildThreadHeaders(undefined)).toBeNull();
+ });
+});
diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
new file mode 100644
index 0000000..31ad39c
--- /dev/null
+++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts
@@ -0,0 +1,3425 @@
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import type { Cache } from 'cache-manager';
+import { openAsBlob } from 'fs';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import { resolveAttachmentsTotalLimit } from '@/modules/mail/attachment.policy';
+import {
+ MAIL_ID_PATTERN,
+ MAX_BODY_VALUE_BYTES,
+} from '@/modules/mail/mail.constants';
+import type { MailProvider } from '@/modules/mail/mail.provider';
+import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
+import type {
+ AttachmentContent,
+ Correspondence,
+ DraftContent,
+ DraftSource,
+ MailAccountProfile,
+ MailAddress,
+ MailAttachment,
+ Mailbox,
+ MailboxBulkResult,
+ MailboxCreate,
+ MailboxDeletion,
+ MailFilterSet,
+ MailFilterUpdate,
+ MailLiveState,
+ MailState,
+ MailUsage,
+ MailVacation,
+ MailWatch,
+ MailWatchListener,
+ MessageDeletionResult,
+ MessageDetailSource,
+ MessageListQuery,
+ MessagePage,
+ MessageSource,
+ MessagesPatchResult,
+ MessageSummary,
+ MessageUpdate,
+ OutgoingAttachment,
+ OutgoingInlineImage,
+ OutgoingMessage,
+ PasswordChangeOutcome,
+ RecipientFields,
+ RejectedRecipient,
+ SavedDraft,
+ SentMessage,
+ SentMessageRecord,
+ UploadedFile,
+} from '@/modules/mail/mail.types';
+import { traceTag } from '@/shared/logger/request-context';
+import { maskEmail } from '@/shared/utils/mask';
+import {
+ JMAP_INLINE_UPLOAD_TIMEOUT_MS,
+ JMAP_REQUEST_TIMEOUT_MS,
+ JMAP_SEND_TIMEOUT_MS,
+ JmapClient,
+ type JmapCallOptions,
+ type JmapSession,
+} from './jmap.client';
+import {
+ DELIVERED_TO_ALL,
+ describeRejections,
+ everyoneRejectedCode,
+ readDeliveryResult,
+ toRejectedRecipients,
+ type DeliveryResult,
+} from './delivery-status';
+import { decodeCursor, encodeCursor } from './jmap-cursor';
+import { JmapFilterScriptStore } from './jmap-filter-script';
+import {
+ JmapMailWatch,
+ type WatchPosition,
+ type WatchReading,
+} from './jmap-mail-watch';
+import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
+import {
+ expectResult,
+ findResult,
+ firstSetError,
+ isState,
+} from './jmap-results';
+import {
+ describeFailure,
+ isMissingAttachmentBlob,
+ jmapFailure,
+ mapJmapErrorType,
+} from './jmap.errors';
+import {
+ BODY_PART_PROPERTIES,
+ DETAIL_PROPERTIES,
+ MAILBOX_PROPERTIES,
+ SUMMARY_BODY_PART_PROPERTIES,
+ SUMMARY_PROPERTIES,
+ fallbackDisplayName,
+ findAttachment,
+ pickIdentity,
+ selectAccountQuota,
+ toAddresses,
+ toDetailSource,
+ toMailbox,
+ toSummary,
+ toUsage,
+ toUtcIso,
+} from './jmap.mapper';
+import type {
+ JmapBlob,
+ JmapChangesResult,
+ JmapEmail,
+ JmapEmailFilter,
+ JmapEmailFilterCondition,
+ JmapEmailSubmission,
+ JmapError,
+ JmapGetResult,
+ JmapIdentity,
+ JmapInvocation,
+ JmapMailbox,
+ JmapQueryResult,
+ JmapQuota,
+ JmapSetResult,
+} from './jmap.types';
+import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
+import { searchTextConditions, withSearchConditions } from './search-terms';
+
+const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
+/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
+const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
+
+/**
+ * 발송 때 남기는 내용 지문 헤더 — 같은 Idempotency-Key 로 내용이 바뀌었는지 비교한다.
+ * 메일과 함께 받는 사람에게도 가지만 Idempotency-Key(무작위)로 만든 HMAC 이라
+ * 키를 모르는 받는 사람은 내용(숨은 참조 등)을 맞춰 볼 수 없다 (send-idempotency.ts 머리말).
+ */
+const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
+const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
+
+/**
+ * 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
+ * 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 —
+ * 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
+ * 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
+ * (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
+ */
+const INTERNAL_SEND_KEYWORD = '$ara-sending';
+
+/**
+ * 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장").
+ * 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다.
+ */
+const REPLY_TO_ID_PROPERTY = 'header:X-Ara-Reply-To-Id:asText';
+
+/** 사용자 초안인지 볼 때 읽는 속성 */
+const DRAFT_CHECK_PROPERTIES = [
+ 'id',
+ 'mailboxIds',
+ 'keywords',
+ FINGERPRINT_PROPERTY,
+];
+
+/** 이어 쓸 임시 저장본을 읽을 때 — 상세 속성 + 초안 확인 + 답장 대상 (겹치는 이름은 한 번만) */
+const DRAFT_DETAIL_PROPERTIES = [
+ ...new Set([
+ ...DETAIL_PROPERTIES,
+ ...DRAFT_CHECK_PROPERTIES,
+ REPLY_TO_ID_PROPERTY,
+ ]),
+];
+
+/** 받는 사람 추천 — 주고받은 주소를 모을 때 읽는 속성 */
+const SENT_ADDRESS_PROPERTIES = ['to', 'cc', 'bcc', 'receivedAt'];
+const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt'];
+
+/**
+ * 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐.
+ * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다.
+ * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
+ * 그래도 이 횟수 안에 맞지 않으면 지우지 않는다.
+ */
+const CONDITIONAL_DESTROY_ATTEMPTS = 3;
+
+/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */
+const STATE_MISMATCH = 'stateMismatch';
+
+/** SetError — 그 대상이 없다 (그사이 지워짐) */
+const NOT_FOUND = 'notFound';
+
+/** SetError — 같은 상위 안에 같은 이름의 메일함이 있다 (2026-10-28 개발 엔진 확인) */
+const ALREADY_EXISTS = 'alreadyExists';
+/** Mailbox/set destroy 의 SetError (RFC 8621 §2.5) — 메일이 남았다 · 하위 메일함이 있다 */
+const MAILBOX_HAS_EMAIL = 'mailboxHasEmail';
+const MAILBOX_HAS_CHILD = 'mailboxHasChild';
+
+/** 메일함을 만들 수 있는 상위의 역할 — 그 밖에 역할이 없는 메일함(내 메일함) 아래와 맨 위에 만들 수 있다 (계약 2절 "메일함 관리") */
+const PARENT_ALLOWED_ROLES: ReadonlySet = new Set(['inbox', 'archive']);
+/** 비울 수 있는 메일함의 역할 — 휴지통 · 스팸메일함 */
+const EMPTYABLE_ROLES: ReadonlySet = new Set(['trash', 'junk']);
+
+/**
+ * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기) 한 요청의 시간 (계약 2절 "메일함 관리") — 실제 상한이다 (BulkRun).
+ * 이 안에서 끝내고 남은 수(remaining)를 돌려준다. 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다.
+ */
+const BULK_REQUEST_BUDGET_MS = 25_000;
+/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두 번)가 끝까지 시간 안에 들게 */
+const BULK_ROUND_RESERVE_MS = 5_000;
+/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */
+const BULK_BATCH_SIZE = 500;
+
+/** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */
+const SOURCE_TYPE = 'message/rfc822';
+const SOURCE_DOWNLOAD_NAME = 'message.eml';
+
+/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
+const SEARCH_EXCLUDED_ROLES: ReadonlySet = new Set(['trash', 'junk']);
+
+/**
+ * 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
+ * (백엔드가 먼저 답해야 사용자가 같은 키로 다시 보낼 수 있다 — jmap.client.ts 의 JMAP_SEND_TIMEOUT_MS 주석).
+ * 서명 그림 올리기와 보내기(초안 생성 + 제출)가 이 안에서 남은 시간만 쓴다.
+ */
+const SEND_TOTAL_BUDGET_MS = 55_000;
+/** 보내기에 남은 시간이 이보다 적으면 보내기 시작하지 않는다 — 보내다 끊기면 나갔는지 알 수 없다 */
+const MIN_SEND_WINDOW_MS = 10_000;
+/**
+ * 제출한 뒤 받는 사람별 결과(EmailSubmission/get 의 deliveryStatus)를 읽는 제한 시간 — 가벼운 조회 하나다 (계약 2절 "받는 사람별 결과").
+ * 발송 전체 시간 안에서, 모두 거절됐을 때 보낸 사본을 지울 시간(SEND_DISCARD_RESERVE_MS)을 남기고 쓴다
+ */
+const SEND_RESULT_TIMEOUT_MS = 5_000;
+/** 결과 읽기에 쓸 시간이 이보다 적으면 읽지 않고 받은 것으로 본다 — 제출은 이미 되었다 */
+const SEND_RESULT_MIN_WINDOW_MS = 1_000;
+/** 받는 사람이 모두 거절했을 때 보낸 사본을 지울 시간 — 결과 읽기가 발송 전체 시간에서 이만큼 남겨 둔다 */
+const SEND_DISCARD_RESERVE_MS = 3_000;
+
+/** 사용자 초안을 조건부로 지운 결과 */
+type DraftDestroyOutcome =
+ /** 지웠다 */
+ | 'destroyed'
+ /** 이미 없다 */
+ | 'gone'
+ /** 그사이 사용자 초안이 아니게 됐다(다른 메일함으로 옮겨짐 등) — 지우지 않았다 */
+ | 'not-a-draft'
+ /** 확인하는 사이 계정의 메일이 계속 바뀌었다 — 지우지 않았다 */
+ | 'contended';
+
+/**
+ * 중복 발송 확인 때 훑는 최근 메일 수 (보낸편지함 + 임시보관함, 받은 시각 내림차순).
+ * 재시도는 같은 쓰기 화면에서 곧 오므로 충분하다 — 그 사이 이보다 많이 보냈다면 다시 보낸다.
+ */
+const IDEMPOTENCY_LOOKBACK = 200;
+
+/**
+ * 쿼터가 없는 계정의 사용량은 메일 크기를 더해 구한다 (대리 접근 권한으로는 엔진의 계정 사용량을 읽을 수 없다).
+ * 한 번에 읽는 개수는 엔진의 maxObjectsInGet(500) 이하, 비용이 커서 결과를 직원별로 잠깐 캐시한다.
+ */
+const USAGE_SCAN_PAGE_SIZE = 500;
+/** 이보다 많으면 더 세지 않는다 (경고 로그 — 실제 사용량보다 작게 보인다) */
+const USAGE_SCAN_MAX_EMAILS = 50_000;
+const USAGE_CACHE_TTL_MS = 5 * 60_000;
+
+/** References 헤더에 이어 붙이는 Message-ID 개수 상한 (긴 스레드에서 헤더가 끝없이 커지지 않게) */
+const MAX_REFERENCES = 20;
+
+/** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */
+const DOWNLOAD_TYPE = 'application/octet-stream';
+
+function toJmapAddress(address: MailAddress): {
+ name: string | null;
+ email: string;
+} {
+ return { name: address.name, email: address.email };
+}
+
+function roleMailboxId(
+ mailboxes: JmapMailbox[],
+ role: string,
+): string | undefined {
+ return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === role)?.id;
+}
+
+/**
+ * 사용자의 임시 저장본인가 — 임시보관함 **하나에만** 있고 `$draft` 이며, 발송 중에 생긴 내부 초안이 아닐 것.
+ * - 다른 메일함에도 들어 있으면(같은 메일이 두 곳에) 사용자 초안으로 보지 않는다 — 지우면 다른 메일함의 사본까지 사라진다
+ * - 내부 초안은 발송 중 키워드나 발송 지문 헤더가 있다 — 지우면 진행 중인 발송이 깨진다
+ */
+/** 메일이 들어 있는 메일함 id */
+function inMailboxes(email: JmapEmail): string[] {
+ return Object.entries(email.mailboxIds ?? {})
+ .filter(([, isIn]) => isIn === true)
+ .map(([mailboxId]) => mailboxId);
+}
+
+function isUserDraft(email: JmapEmail, draftsId: string): boolean {
+ const fingerprint = (email as unknown as Record)[
+ FINGERPRINT_PROPERTY
+ ];
+ const mailboxes = inMailboxes(email);
+ return (
+ mailboxes.length === 1 &&
+ mailboxes[0] === draftsId &&
+ email.keywords?.$draft === true &&
+ email.keywords?.[INTERNAL_SEND_KEYWORD] !== true &&
+ !(typeof fingerprint === 'string' && fingerprint.trim() !== '')
+ );
+}
+
+/**
+ * 읽음 · 중요 표시 · 이동 · 스팸 표시를 JMAP 패치로 — 한 통(updateMessage)과 여러 통(updateMessages)이 같은 패치를 쓴다.
+ * 스팸 표시는 `$junk` · `$notjunk` 를 서로 반대로 둔다 (RFC 8621 4.1.1) — 메일함 이동과 같은 패치에 함께 든다
+ */
+function toEmailPatch(update: MessageUpdate): Record {
+ const patch: Record = {};
+ if (update.isRead !== undefined) {
+ patch['keywords/$seen'] = update.isRead ? true : null;
+ }
+ if (update.isFlagged !== undefined) {
+ patch['keywords/$flagged'] = update.isFlagged ? true : null;
+ }
+ if (update.isJunk !== undefined) {
+ patch['keywords/$junk'] = update.isJunk ? true : null;
+ patch['keywords/$notjunk'] = update.isJunk ? null : true;
+ }
+ if (update.mailboxId !== undefined) {
+ // 이동 = 이 메일함 하나에만 속하게 한다
+ patch.mailboxIds = { [update.mailboxId]: true };
+ }
+ return patch;
+}
+
+/** 메일함에서 메일을 지우는 방법 — 영구 삭제 · 그 메일함에서만 빼기 */
+type MailboxRemoval = 'destroy' | 'detach';
+
+/**
+ * 메일함(휴지통 · 스팸메일함)의 메일을 어떻게 지울지 — 그 메일함에만 있으면 영구 삭제, 다른 메일함에도 함께 있으면 그 메일함에서만 뺀다
+ * (영구 삭제하면 다른 메일함의 사본까지 사라진다). 그 메일함에 없으면 null — 지우지 않는다.
+ * 한 통 삭제(purgeFromTrash) · 여러 통 영구 삭제(deleteFromTrash) · 비우기(emptyMailbox)가 같은 규칙을 쓴다.
+ * @param mailboxIds 메일이 들어 있는 메일함 (inMailboxes)
+ */
+function removalFrom(
+ mailboxIds: readonly string[],
+ mailboxId: string,
+): MailboxRemoval | null {
+ if (!mailboxIds.includes(mailboxId)) return null;
+ return mailboxIds.every((candidate) => candidate === mailboxId)
+ ? 'destroy'
+ : 'detach';
+}
+
+/**
+ * 확인한 상태 그대로일 때만(ifInState) 하는 Email/set 의 바꿀 것 — 영구 삭제 · 고치기만 (만들기는 넣을 수 없다).
+ * ifInState 는 conditionalEmailSet 의 필수 인자라, 이 경로로는 조건 없는 지우기가 나갈 수 없다
+ * (조건 없는 지우기는 방금 만든 메일의 되돌림 경로 — discardQuietly · discardDraft — 에만 있다)
+ */
+interface EmailSetChanges {
+ readonly destroy?: readonly string[];
+ readonly update?: Readonly>>;
+}
+
+/** 메일함에서 지우는 바꿀 것 — 영구 삭제와 그 메일함에서 빼기 (removalFrom 의 두 갈래) */
+function removalChanges(
+ mailboxId: string,
+ destroy: readonly string[],
+ detach: readonly string[],
+): EmailSetChanges {
+ return {
+ ...(destroy.length > 0 ? { destroy } : {}),
+ ...(detach.length > 0
+ ? {
+ update: Object.fromEntries(
+ detach.map((id) => [id, { [`mailboxIds/${mailboxId}`]: null }]),
+ ),
+ }
+ : {}),
+ };
+}
+
+/**
+ * 확인한 상태 그대로일 때만(ifInState) 메일함에서 지우는 Email/set 인자 — 영구 삭제와 그 메일함에서 빼기를 한 번에.
+ * 상태가 어긋나면 엔진이 `stateMismatch` 로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인).
+ */
+function removalSet(
+ accountId: string,
+ ifInState: string,
+ mailboxId: string,
+ destroy: readonly string[],
+ detach: readonly string[],
+): Record {
+ const { destroy: destroyIds, update } = removalChanges(
+ mailboxId,
+ destroy,
+ detach,
+ );
+ return {
+ accountId,
+ ifInState,
+ ...(destroyIds ? { destroy: [...destroyIds] } : {}),
+ ...(update ? { update } : {}),
+ };
+}
+
+/** 엔진이 정한 메일함 역할(소문자) — 없으면 null. 우리가 모르는 역할도 역할이다 (기본 메일함으로 다룬다) */
+function engineRole(mailbox: JmapMailbox): string | null {
+ const role = mailbox.role?.trim().toLowerCase();
+ return role ? role : null;
+}
+
+/**
+ * 경로의 메일함 id 로 메일함을 찾는다 — 엔진에 그 id 를 보내지 않고 목록에서 찾는다
+ * (엔진이 해석하지 못하는 형식의 id 도 없는 메일함과 같은 RES_001 이 된다)
+ */
+function findMailbox(
+ mailboxes: readonly JmapMailbox[],
+ id: string,
+): JmapMailbox {
+ const mailbox = mailboxes.find((candidate) => candidate.id === id);
+ if (!mailbox) {
+ throw new BusinessException('RES_001', {
+ context: { reason: 'mailbox-not-found' },
+ });
+ }
+ return mailbox;
+}
+
+/** 역할이 있는 메일함(기본 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 VAL_001 (계약 2절 "메일함 관리") */
+function assertUserMailbox(mailbox: JmapMailbox, action: string): void {
+ const role = engineRole(mailbox);
+ if (role !== null) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'role-mailbox', role, action },
+ });
+ }
+}
+
+/**
+ * 메일함 만들기 · 이름 바꾸기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함)은 MAIL_009,
+ * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts)
+ */
+function mailboxSetFailure(
+ error: JmapError,
+ method: string,
+): BusinessException {
+ if (error.type === ALREADY_EXISTS) {
+ return new BusinessException('MAIL_009', {
+ context: { reason: 'mailbox-name-taken', method },
+ });
+ }
+ if (
+ error.type === 'invalidProperties' &&
+ (error.properties ?? []).includes('parentId')
+ ) {
+ return new BusinessException('RES_001', {
+ context: { reason: 'mailbox-parent-gone', method },
+ });
+ }
+ return jmapFailure(error, { method });
+}
+
+/**
+ * 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기)의 한 요청 — 시간 · 진행 · 멈춘 차례 (계약 2절 "메일함 관리").
+ * - 시간: 메일함 목록을 읽기 **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다
+ * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 —
+ * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게
+ * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가
+ * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다
+ * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001
+ */
+class BulkRun {
+ private readonly deadline = Date.now() + BULK_REQUEST_BUDGET_MS;
+ private stalls = 0;
+ private lastStall: string | null = null;
+ /** 이번 요청에서 처리한 메일 수 */
+ processed = 0;
+
+ /** 다음 차례를 시작해도 되는가 */
+ canStartRound(): boolean {
+ return this.left() > BULK_ROUND_RESERVE_MS;
+ }
+
+ /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */
+ callOptions(): JmapCallOptions {
+ return {
+ timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())),
+ };
+ }
+
+ /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */
+ progress(count: number): void {
+ if (count <= 0) return;
+ this.processed += count;
+ this.stalls = 0;
+ }
+
+ /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */
+ stalled(why: string): boolean {
+ this.stalls += 1;
+ this.lastStall = why;
+ return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS;
+ }
+
+ /**
+ * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다.
+ * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로)
+ */
+ cutOffAfterProgress(error: unknown): boolean {
+ return (
+ this.processed > 0 &&
+ this.left() <= 0 &&
+ error instanceof BusinessException &&
+ error.code === 'MAIL_001'
+ );
+ }
+
+ /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */
+ assertProgress(operation: string): void {
+ if (this.processed > 0) return;
+ throw new BusinessException('MAIL_001', {
+ context: { reason: `${operation}-no-progress`, stalled: this.lastStall },
+ });
+ }
+
+ private left(): number {
+ return this.deadline - Date.now();
+ }
+}
+
+/** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */
+function queryTotal(result: JmapQueryResult, received: number): number {
+ if (typeof result.total === 'number' && result.total >= received) {
+ return result.total;
+ }
+ return received === BULK_BATCH_SIZE ? received + 1 : received;
+}
+
+/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */
+function firstRefusal(
+ ...errors: (Record | undefined)[]
+): JmapError | undefined {
+ return errors
+ .flatMap((record) => Object.values(record ?? {}))
+ .find((error) => error?.type !== NOT_FOUND);
+}
+
+/**
+ * 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
+ * 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
+ */
+function combineMailStates(mailboxState: string, emailState: string): string {
+ const encode = (state: string) => Buffer.from(state).toString('base64url');
+ return `${encode(mailboxState)}.${encode(emailState)}`;
+}
+
+/** 메일 상태를 읽은 것 — 클라이언트에 주는 값과, 실시간 알림이 다음에 물을 엔진의 메일 상태 · 함께 읽은 메일함 */
+interface MailStatesRead {
+ /** GET /api/mail-state · 실시간 알림의 state (combineMailStates) */
+ readonly state: string;
+ readonly emailState: string;
+ readonly mailboxes: JmapMailbox[];
+}
+
+/**
+ * 같은 요청의 `Mailbox/get`('m') · `Email/get`('e') 응답에서 상태를 읽는다 — 새 메일 확인(getMailState)과 실시간 알림이
+ * **같은 함수**로 만든다(값이 어긋나면 클라이언트가 쓸데없이 다시 맞춘다). 엔진이 상태를 알려 주지 않으면 MAIL_001
+ */
+function readMailStates(responses: JmapInvocation[]): MailStatesRead {
+ const mailboxes = expectResult>(
+ responses,
+ 'm',
+ 'Mailbox/get',
+ );
+ const emails = expectResult>(
+ responses,
+ 'e',
+ 'Email/get',
+ );
+ if (!isState(mailboxes.state) || !isState(emails.state)) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'mail-state-missing' },
+ });
+ }
+ return {
+ state: combineMailStates(mailboxes.state, emails.state),
+ emailState: emails.state,
+ mailboxes: mailboxes.list ?? [],
+ };
+}
+
+/** 실시간 알림이 메일함에서 읽는 속성 — 받은편지함을 찾고 안 읽은 수를 센다 */
+const LIVE_MAILBOX_PROPERTIES = ['id', 'role', 'unreadEmails'];
+
+/** Email/changes 한 번에 받는 변화 수 — created 를 같은 요청의 Email/get 으로 읽으므로 엔진의 maxObjectsInGet(500) 이하 */
+const LIVE_MAX_CHANGES = 500;
+/** 한 번 알릴 때 Email/changes 를 이어 묻는 횟수 — 넘게 바뀌었으면(대량 가져오기 등) 이번에는 새 메일을 알리지 않는다 */
+const LIVE_MAX_CHANGE_PAGES = 4;
+/** Email/changes 의 메서드 오류 — 그 상태부터는 셀 수 없다 (너무 오래됨 등, RFC 8620 §5.2) */
+const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges';
+
+/** 엔진이 요청 전체를 거부했다(HTTP 400 notRequest) — jmap.client.ts 의 call 이 invalidRequestCode 로 바꿔 던진 것 */
+function isRejectedRequest(error: unknown): boolean {
+ return (
+ error instanceof BusinessException &&
+ error.context?.reason === 'request-rejected'
+ );
+}
+
+function findInbox(mailboxes: readonly JmapMailbox[]): JmapMailbox | undefined {
+ return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === 'inbox');
+}
+
+/** 실시간 알림의 상태 — 받은편지함이 없으면 안 읽은 수 0 */
+function toLiveState(
+ states: MailStatesRead,
+ inbox: JmapMailbox | undefined,
+): MailLiveState {
+ return { state: states.state, inboxUnread: inbox?.unreadEmails ?? 0 };
+}
+
+/** 메일함 · 메일 상태를 읽는 호출 ('m' · 'e' — readMailStates 가 읽는다). 받은편지함을 알면 그것만 읽는다 */
+function liveStateCalls(
+ accountId: string,
+ inboxId: string | null,
+): JmapInvocation[] {
+ return [
+ [
+ 'Mailbox/get',
+ {
+ accountId,
+ ids: inboxId === null ? null : [inboxId],
+ properties: LIVE_MAILBOX_PROPERTIES,
+ },
+ 'm',
+ ],
+ ['Email/get', { accountId, ids: [] }, 'e'],
+ ];
+}
+
+/** 메일에 함께 싣는 그림 — 엔진에 올린 blob */
+interface UploadedInlineImage {
+ readonly blobId: string;
+ readonly cid: string;
+ readonly type: string;
+ readonly name: string;
+}
+
+/**
+ * 본문·첨부 속성. 그림을 함께 실을 때만 bodyStructure 로 구조를 직접 적는다 —
+ * mixed[related[alternative[text, html], 그림…], 첨부…] (첨부가 없으면 related 가 맨 위). 그 밖에는 지금처럼
+ * textBody·htmlBody·attachments 로 맡긴다 (엔진이 alternative·mixed 를 만든다, RFC 8621 §4.6).
+ * (2026-10-27 개발 엔진으로 이 구조를 만들어 다시 읽고, 우리 받은 메일 정화가 cid 그림을 보이는 것까지 확인)
+ */
+function bodyProperties(
+ message: Pick,
+ inline: readonly UploadedInlineImage[],
+): Record {
+ const bodyValues = {
+ body: { value: message.body },
+ ...(message.htmlBody ? { html: { value: message.htmlBody } } : {}),
+ };
+ const attachments = message.attachments.map((attachment) => ({
+ blobId: attachment.id,
+ type: attachment.type,
+ name: attachment.name,
+ disposition: 'attachment',
+ }));
+
+ if (inline.length === 0 || !message.htmlBody) {
+ return {
+ bodyValues,
+ textBody: [{ partId: 'body', type: 'text/plain' }],
+ ...(message.htmlBody
+ ? { htmlBody: [{ partId: 'html', type: 'text/html' }] }
+ : {}),
+ ...(attachments.length ? { attachments } : {}),
+ };
+ }
+
+ const related = {
+ type: 'multipart/related',
+ subParts: [
+ {
+ type: 'multipart/alternative',
+ subParts: [
+ { partId: 'body', type: 'text/plain' },
+ { partId: 'html', type: 'text/html' },
+ ],
+ },
+ ...inline.map((image) => ({
+ blobId: image.blobId,
+ type: image.type,
+ name: image.name,
+ cid: image.cid,
+ disposition: 'inline',
+ })),
+ ],
+ };
+ return {
+ bodyValues,
+ bodyStructure: attachments.length
+ ? { type: 'multipart/mixed', subParts: [related, ...attachments] }
+ : related,
+ };
+}
+
+/** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */
+export function buildThreadHeaders(
+ original: Pick | undefined,
+): { inReplyTo: string[]; references: string[] } | null {
+ const messageIds = original?.messageId ?? [];
+ if (messageIds.length === 0) return null;
+
+ const references = [
+ ...new Set([...(original?.references ?? []), ...messageIds]),
+ ].slice(-MAX_REFERENCES);
+ return { inReplyTo: messageIds, references };
+}
+
+/**
+ * Stalwart(JMAP) 어댑터 — `MailProvider` 의 구현.
+ * JMAP 의 메서드·속성·오류 형태는 이 파일과 `jmap/` 안에서만 다룬다.
+ */
+@Injectable()
+export class JmapMailProvider implements MailProvider {
+ private readonly logger = new Logger(JmapMailProvider.name);
+
+ constructor(
+ private readonly jmap: JmapClient,
+ @Inject(CACHE_MANAGER) private readonly cache: Cache,
+ @Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
+ /** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */
+ private readonly filterScripts: JmapFilterScriptStore,
+ ) {}
+
+ verifyCredentials(email: string, password: string): Promise {
+ return this.jmap.verifyCredentials(email, password);
+ }
+
+ /** 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword) */
+ changePassword(
+ email: string,
+ currentPassword: string,
+ newPassword: string,
+ ): Promise {
+ return this.jmap.changePassword(email, currentPassword, newPassword);
+ }
+
+ async getProfile(
+ user: string,
+ options: { fresh?: boolean } = {},
+ ): Promise {
+ const session = await this.jmap.getSession(user, options);
+ // 로그인·리프레시(fresh)에서만 — 관리 권한이 있는 계정은 웹메일로 다루지 않는다 (있으면 AUTH_001)
+ if (options.fresh) await this.jmap.assertNoManagementAccess(user);
+ const responses = await this.jmap.call(user, (accountId) => [
+ ['Identity/get', { accountId, ids: null }, 'i'],
+ ]);
+ const { list = [] } = expectResult>(
+ responses,
+ 'i',
+ 'Identity/get',
+ );
+
+ const name = pickIdentity(list, session.username)?.name?.trim();
+ return {
+ email: session.username,
+ name: name || fallbackDisplayName(session.username),
+ };
+ }
+
+ async listMailboxes(user: string): Promise {
+ return (await this.loadMailboxes(user)).map(toMailbox);
+ }
+
+ /**
+ * 메일함 만들기 — 메일함 목록으로 상위를 확인하고(요청 1), 만들고 다시 읽는다(요청 2 — Mailbox/set 다음의 Mailbox/get 이 만든 것을 본다).
+ * - 상위가 없으면 RES_001, 받은편지함 · 보관함 · 역할 없는 메일함이 아니면 VAL_001
+ * - 같은 상위 안에 같은 이름이면 엔진이 alreadyExists 로 거절한다 → MAIL_009
+ * - 구독(isSubscribed)으로 만든다 — 구독한 폴더만 보이는 PC 메일 프로그램(IMAP)에도 보이게
+ */
+ async createMailbox(user: string, input: MailboxCreate): Promise {
+ const mailboxes = await this.loadMailboxes(user);
+ if (input.parentId !== null) {
+ const role = engineRole(findMailbox(mailboxes, input.parentId));
+ if (role !== null && !PARENT_ALLOWED_ROLES.has(role)) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'mailbox-parent-not-allowed', parentRole: role },
+ });
+ }
+ }
+
+ const responses = await this.jmap.call(user, (accountId) => [
+ [
+ 'Mailbox/set',
+ {
+ accountId,
+ create: {
+ mailbox: {
+ name: input.name,
+ parentId: input.parentId,
+ isSubscribed: true,
+ },
+ },
+ },
+ 'c',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: MAILBOX_PROPERTIES },
+ 'm',
+ ],
+ ]);
+ const set = expectResult(responses, 'c', 'Mailbox/set');
+ const failure = firstSetError(set.notCreated);
+ if (failure) throw mailboxSetFailure(failure, 'Mailbox/set');
+
+ const id = set.created?.mailbox?.id;
+ if (!id) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'mailbox-not-created' },
+ });
+ }
+ const { list = [] } = expectResult>(
+ responses,
+ 'm',
+ 'Mailbox/get',
+ );
+ // 다시 읽은 목록에 없으면(있을 수 없는 경우) 보낸 값으로 답한다 — 만들기는 성공했다
+ return toMailbox(
+ list.find((mailbox) => mailbox.id === id) ?? {
+ id,
+ name: input.name,
+ parentId: input.parentId,
+ },
+ );
+ }
+
+ /**
+ * 메일함 이름 바꾸기 — 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001, 같은 이름이 있으면 MAIL_009.
+ * 지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게)
+ */
+ async renameMailbox(
+ user: string,
+ id: string,
+ name: string,
+ ): Promise {
+ const mailbox = findMailbox(await this.loadMailboxes(user), id);
+ assertUserMailbox(mailbox, 'rename');
+ if (mailbox.name === name) return toMailbox(mailbox);
+
+ const responses = await this.jmap.call(user, (accountId) => [
+ ['Mailbox/set', { accountId, update: { [mailbox.id]: { name } } }, 'u'],
+ [
+ 'Mailbox/get',
+ { accountId, ids: [mailbox.id], properties: MAILBOX_PROPERTIES },
+ 'm',
+ ],
+ ]);
+ const set = expectResult(responses, 'u', 'Mailbox/set');
+ const failure = firstSetError(set.notUpdated);
+ if (failure) throw mailboxSetFailure(failure, 'Mailbox/set');
+
+ const [renamed] =
+ expectResult>(responses, 'm', 'Mailbox/get')
+ .list ?? [];
+ if (!renamed) {
+ throw new BusinessException('RES_001', {
+ context: { reason: 'mailbox-gone' },
+ });
+ }
+ return toMailbox(renamed);
+ }
+
+ /**
+ * 메일함 지우기 (계약 2절 "메일함 관리") — 메일을 영구 삭제하지 않는다. 한 요청의 시간 · 멈춘 차례는 BulkRun.
+ * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001
+ * 2) 차례마다 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 —
+ * 이 메일함에만 있으면 휴지통으로(이 메일함에서 빼고 휴지통에 넣는다), 다른 메일함에도 있으면 이 메일함에서만 뺀다
+ * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다).
+ * 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다
+ * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다).
+ * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다)
+ */
+ async deleteMailbox(user: string, id: string): Promise {
+ const run = new BulkRun();
+ const mailboxes = await this.loadMailboxes(user, run.callOptions());
+ const mailbox = findMailbox(mailboxes, id);
+ assertUserMailbox(mailbox, 'delete');
+ if (mailboxes.some((candidate) => candidate.parentId === mailbox.id)) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'mailbox-has-children' },
+ });
+ }
+ const trashId = roleMailboxId(mailboxes, 'trash');
+ const target = `mailboxIds/${mailbox.id}`;
+ let movedToTrash = 0;
+ let remaining = mailbox.totalEmails ?? 0;
+ const result = (deleted: boolean): MailboxDeletion => ({
+ deleted,
+ processed: run.processed,
+ movedToTrash,
+ remaining: deleted ? 0 : remaining,
+ });
+
+ try {
+ while (run.canStartRound()) {
+ const batch = await this.readMailboxBatch(
+ user,
+ mailbox.id,
+ run.callOptions(),
+ );
+ remaining = batch.total;
+ if (batch.emails.length === 0) {
+ if (
+ await this.destroyEmptyMailbox(user, mailbox.id, run.callOptions())
+ ) {
+ return result(true);
+ }
+ // 비운 사이 메일이 들어왔다 — 다시 옮긴다 (읽은 목록이 비어 있는데도 이어서 거절되면 엔진 이상 — 멈춘다)
+ if (run.stalled('mailbox-has-email')) break;
+ continue;
+ }
+ if (!trashId) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-mailbox-missing' },
+ });
+ }
+ // 상태를 모르면 조건 없이 옮기지 않는다
+ if (batch.state === null) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'mailbox-state-unknown' },
+ });
+ }
+
+ const toTrash: string[] = [];
+ const detach: string[] = [];
+ for (const email of batch.emails) {
+ if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') {
+ toTrash.push(email.id);
+ } else {
+ detach.push(email.id);
+ }
+ }
+ const set = await this.conditionalEmailSet(
+ user,
+ batch.state,
+ {
+ update: {
+ ...Object.fromEntries(
+ toTrash.map((emailId) => [
+ emailId,
+ { [target]: null, [`mailboxIds/${trashId}`]: true },
+ ]),
+ ),
+ ...Object.fromEntries(
+ detach.map((emailId) => [emailId, { [target]: null }]),
+ ),
+ },
+ },
+ run.callOptions(),
+ );
+ if (set === null) {
+ if (run.stalled('state-mismatch')) break;
+ continue;
+ }
+
+ const updated = new Set(Object.keys(set.updated ?? {}));
+ const moved = toTrash.filter((emailId) => updated.has(emailId)).length;
+ const removed =
+ moved + detach.filter((emailId) => updated.has(emailId)).length;
+ this.warnRefusals(user, set.notUpdated, '지우는 메일함에서 옮기지');
+ movedToTrash += moved;
+ remaining = Math.max(0, batch.total - removed);
+ if (removed > 0) {
+ run.progress(removed);
+ continue;
+ }
+
+ const refusal = firstRefusal(set.notUpdated);
+ if (refusal) {
+ if (run.processed > 0) break;
+ throw jmapFailure(refusal, { method: 'Email/set' });
+ }
+ // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 읽는다
+ if (run.stalled('no-change')) break;
+ }
+ } catch (error) {
+ if (!run.cutOffAfterProgress(error)) throw error;
+ }
+ run.assertProgress('mailbox-delete');
+ return result(false);
+ }
+
+ /**
+ * 메일함 전체 읽음 — 안 읽은 메일(`$seen` 없음)을 BULK_BATCH_SIZE 통씩 찾아 읽음으로 바꾼다 (조건 없이 — 되돌릴 수 있는 표시라서).
+ * 한 요청의 시간 · 멈춘 차례는 BulkRun — 남은 수를 돌려준다. 없는 메일함은 RES_001.
+ * 엔진이 모두 거절한 차례가 있으면 멈춘다 — 이 요청에서 바꾼 것이 없으면 그 거절의 코드로 실패한다 (클라이언트가 끝없이 다시 보내지 않게)
+ */
+ async markMailboxRead(user: string, id: string): Promise {
+ const run = new BulkRun();
+ const mailbox = findMailbox(
+ await this.loadMailboxes(user, run.callOptions()),
+ id,
+ );
+ let remaining = mailbox.unreadEmails ?? 0;
+
+ try {
+ while (run.canStartRound()) {
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/query',
+ {
+ accountId,
+ filter: { inMailbox: mailbox.id, notKeyword: '$seen' },
+ limit: BULK_BATCH_SIZE,
+ calculateTotal: true,
+ },
+ 'q',
+ ],
+ ],
+ run.callOptions(),
+ );
+ const query = expectResult(
+ responses,
+ 'q',
+ 'Email/query',
+ );
+ const ids = query.ids ?? [];
+ if (ids.length === 0) {
+ return { processed: run.processed, remaining: 0 };
+ }
+ const total = queryTotal(query, ids.length);
+ remaining = total;
+
+ const result = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/set',
+ {
+ accountId,
+ update: Object.fromEntries(
+ ids.map((emailId) => [emailId, { 'keywords/$seen': true }]),
+ ),
+ },
+ 'u',
+ ],
+ ],
+ run.callOptions(),
+ );
+ const set = expectResult(result, 'u', 'Email/set');
+ const updated = new Set(Object.keys(set.updated ?? {}));
+ const marked = ids.filter((emailId) => updated.has(emailId)).length;
+ this.warnRefusals(user, set.notUpdated, '읽음으로 바꾸지');
+ remaining = Math.max(0, total - marked);
+ if (marked > 0) {
+ run.progress(marked);
+ continue;
+ }
+
+ const refusal = firstRefusal(set.notUpdated);
+ if (refusal) {
+ if (run.processed > 0) break;
+ throw jmapFailure(refusal, { method: 'Email/set' });
+ }
+ // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 찾는다
+ if (run.stalled('no-change')) break;
+ }
+ } catch (error) {
+ if (!run.cutOffAfterProgress(error)) throw error;
+ }
+ run.assertProgress('mailbox-mark-read');
+ return { processed: run.processed, remaining };
+ }
+
+ /**
+ * 휴지통 · 스팸메일함 비우기 (영구 삭제) — 휴지통에서 영구 삭제(deleteFromTrash)와 같은 규칙이다:
+ * 차례마다 BULK_BATCH_SIZE 통을 읽어 확인한 상태 그대로일 때만(ifInState) 지운다 — 그 메일함에만 있으면 영구 삭제,
+ * 다른 메일함에도 있으면 그 메일함에서만 뺀다. 어긋나면 다시 읽는다. 한 요청의 시간 · 멈춘 차례는 BulkRun —
+ * 이 요청에서 지운 것이 없이 멈추면 MAIL_001 (지우지 않았다). 남은 수를 돌려준다.
+ * 없는 메일함은 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
+ */
+ async emptyMailbox(user: string, id: string): Promise {
+ const run = new BulkRun();
+ const mailbox = findMailbox(
+ await this.loadMailboxes(user, run.callOptions()),
+ id,
+ );
+ const role = engineRole(mailbox);
+ if (role === null || !EMPTYABLE_ROLES.has(role)) {
+ throw new BusinessException('VAL_001', {
+ context: { reason: 'mailbox-not-emptyable', role },
+ });
+ }
+ let remaining = mailbox.totalEmails ?? 0;
+
+ try {
+ while (run.canStartRound()) {
+ const batch = await this.readMailboxBatch(
+ user,
+ mailbox.id,
+ run.callOptions(),
+ );
+ if (batch.emails.length === 0) {
+ return { processed: run.processed, remaining: 0 };
+ }
+ remaining = batch.total;
+ // 상태를 모르면 조건 없이 지우지 않는다
+ if (batch.state === null) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'mailbox-state-unknown' },
+ });
+ }
+
+ const destroy: string[] = [];
+ const detach: string[] = [];
+ for (const email of batch.emails) {
+ if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') {
+ destroy.push(email.id);
+ } else {
+ detach.push(email.id);
+ }
+ }
+ const set = await this.conditionalEmailSet(
+ user,
+ batch.state,
+ removalChanges(mailbox.id, destroy, detach),
+ run.callOptions(),
+ );
+ if (set === null) {
+ if (run.stalled('state-mismatch')) break;
+ continue;
+ }
+
+ const destroyed = new Set(set.destroyed ?? []);
+ const detached = new Set(Object.keys(set.updated ?? {}));
+ const removed =
+ destroy.filter((emailId) => destroyed.has(emailId)).length +
+ detach.filter((emailId) => detached.has(emailId)).length;
+ this.warnRefusals(
+ user,
+ { ...set.notDestroyed, ...set.notUpdated },
+ '메일함을 비우며 지우지',
+ );
+ remaining = Math.max(0, batch.total - removed);
+ if (removed > 0) {
+ run.progress(removed);
+ continue;
+ }
+
+ const refusal = firstRefusal(set.notDestroyed, set.notUpdated);
+ if (refusal) {
+ if (run.processed > 0) break;
+ throw jmapFailure(refusal, { method: 'Email/set' });
+ }
+ // 바뀐 것도 거절도 없다 (모두 그사이 없어짐 등) — 다시 읽는다
+ if (run.stalled('no-change')) break;
+ }
+ } catch (error) {
+ if (!run.cutOffAfterProgress(error)) throw error;
+ }
+ run.assertProgress('mailbox-empty');
+ return { processed: run.processed, remaining };
+ }
+
+ /**
+ * 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다
+ * (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts)
+ */
+ getVacation(user: string): Promise {
+ return this.filterScripts.getVacation(user);
+ }
+
+ /** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */
+ setVacation(user: string, vacation: MailVacation): Promise {
+ return this.filterScripts.setVacation(user, vacation);
+ }
+
+ /** 메일 필터 — 규칙 · 지문 · 동작하는지 (계약 2절 "메일 필터") */
+ getMailFilters(user: string): Promise {
+ return this.filterScripts.getFilters(user);
+ }
+
+ /** 메일 필터를 통째로 바꾼다 — 그사이 규칙이 바뀌었으면 MAIL_012, 만든 스크립트가 한도를 넘으면 MAIL_013 */
+ setMailFilters(
+ user: string,
+ update: MailFilterUpdate,
+ ): Promise {
+ return this.filterScripts.setFilters(user, update);
+ }
+
+ async getUsage(user: string): Promise {
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [['Quota/get', { accountId, ids: null }, 'qt']],
+ { capabilities: [QUOTA_CAPABILITY] },
+ );
+ const { list = [] } = expectResult>(
+ responses,
+ 'qt',
+ 'Quota/get',
+ );
+
+ const quota = selectAccountQuota(list);
+ if (quota) return toUsage(quota);
+
+ // 계정 쿼터가 없다 = 한도 없음. 사용량은 메일 크기 합계로 대신한다 (연락처·일정 등은 빠진 근삿값)
+ const cacheKey = `mail:usage:${user}`;
+ const cached = await this.cache.get(cacheKey);
+ if (typeof cached === 'number') {
+ return { usedBytes: cached, limitBytes: null };
+ }
+
+ const usedBytes = await this.sumEmailSizes(user);
+ await this.cache.set(cacheKey, usedBytes, USAGE_CACHE_TTL_MS);
+ return { usedBytes, limitBytes: null };
+ }
+
+ /** 발송 검사(assertAttachmentsFit)와 같은 세션·같은 함수로 정한다 — 세션은 발송 때처럼 캐시된 것을 쓴다 */
+ async getAttachmentsTotalLimit(user: string): Promise {
+ return this.attachmentsTotalLimit(await this.jmap.getSession(user));
+ }
+
+ /**
+ * 새 메일 확인 — 메일함·메일의 상태 문자열만 읽는다 (`ids: []` — 메일을 읽지 않는 가벼운 조회, 요청 한 번).
+ * 새 메일이 오거나 다른 창·메일 프로그램이 읽음·이동·삭제를 하면 둘 중 하나가 바뀐다 (2026-10-27 개발 엔진 확인).
+ */
+ async getMailState(user: string): Promise {
+ const responses = await this.jmap.call(user, (accountId) => [
+ ['Mailbox/get', { accountId, ids: [] }, 'm'],
+ ['Email/get', { accountId, ids: [] }, 'e'],
+ ]);
+ // 실시간 알림의 state 도 이 함수로 만든다
+ return { state: readMailStates(responses).state };
+ }
+
+ /**
+ * 실시간 알림 (계약 2절 "실시간 알림") — 엔진의 EventSource(대리 접근 · types=Mailbox,Email · ping=30 · closeafter=no)를 하나 연다.
+ * 연결 · 다시 잇기 · 무소식 감시 · 신호 모으기는 JmapMailWatch, 엔진에 묻는 모양은 readLiveSnapshot · readLiveChanges 다.
+ */
+ watchMail(user: string, listener: MailWatchListener): Promise {
+ return JmapMailWatch.start(
+ {
+ open: (signal) =>
+ this.jmap.openEventSource(
+ user,
+ { types: PUSH_TYPES, pingSeconds: PUSH_PING_SECONDS },
+ signal,
+ ),
+ snapshot: (timeoutMs) => this.readLiveSnapshot(user, timeoutMs),
+ changes: (position) => this.readLiveChanges(user, position),
+ },
+ listener,
+ maskEmail(user),
+ );
+ }
+
+ async listMessages(
+ user: string,
+ query: MessageListQuery,
+ ): Promise {
+ const scope = { mailboxId: query.mailboxId, text: query.text };
+ // 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
+ const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
+
+ // 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
+ // 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
+ const textConditions =
+ query.text !== undefined ? searchTextConditions(query.text) : [];
+ if (query.text !== undefined && textConditions.length === 0) {
+ return { items: [], hasNext: false, nextCursor: null, total: 0 };
+ }
+
+ // 범위 — 메일함 하나, 또는 휴지통·스팸메일함을 뺀 모든 메일함(검색). 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 —
+ // 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다
+ const range: JmapEmailFilterCondition =
+ query.mailboxId !== undefined
+ ? { inMailbox: query.mailboxId }
+ : await this.searchableMailboxes(user);
+ const filter = withSearchConditions(
+ { ...range, notKeyword: INTERNAL_SEND_KEYWORD },
+ textConditions,
+ );
+
+ let responses = await this.queryMessages(
+ user,
+ query,
+ filter,
+ resume ? { anchor: resume.anchor } : { position: 0 },
+ );
+
+ // 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
+ const first = findResult(responses, 'q', 'Email/query');
+ if (resume && !first.ok && first.error.type === 'anchorNotFound') {
+ responses = await this.queryMessages(user, query, filter, {
+ position: resume.position,
+ });
+ }
+
+ if (query.mailboxId !== undefined) {
+ const mailbox = expectResult>(
+ responses,
+ 'mb',
+ 'Mailbox/get',
+ );
+ if (!mailbox.list?.length) {
+ // 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
+ throw new BusinessException('RES_001', {
+ context: { reason: 'mailbox-not-found' },
+ });
+ }
+ }
+
+ const result = expectResult(responses, 'q', 'Email/query');
+ const { list = [] } = expectResult>(
+ responses,
+ 'g',
+ 'Email/get',
+ );
+
+ const ids = result.ids ?? [];
+ const start = result.position ?? 0;
+ const total = typeof result.total === 'number' ? result.total : null;
+
+ // Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
+ const byId = new Map(list.map((email) => [email.id, email]));
+ const items = ids.flatMap((id) => {
+ const email = byId.get(id);
+ return email ? [toSummary(email)] : [];
+ });
+
+ const hasNext =
+ total !== null ? start + ids.length < total : ids.length === query.limit;
+ const lastId = ids.at(-1);
+
+ return {
+ items,
+ hasNext,
+ nextCursor:
+ hasNext && lastId
+ ? encodeCursor(
+ { anchor: lastId, position: start + ids.length },
+ scope,
+ )
+ : null,
+ total,
+ };
+ }
+
+ async getMessage(user: string, id: string): Promise {
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/get',
+ {
+ accountId,
+ ids: [id],
+ properties: DETAIL_PROPERTIES,
+ bodyProperties: BODY_PART_PROPERTIES,
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ // 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 주고 그 부분에 isTruncated 를 켠다 (매퍼가 읽는다).
+ // 우리가 보낸 서식 본문은 발송 때 정화 결과를 같은 바이트 수로 막았으므로 잘리지 않는다 (mail.constants.ts)
+ maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
+ },
+ 'g',
+ ],
+ ],
+ { invalidRequestCode: 'RES_001' },
+ );
+
+ return toDetailSource(this.requireEmail(responses, 'g'));
+ }
+
+ async updateMessage(
+ user: string,
+ id: string,
+ update: MessageUpdate,
+ ): Promise {
+ const patch = toEmailPatch(update);
+
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [
+ ['Email/set', { accountId, update: { [id]: patch } }, 'u'],
+ [
+ 'Email/get',
+ {
+ accountId,
+ ids: [id],
+ properties: SUMMARY_PROPERTIES,
+ bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
+ },
+ 'g',
+ ],
+ ],
+ { invalidRequestCode: 'RES_001' },
+ );
+
+ const set = expectResult(responses, 'u', 'Email/set');
+ const failure = firstSetError(set.notUpdated);
+ if (failure) throw jmapFailure(failure, { method: 'Email/set' });
+
+ return toSummary(this.requireEmail(responses, 'g'));
+ }
+
+ /**
+ * 여러 통에 같은 변경을 한다 — 엔진 요청 한 번(옮길 메일함 확인 + Email/set). 메일마다 패치는 한 통 PATCH 와 같다.
+ * - 바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds — 나머지는 그대로 처리한다
+ * - 옮길 메일함이 없으면 404 RES_001 — 엔진은 없는 메일함으로의 이동을 메일마다 거절하므로(invalidProperties)
+ * 같은 요청의 Email/set 도 아무것도 바꾸지 않는다. 메일 하나가 바뀐 것처럼 오면(있을 수 없는 경우) 사실대로 돌려준다
+ * - 메서드 단위 실패는 그 코드(엔진 장애 MAIL_001) — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내도 결과가 같다)
+ * id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 요청 전체를 거부하면 VAL_001 (아무것도 바꾸지 않았다)
+ */
+ async updateMessages(
+ user: string,
+ ids: readonly string[],
+ update: MessageUpdate,
+ ): Promise {
+ const patch = toEmailPatch(update);
+ const { mailboxId } = update;
+
+ const responses = await this.jmap.call(
+ user,
+ (accountId) => [
+ ...(mailboxId !== undefined
+ ? [
+ [
+ 'Mailbox/get',
+ { accountId, ids: [mailboxId], properties: ['id'] },
+ 'mb',
+ ] as JmapInvocation,
+ ]
+ : []),
+ [
+ 'Email/set',
+ {
+ accountId,
+ update: Object.fromEntries(ids.map((id) => [id, patch])),
+ },
+ 'u',
+ ],
+ ],
+ { invalidRequestCode: 'VAL_001' },
+ );
+
+ const set = expectResult(responses, 'u', 'Email/set');
+ const updated = new Set(Object.keys(set.updated ?? {}));
+ const updatedIds = ids.filter((id) => updated.has(id));
+ const failedIds = ids.filter((id) => !updated.has(id));
+
+ if (mailboxId !== undefined) {
+ const mailbox = expectResult>(
+ responses,
+ 'mb',
+ 'Mailbox/get',
+ );
+ if (!mailbox.list?.length) {
+ if (updatedIds.length === 0) {
+ throw new BusinessException('RES_001', {
+ context: { reason: 'mailbox-not-found', requested: ids.length },
+ });
+ }
+ this.logger.warn(
+ `[MAIL_001]${traceTag()} 없는 메일함으로 옮긴 메일이 있다고 엔진이 답했습니다 — 결과를 그대로 돌려줍니다: user=${maskEmail(user)}, 바뀜=${updatedIds.length}통`,
+ );
+ }
+ }
+
+ this.warnRefusals(user, set.notUpdated, '바꾸지');
+ return { updatedIds, failedIds };
+ }
+
+ async deleteMessage(user: string, id: string): Promise {
+ const lookup = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/get',
+ { accountId, ids: [id], properties: ['id', 'mailboxIds'] },
+ 'g',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: ['id', 'role'] },
+ 'm',
+ ],
+ ],
+ { invalidRequestCode: 'RES_001' },
+ );
+
+ const email = this.requireEmail(lookup, 'g');
+ const { list: mailboxes = [] } = expectResult>(
+ lookup,
+ 'm',
+ 'Mailbox/get',
+ );
+ const trashId = roleMailboxId(mailboxes, 'trash');
+ if (!trashId) {
+ // 휴지통 없이 지우면 되돌릴 수 없다 — 영구 삭제로 대신하지 않는다
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-mailbox-missing' },
+ });
+ }
+
+ if (email.mailboxIds?.[trashId] === true) {
+ // 휴지통에 있다 — 확인한 상태 그대로일 때만 영구 삭제한다
+ const { state } = expectResult>(
+ lookup,
+ 'g',
+ 'Email/get',
+ );
+ await this.purgeFromTrash(
+ user,
+ email.id,
+ trashId,
+ typeof state === 'string'
+ ? { state, mailboxIds: inMailboxes(email) }
+ : null,
+ );
+ return;
+ }
+
+ const responses = await this.jmap.call(user, (accountId) => [
+ [
+ 'Email/set',
+ {
+ accountId,
+ update: { [email.id]: { mailboxIds: { [trashId]: true } } },
+ },
+ 'd',
+ ],
+ ]);
+ const set = expectResult(responses, 'd', 'Email/set');
+ const failure = firstSetError(set.notUpdated);
+ if (failure) throw jmapFailure(failure, { method: 'Email/set' });
+ }
+
+ /**
+ * 휴지통의 메일을 영구 삭제한다 — 확인한 뒤 계정의 메일이 바뀌지 않았을 때만 (ifInState, destroyUserDraft 와 같은 모양).
+ * - 휴지통에만 있으면 지운다 (영구 삭제)
+ * - 휴지통과 다른 메일함에 함께 있으면 지우지 않고 휴지통에서만 뺀다 — 지우면 다른 메일함의 사본까지 사라진다
+ * - 확인하는 사이 휴지통 밖으로 되돌려졌거나 이미 없으면 아무것도 하지 않는다 — 지금 상태를 존중한다 (응답은 그대로 204, "이미 처리됨")
+ * - 어긋남이 CONDITIONAL_DESTROY_ATTEMPTS 번 이어지면 지우지 않고 MAIL_001 (다시 시도하면 된다)
+ * @param checked 휴지통에 있음을 확인한 때의 상태와 그때의 메일함 — 모르면 null (먼저 확인한다)
+ */
+ private async purgeFromTrash(
+ user: string,
+ id: string,
+ trashId: string,
+ checked: { state: string; mailboxIds: string[] } | null,
+ ): Promise {
+ let snapshot = checked;
+ for (
+ let attempt = 0;
+ attempt < CONDITIONAL_DESTROY_ATTEMPTS;
+ attempt += 1
+ ) {
+ if (snapshot === null) {
+ const lookup = await this.jmap.call(user, (accountId) => [
+ [
+ 'Email/get',
+ { accountId, ids: [id], properties: ['id', 'mailboxIds'] },
+ 'g',
+ ],
+ ]);
+ const found = expectResult>(
+ lookup,
+ 'g',
+ 'Email/get',
+ );
+ const email = found.list?.[0];
+ // 이미 없다 · 휴지통 밖으로 되돌려졌다 — 아무것도 하지 않는다
+ if (!email || email.mailboxIds?.[trashId] !== true) return;
+ if (typeof found.state !== 'string') {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-state-unknown' },
+ });
+ }
+ snapshot = { state: found.state, mailboxIds: inMailboxes(email) };
+ }
+
+ // 확인한 대로면 늘 휴지통에 있다 — 아니면 지우지 않는다
+ const removal = removalFrom(snapshot.mailboxIds, trashId);
+ if (removal === null) return;
+ const ifInState = snapshot.state;
+ const responses = await this.jmap.call(user, (accountId) => [
+ [
+ 'Email/set',
+ removalSet(
+ accountId,
+ ifInState,
+ trashId,
+ removal === 'destroy' ? [id] : [],
+ removal === 'detach' ? [id] : [],
+ ),
+ 'd',
+ ],
+ ]);
+ const result = findResult(responses, 'd', 'Email/set');
+ if (!result.ok) {
+ if (result.error.type === STATE_MISMATCH) {
+ snapshot = null;
+ continue;
+ }
+ throw jmapFailure(result.error, { method: 'Email/set' });
+ }
+ const set = result.args as JmapSetResult;
+ const failure = firstSetError(
+ removal === 'destroy' ? set.notDestroyed : set.notUpdated,
+ );
+ // 지웠다 · 휴지통에서 뺐다 · 그사이 없어졌다
+ if (!failure || failure.type === NOT_FOUND) return;
+ throw jmapFailure(failure, { method: 'Email/set' });
+ }
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-delete-contended' },
+ });
+ }
+
+ /**
+ * 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 다룬다. 휴지통 밖의 메일을 휴지통으로 옮기지 않는다 (DELETE 한 통과 다르다).
+ * 한 번의 시도: 메일·메일함 확인(요청 1) → 확인한 상태 그대로일 때만 한 번에 지우기(요청 2, ifInState).
+ * - 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 (removalFrom — 한 통 삭제와 같은 규칙)
+ * - 없음 · 휴지통에 없음 → skippedIds (확인한 뒤 다른 창이 휴지통 밖으로 되돌린 메일은 상태가 어긋나 지우지 않는다)
+ * - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고)
+ * - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001
+ * - 휴지통이 없으면 MAIL_001 (지우지 않는다)
+ * id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 확인 요청 전체를 거부하면 VAL_001 (지우지 않았다)
+ */
+ async deleteFromTrash(
+ user: string,
+ ids: readonly string[],
+ ): Promise {
+ for (
+ let attempt = 0;
+ attempt < CONDITIONAL_DESTROY_ATTEMPTS;
+ attempt += 1
+ ) {
+ const lookup = await this.jmap.call(
+ user,
+ (accountId) => [
+ [
+ 'Email/get',
+ { accountId, ids: [...ids], properties: ['id', 'mailboxIds'] },
+ 'g',
+ ],
+ [
+ 'Mailbox/get',
+ { accountId, ids: null, properties: ['id', 'role'] },
+ 'm',
+ ],
+ ],
+ { invalidRequestCode: 'VAL_001' },
+ );
+ const found = expectResult>(
+ lookup,
+ 'g',
+ 'Email/get',
+ );
+ const { list: mailboxes = [] } = expectResult>(
+ lookup,
+ 'm',
+ 'Mailbox/get',
+ );
+ const trashId = roleMailboxId(mailboxes, 'trash');
+ if (!trashId) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-mailbox-missing' },
+ });
+ }
+
+ // 요청한 id 그대로 찾는다 — 엔진이 돌려준 id 가 다르면(정규화) 지우지 않고 건너뛴다
+ const byId = new Map(
+ (found.list ?? []).map((email) => [email.id, email]),
+ );
+ const destroy: string[] = [];
+ const untrash: string[] = [];
+ for (const id of ids) {
+ const email = byId.get(id);
+ const removal = email ? removalFrom(inMailboxes(email), trashId) : null;
+ if (removal === 'destroy') destroy.push(id);
+ else if (removal === 'detach') untrash.push(id);
+ }
+ if (destroy.length === 0 && untrash.length === 0) {
+ return { deletedIds: [], skippedIds: [...ids] };
+ }
+ // 상태를 모르면 조건 없이 지우지 않는다
+ if (!isState(found.state)) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-state-unknown' },
+ });
+ }
+
+ const ifInState = found.state;
+ const responses = await this.jmap.call(user, (accountId) => [
+ [
+ 'Email/set',
+ removalSet(accountId, ifInState, trashId, destroy, untrash),
+ 'd',
+ ],
+ ]);
+ const result = findResult(responses, 'd', 'Email/set');
+ if (!result.ok) {
+ // 확인한 뒤 계정의 메일이 바뀌었다 — 아무것도 하지 않았으니 다시 확인한다
+ if (result.error.type === STATE_MISMATCH) continue;
+ throw jmapFailure(result.error, { method: 'Email/set' });
+ }
+
+ const set = result.args as JmapSetResult;
+ // 엔진이 지웠다 · 휴지통에서 뺐다고 답한 것 중 이 요청이 지우려던 것만
+ const destroyed = new Set(set.destroyed ?? []);
+ const untrashed = new Set(Object.keys(set.updated ?? {}));
+ const deleted = new Set([
+ ...destroy.filter((id) => destroyed.has(id)),
+ ...untrash.filter((id) => untrashed.has(id)),
+ ]);
+ this.warnRefusals(
+ user,
+ { ...set.notDestroyed, ...set.notUpdated },
+ '휴지통에서 지우지',
+ );
+ return {
+ deletedIds: ids.filter((id) => deleted.has(id)),
+ skippedIds: ids.filter((id) => !deleted.has(id)),
+ };
+ }
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'trash-delete-contended', requested: ids.length },
+ });
+ }
+
+ async sendMessage(
+ user: string,
+ message: OutgoingMessage,
+ ): Promise