generated from ttipo/project-base
feat(backend): 메일 검색 색인의 바탕(표 · 맞추기 · 지난 메일 채우기 · 준비 상태)을 더한다
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다. - 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와 mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다) - 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝) - 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전, 서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조 - GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘 - 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다) 리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB · 16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import request from 'supertest';
|
||||
import type { Response, Test as HttpTest } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { configureApp, createHttpAdapter } from '@/app.setup';
|
||||
@@ -121,6 +122,14 @@ import {
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import {
|
||||
MAIL_INDEX_SOURCE,
|
||||
type IndexDocument,
|
||||
type MailIndexSource,
|
||||
} from '@/modules/mail-search/mail-index-source';
|
||||
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
|
||||
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
|
||||
import { toIndexRow } from '@/modules/mail-search/index-document';
|
||||
import {
|
||||
SystemSettingsService,
|
||||
type SystemSettingsPatch,
|
||||
@@ -491,6 +500,108 @@ const testFileStorageSettings = (dir: string): FileStorageSettings => ({
|
||||
clamav: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* 14단계 — 검색 색인의 대역 엔진: 직원마다 메일 글과 state. 실제 엔진 읽기(JMAP)는 jmap-index-source.spec.ts 가 본다.
|
||||
* 넣지 않은 직원은 엔진 장애(MAIL_001)로 답한다 — 개발 DB 를 함께 쓰는 실제 직원의 줄을 대역으로 채우지 않게
|
||||
*/
|
||||
class FakeIndexSource implements MailIndexSource {
|
||||
readonly mailboxes = new Map<
|
||||
string,
|
||||
{ state: string; emails: IndexDocument[] }
|
||||
>();
|
||||
|
||||
private mailbox(user: string) {
|
||||
const found = this.mailboxes.get(user);
|
||||
if (!found) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'e2e-unknown-index-user' },
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
snapshot(user: string) {
|
||||
const { state, emails } = this.mailbox(user);
|
||||
return Promise.resolve({ state, total: emails.length });
|
||||
}
|
||||
|
||||
changes(user: string, sinceState: string) {
|
||||
const { state } = this.mailbox(user);
|
||||
// 같은 state 면 변화 없음, 다르면 셀 수 없음(전체 대조로)
|
||||
return Promise.resolve(
|
||||
sinceState === state
|
||||
? {
|
||||
kind: 'changes' as const,
|
||||
created: [],
|
||||
updated: [],
|
||||
destroyed: [],
|
||||
newState: state,
|
||||
hasMore: false,
|
||||
}
|
||||
: { kind: 'cannotCalculate' as const },
|
||||
);
|
||||
}
|
||||
|
||||
documents(user: string, ids: readonly string[]) {
|
||||
const { emails } = this.mailbox(user);
|
||||
return Promise.resolve(emails.filter((email) => ids.includes(email.id)));
|
||||
}
|
||||
|
||||
mutable(user: string, ids: readonly string[]) {
|
||||
return this.documents(user, ids);
|
||||
}
|
||||
|
||||
pageIds(
|
||||
user: string,
|
||||
from: { anchor: string | null; position: number },
|
||||
limit: number,
|
||||
) {
|
||||
const ids = this.newestFirst(user);
|
||||
const anchorAt = from.anchor === null ? -1 : ids.indexOf(from.anchor);
|
||||
const start = anchorAt >= 0 ? anchorAt + 1 : from.position;
|
||||
return Promise.resolve({
|
||||
ids: ids.slice(start, start + limit),
|
||||
position: start,
|
||||
total: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
allIds(user: string) {
|
||||
const { state } = this.mailbox(user);
|
||||
return Promise.resolve({ state, ids: this.newestFirst(user) });
|
||||
}
|
||||
|
||||
private newestFirst(user: string): string[] {
|
||||
return [...this.mailbox(user).emails]
|
||||
.sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))
|
||||
.map((email) => email.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 14단계 — 색인에 넣을 메일 한 통 (대역) */
|
||||
function indexDocument(overrides: Partial<IndexDocument>): IndexDocument {
|
||||
return {
|
||||
id: 'e2e-mail',
|
||||
threadId: 'e2e-thread',
|
||||
mailboxIds: ['inbox'],
|
||||
keywords: [],
|
||||
receivedAt: '2026-11-05T01:00:00Z',
|
||||
size: 100,
|
||||
messageIds: [],
|
||||
inReplyTo: [],
|
||||
references: [],
|
||||
subject: '',
|
||||
from: [{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }],
|
||||
to: [],
|
||||
cc: [],
|
||||
attachmentNames: [],
|
||||
hasAttachment: false,
|
||||
preview: '',
|
||||
bodyText: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 목록 응답의 data — 계약의 { items, pageInfo } */
|
||||
interface Page<T> {
|
||||
items: T[];
|
||||
@@ -925,6 +1036,9 @@ describe('응답 계약 (e2e)', () => {
|
||||
const rotated = (res: Response) =>
|
||||
cookieValue(setCookie(res, 'refresh_token'));
|
||||
|
||||
/** 14단계 — 검색 색인의 대역 엔진 */
|
||||
const fakeIndexSource = new FakeIndexSource();
|
||||
|
||||
beforeAll(async () => {
|
||||
fileDir = mkdtempSync(join(tmpdir(), 'arasystem-e2e-files-'));
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
@@ -1557,6 +1671,9 @@ describe('응답 계약 (e2e)', () => {
|
||||
.useValue(fakeAccountSecurityProvider)
|
||||
.overrideProvider(ACCOUNT_SECURITY_SETTINGS)
|
||||
.useValue(TEST_ACCOUNT_SECURITY_SETTINGS)
|
||||
// 14단계 — 검색 색인의 엔진 읽기는 대역으로
|
||||
.overrideProvider(MAIL_INDEX_SOURCE)
|
||||
.useValue(fakeIndexSource)
|
||||
.compile();
|
||||
|
||||
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
|
||||
@@ -1572,6 +1689,13 @@ describe('응답 계약 (e2e)', () => {
|
||||
configureApp(app);
|
||||
|
||||
await app.init();
|
||||
// 14단계 — 검색 색인의 5분 맞추기 · 1분 채우기는 돌리지 않는다 — 테스트가 직접 부른다.
|
||||
// 거꾸로 같은 DB 를 쓰는 개발 서버(개발 PC 에서만 — CI 에는 없다)의 cron 은 사내 도메인(arasystem.test) 주소만 맞추고 채우므로
|
||||
// 이 시험의 줄(e2e.arasystem.test)을 건드리지 않는다 (MailSearchSyncService.isIndexedAddress)
|
||||
const scheduler = app.get(SchedulerRegistry);
|
||||
for (const name of ['mail-search-sync', 'mail-search-build']) {
|
||||
void scheduler.getCronJob(name).stop();
|
||||
}
|
||||
server = app.getHttpServer();
|
||||
jwt = app.get(JwtService, { strict: false });
|
||||
authSettings = app.get<AuthSettings>(AUTH_SETTINGS, { strict: false });
|
||||
@@ -1603,6 +1727,9 @@ describe('응답 계약 (e2e)', () => {
|
||||
'DELETE FROM login_events WHERE user_email LIKE $1',
|
||||
'DELETE FROM app_password_usages WHERE user_email LIKE $1',
|
||||
'DELETE FROM two_factor_settings WHERE user_email LIKE $1',
|
||||
// 14단계 — 검색 색인 (로그인하면 상태 줄이 생긴다)
|
||||
'DELETE FROM mail_search_messages WHERE user_email LIKE $1',
|
||||
'DELETE FROM mail_search_states WHERE user_email LIKE $1',
|
||||
]) {
|
||||
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
|
||||
}
|
||||
@@ -7526,6 +7653,193 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('14단계 1차 — 검색 색인 바탕 (GET /api/search-index · 색인 표 · 채우기)', () => {
|
||||
const ME = 'search.index@e2e.arasystem.test';
|
||||
const OTHER = 'search.other@e2e.arasystem.test';
|
||||
const FROM_IP = '198.51.100.140';
|
||||
|
||||
const signIn = async (email: string) => {
|
||||
const res = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', FROM_IP)
|
||||
.send({ email, password: CORRECT_PASSWORD })
|
||||
.expect(200);
|
||||
return `access_token=${cookieValue(setCookie(res, 'access_token'))}`;
|
||||
};
|
||||
const statusOf = (cookie: string) =>
|
||||
request(server).get('/api/search-index').set('Cookie', cookie);
|
||||
|
||||
afterEach(async () => {
|
||||
fakeIndexSource.mailboxes.clear();
|
||||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM "${table}" WHERE user_email = ANY($1::text[])`,
|
||||
[[ME, OTHER]],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('로그인하면 상태 줄이 building 으로 생긴다 — 준비 상태는 { building, 0, null } · 로그인 없이는 401 AUTH_001', async () => {
|
||||
const cookie = await signIn(ME);
|
||||
const rows: { status: string; email_state: string | null }[] =
|
||||
await dataSource.query(
|
||||
'SELECT status, email_state FROM mail_search_states WHERE user_email = $1',
|
||||
[ME],
|
||||
);
|
||||
expect(rows).toEqual([{ status: 'building', email_state: null }]);
|
||||
|
||||
const res = await statusOf(cookie).expect(200);
|
||||
expect(dataOf(res)).toEqual({ status: 'building', done: 0, total: null });
|
||||
|
||||
const anonymous = await request(server)
|
||||
.get('/api/search-index')
|
||||
.expect(401);
|
||||
expect(body(anonymous).error?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('상태 줄이 없던 직원은 준비 상태를 물을 때 만든다 · 멈춘 줄은 로그인하면 푼다', async () => {
|
||||
// 세션 없이 서명만 맞춘 쿠키 — 로그인을 거치지 않았다
|
||||
await statusOf(cookieOf(OTHER)).expect(200);
|
||||
await dataSource.query(
|
||||
`UPDATE mail_search_states SET paused_reason = 'locked', failure_count = 3 WHERE user_email = $1`,
|
||||
[OTHER],
|
||||
);
|
||||
await signIn(OTHER);
|
||||
const rows: { paused_reason: string | null; failure_count: number }[] =
|
||||
await dataSource.query(
|
||||
'SELECT paused_reason, failure_count FROM mail_search_states WHERE user_email = $1',
|
||||
[OTHER],
|
||||
);
|
||||
expect(rows).toEqual([{ paused_reason: null, failure_count: 0 }]);
|
||||
});
|
||||
|
||||
it('채우기 → ready — 색인 줄의 글자 두 개 묶음(GIN)과 접기 글(strpos)로 부분 일치를 찾는다 (실제 PostgreSQL)', async () => {
|
||||
const cookie = await signIn(ME);
|
||||
fakeIndexSource.mailboxes.set(ME, {
|
||||
state: 's1',
|
||||
emails: [
|
||||
indexDocument({
|
||||
id: 'm-contract',
|
||||
subject: 'Re: [공지] 11월 계약서',
|
||||
bodyText: '계약서를 보내 드립니다. Project PLAN 첨부',
|
||||
attachmentNames: ['계약서.PDF'],
|
||||
hasAttachment: true,
|
||||
mailboxIds: ['inbox', 'archive'],
|
||||
keywords: ['$seen'],
|
||||
receivedAt: '2026-11-04T01:00:00Z',
|
||||
}),
|
||||
indexDocument({
|
||||
id: 'm-meeting',
|
||||
subject: '회의록',
|
||||
bodyText: '내일 회의는 3층 회의실',
|
||||
receivedAt: '2026-11-05T01:00:00Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const build = app.get(MailSearchBuildService);
|
||||
// 마무리(따라잡기 · 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만 시작한다 — 넉넉히
|
||||
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
|
||||
expect(dataOf(await statusOf(cookie).expect(200))).toEqual({
|
||||
status: 'ready',
|
||||
done: 2,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const find = async (column: string, word: string, grams: string[]) =>
|
||||
(
|
||||
await dataSource.query<{ email_id: string }[]>(
|
||||
`SELECT email_id FROM mail_search_messages WHERE user_email = $1 AND grams @> $2::text[] AND strpos(${column}, $3) > 0 ORDER BY received_at DESC`,
|
||||
[ME, grams, word],
|
||||
)
|
||||
).map((row) => row.email_id);
|
||||
// 붙여 쓴 말 · 앞부분 · 말머리 · 대소문자
|
||||
expect(await find('fold_body', '계약', ['계약'])).toEqual(['m-contract']);
|
||||
expect(await find('fold_body', 'proj', ['pr', 'ro', 'oj'])).toEqual([
|
||||
'm-contract',
|
||||
]);
|
||||
expect(await find('fold_subject', '공지', ['공지'])).toEqual([
|
||||
'm-contract',
|
||||
]);
|
||||
expect(await find('fold_attachment', '.pdf', ['.p', 'pd', 'df'])).toEqual(
|
||||
['m-contract'],
|
||||
);
|
||||
expect(await find('fold_body', '회의', ['회의'])).toEqual(['m-meeting']);
|
||||
// 메일함 범위(GIN) · 배열 칸이 그대로 들어갔다
|
||||
const inArchive: { email_id: string; keywords: string[] }[] =
|
||||
await dataSource.query(
|
||||
`SELECT email_id, keywords FROM mail_search_messages WHERE user_email = $1 AND mailbox_ids && $2::text[]`,
|
||||
[ME, ['archive']],
|
||||
);
|
||||
expect(inArchive).toEqual([
|
||||
{ email_id: 'm-contract', keywords: ['$seen'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('표 쓰기 — 넣기 · 메일함 · 키워드만 바꾸기(한 문장) · 지우기, 상태 줄이 없으면 아무것도 쓰지 않는다', async () => {
|
||||
await statusOf(cookieOf(OTHER)).expect(200);
|
||||
const repository = app.get(MailSearchIndexRepository);
|
||||
const now = new Date();
|
||||
const row = toIndexRow(indexDocument({ id: 'w1', subject: '견적' }));
|
||||
|
||||
expect(
|
||||
(
|
||||
await repository.write(
|
||||
OTHER,
|
||||
{ upserts: [row, { ...row, emailId: 'w2' }] },
|
||||
now,
|
||||
)
|
||||
).written,
|
||||
).toBe(true);
|
||||
const updated = await repository.write(
|
||||
OTHER,
|
||||
{
|
||||
updates: [
|
||||
{
|
||||
id: 'w1',
|
||||
mailboxIds: ['trash'],
|
||||
keywords: ['$seen', '$flagged'],
|
||||
},
|
||||
{ id: 'not-indexed', mailboxIds: ['inbox'], keywords: [] },
|
||||
],
|
||||
deletes: ['w2'],
|
||||
state: { emailState: 's9', lastSyncedAt: now },
|
||||
},
|
||||
now,
|
||||
);
|
||||
expect([...updated.updatedIds]).toEqual(['w1']);
|
||||
expect(await repository.indexedIds(OTHER)).toEqual(['w1']);
|
||||
expect([...(await repository.existingIds(OTHER, ['w1', 'w2']))]).toEqual([
|
||||
'w1',
|
||||
]);
|
||||
const [stored]: { mailbox_ids: string[]; keywords: string[] }[] =
|
||||
await dataSource.query(
|
||||
'SELECT mailbox_ids, keywords FROM mail_search_messages WHERE user_email = $1',
|
||||
[OTHER],
|
||||
);
|
||||
expect(stored).toEqual({
|
||||
mailbox_ids: ['trash'],
|
||||
keywords: ['$seen', '$flagged'],
|
||||
});
|
||||
expect((await repository.findState(OTHER))?.emailState).toBe('s9');
|
||||
expect(await repository.recordFailure(OTHER, now)).toBe(1);
|
||||
|
||||
// 퇴사 마무리처럼 상태 줄 → 색인 줄을 지우면, 뒤늦은 쓰기는 아무것도 하지 않는다
|
||||
await dataSource.query(
|
||||
'DELETE FROM mail_search_states WHERE user_email = $1',
|
||||
[OTHER],
|
||||
);
|
||||
await dataSource.query(
|
||||
'DELETE FROM mail_search_messages WHERE user_email = $1',
|
||||
[OTHER],
|
||||
);
|
||||
expect(
|
||||
(await repository.write(OTHER, { upserts: [row] }, now)).written,
|
||||
).toBe(false);
|
||||
expect(await repository.countMessages(OTHER)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
|
||||
let systemSettings: SystemSettingsService;
|
||||
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다).
|
||||
* 서버의 색인 · 찾기(src/shared/utils/search-text.ts · subject-base.ts)와 화면의 미리 확인 · 강조(frontend `shared/utils/search-match.ts`)를 같은 표로 본다.
|
||||
*
|
||||
* ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로 — 14단계 frontend 차례에 옮긴다).
|
||||
* 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
|
||||
* 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다).
|
||||
*/
|
||||
|
||||
/** 접기 — [넣은 글, 접은 글]. 접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 */
|
||||
export const FOLD_SAMPLES: readonly (readonly [
|
||||
input: string,
|
||||
folded: string,
|
||||
])[] = [
|
||||
['계약서', '계약서'],
|
||||
['Project PLAN', 'project plan'],
|
||||
// 전각 ASCII → 반각 (대문자는 소문자로까지)
|
||||
['ABC123!', 'abc123!'],
|
||||
['RE:회의', 're:회의'],
|
||||
['[공지]', '[공지]'],
|
||||
// 전각 공백(U+3000)은 바꾸지 않는다 — 공백은 위치를 지키려고 그대로 둔다
|
||||
['회의\u3000일정', '회의\u3000일정'],
|
||||
// 소문자가 코드 포인트 하나 · 같은 길이일 때만 — 'İ'(U+0130)는 'i̇' 두 글자가 되므로 그대로
|
||||
['İstanbul', 'İstanbul'],
|
||||
['ÀÉÎÕÜ', 'àéîõü'],
|
||||
['ΣΑΣ', 'σασ'],
|
||||
// 풀어 쓴 한글(NFD)은 NFC 로 모은다
|
||||
['\u1112\u1161\u11ab\u1100\u1173\u11af', '한글'],
|
||||
// ㈜ 는 그대로 (NFKC 가 아니다 — 길이를 바꾸지 않는다)
|
||||
['㈜아라', '㈜아라'],
|
||||
// BMP 밖 글자 — Deseret 대문자는 소문자로(둘 다 두 단위), 이모지는 그대로
|
||||
['\u{10400}😀', '\u{10428}😀'],
|
||||
['', ''],
|
||||
];
|
||||
|
||||
/** 글자 두 개 묶음 — [접은 글, 묶음(나온 차례, 겹치지 않게)] */
|
||||
export const GRAM_SAMPLES: readonly (readonly [
|
||||
fold: string,
|
||||
grams: readonly string[],
|
||||
])[] = [
|
||||
['계약서', ['계약', '약서']],
|
||||
['회의 일정', ['회의', '일정']],
|
||||
['계약', ['계약']],
|
||||
['계', []],
|
||||
['a b c', []],
|
||||
['aaaa', ['aa']],
|
||||
['ab\nab\tab', ['ab']],
|
||||
// 전각 공백 · NBSP 도 공백이다
|
||||
['회의\u3000일정\u00a0안내', ['회의', '일정', '안내']],
|
||||
// 문장부호는 글자다 — 're:' 의 'e:' 도 묶음
|
||||
['re:회의', ['re', 'e:', ':회', '회의']],
|
||||
// BMP 밖 글자는 코드 포인트 하나
|
||||
['😀😁', ['😀😁']],
|
||||
['', []],
|
||||
];
|
||||
|
||||
/** 맞은 곳 — [원문(NFC), 찾는 말들, 맞은 곳들 [시작, 끝) UTF-16] — 겹치거나 맞닿으면 합친다 */
|
||||
export const MATCH_SAMPLES: readonly (readonly [
|
||||
original: string,
|
||||
terms: readonly string[],
|
||||
ranges: readonly (readonly [number, number])[],
|
||||
])[] = [
|
||||
['계약서를 보냈습니다', ['계약'], [[0, 2]]],
|
||||
['Project plan', ['PROJ'], [[0, 4]]],
|
||||
['ABC 주문', ['abc'], [[0, 3]]],
|
||||
[
|
||||
'회의 회의록 회의실',
|
||||
['회의'],
|
||||
[
|
||||
[0, 2],
|
||||
[3, 5],
|
||||
[7, 9],
|
||||
],
|
||||
],
|
||||
// 겹치는 자리도 찾아 합친다
|
||||
['aaa', ['aa'], [[0, 3]]],
|
||||
// 맞닿은 두 말은 하나로
|
||||
['계약서', ['계약', '서'], [[0, 3]]],
|
||||
[
|
||||
'회의 일정 안내',
|
||||
['일정', '회의'],
|
||||
[
|
||||
[0, 2],
|
||||
[3, 5],
|
||||
],
|
||||
],
|
||||
// 구절은 띄어쓰기까지 그대로
|
||||
['회의 일정 안내', ['회의 일정'], [[0, 5]]],
|
||||
['회의 일정', ['회의 일정'], []],
|
||||
[
|
||||
'[공지] Re: 회의',
|
||||
['공지', 're'],
|
||||
[
|
||||
[1, 3],
|
||||
[5, 7],
|
||||
],
|
||||
],
|
||||
// 빈 말은 건너뛴다 · 없는 말은 맞은 곳 없음
|
||||
['계약', ['', '없음'], []],
|
||||
// BMP 밖 글자 앞뒤에서도 UTF-16 위치
|
||||
['😀계약', ['계약'], [[2, 4]]],
|
||||
['', ['계약'], []],
|
||||
];
|
||||
|
||||
/** 말머리 떼기 — [제목, 말머리를 뗀 접기 글] */
|
||||
export const SUBJECT_BASE_SAMPLES: readonly (readonly [
|
||||
subject: string,
|
||||
base: string,
|
||||
])[] = [
|
||||
['회의 일정 안내', '회의 일정 안내'],
|
||||
['Re: 회의 일정 안내', '회의 일정 안내'],
|
||||
['RE: Re: FW: 회의 일정 안내', '회의 일정 안내'],
|
||||
['Re:회의', '회의'],
|
||||
['Fwd: 견적서', '견적서'],
|
||||
['AW: WG: Angebot', 'angebot'],
|
||||
// 한국어 Outlook — 엔진은 떼지 않는다
|
||||
['회신: 회의 일정 안내', '회의 일정 안내'],
|
||||
['답장: 회신: 전달: 회의', '회의'],
|
||||
['회신 : 회의', '회의'],
|
||||
// 수 · 전각 쌍점
|
||||
['Re[2]: 회의', '회의'],
|
||||
['RE(3): 회의', '회의'],
|
||||
['Re:회의', '회의'],
|
||||
['RE: 회의', '회의'],
|
||||
// 대괄호 꼬리표 (여러 겹 · 말머리와 섞여도)
|
||||
['[공지] 회의 일정', '회의 일정'],
|
||||
['Re: [공지] [중요] 회의', '회의'],
|
||||
['[외부] RE: 견적', '견적'],
|
||||
// 공백은 하나로 · 대소문자 없이
|
||||
[' Re: 회의 일정 ', '회의 일정'],
|
||||
['Project PLAN', 'project plan'],
|
||||
// 쌍점이 없으면 말머리가 아니다
|
||||
['Re 회의', 're 회의'],
|
||||
['Reply to 회의', 'reply to 회의'],
|
||||
['Regarding: 회의', 'regarding: 회의'],
|
||||
['회신드립니다', '회신드립니다'],
|
||||
// 다 떼면 빈 글
|
||||
['Re:', ''],
|
||||
['[공지]', ''],
|
||||
['', ''],
|
||||
];
|
||||
Reference in New Issue
Block a user