Skip to content

Commit e7d4a87

Browse files
chrisryugjclaude
andcommitted
feat: .eml 메일 파일 검색 지원 (이슈 #29)
- mail-parser 기반 RFC822 파서 추가 (parsers/eml.rs) - 제목·보낸사람·받는사람·날짜 헤더 + 본문(text/plain 우선, HTML fallback) 인덱싱 - EUC-KR/CP949 charset·quoted-printable/base64 자동 해제 (full_encoding) - 발신일을 문서 날짜 메타로 저장 → after:/before: 필터 적용 - 지원 확장자·FileIcon/Badge '메일' 라벨·README 반영 - v3.0.1 bump Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qp5mZnNZJW8f5kMML7Vxgc
1 parent a6c0292 commit e7d4a87

11 files changed

Lines changed: 218 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [3.0.1] - 2026-06-19
4+
5+
**`.eml` 메일 파일 검색 지원 (이슈 #29)**
6+
7+
### ✨ 추가
8+
9+
- **`.eml` 메일 파싱** — Outlook 없이 백업해 둔 메일(`.eml`)을 인덱싱·검색. 제목·보낸사람·받는사람·날짜 헤더를 본문과 함께 검색 대상에 포함하고, 본문은 `text/plain` 파트를 우선 추출하되 없으면 `text/html`을 텍스트로 변환. EUC-KR/CP949 등 한국 메일 charset과 quoted-printable/base64 인코딩은 `mail-parser`가 자동 해제. 발신 날짜는 문서 메타데이터로 저장돼 `after:`/`before:` 날짜 필터에도 잡힌다. 첨부파일 본문은 인덱싱하지 않는다(메일 본문 검색이 목적).
10+
- 지원 확장자 목록·파일 타입 라벨('메일')·README 표에 `.eml` 반영.
11+
312
## [3.0.0] - 2026-06-16
413

514
**메이저 릴리즈 — 검색 연산자·형태소 근접검색 신설 + 성능 대수술 + UX 환골탈태**

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Everything처럼 파일명 일부만 입력하면 인메모리 캐시에서 **
5555
| PDF | `.pdf` | 스캔 PDF는 OCR 자동 적용 |
5656
| 이미지 | `.jpg` `.png` `.bmp` `.tiff` | OCR로 텍스트 추출 |
5757
| 텍스트 | `.txt` `.md` | EUC-KR/CP949 자동 감지 |
58+
| 메일 | `.eml` | 제목·보낸사람·받는사람·본문 검색 (charset 자동 디코딩) |
5859

5960
---
6061

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anything",
3-
"version": "3.0.0",
3+
"version": "3.0.1",
44
"description": "로컬 문서 검색 앱 - Everything 대항마",
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 24 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "docufinder"
3-
version = "3.0.0"
3+
version = "3.0.1"
44
description = "로컬 문서 검색 앱 - HWPX, Office, PDF 지원"
55
authors = ["Chris"]
66
edition = "2021"
@@ -41,6 +41,8 @@ pdf-extract = "0.7" # PDF
4141
lopdf = "0.34" # PDF 임베디드 이미지 추출 (스캔 PDF OCR용, pdf-extract와 버전 일치)
4242
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "bmp", "tiff"] }
4343
flate2 = "1" # PDF 이미지 스트림 디코딩 (FlateDecode)
44+
# .eml 메일 (이슈 #29) — RFC822 MIME 파싱. full_encoding: EUC-KR/CP949 등 한국 메일 charset 디코딩
45+
mail-parser = { version = "0.11", default-features = false, features = ["full_encoding"] }
4446

4547
# Async
4648
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "fs", "process"] }

src-tauri/src/constants.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub fn is_allow_system_folders() -> bool {
1717
/// 지원하는 파일 확장자 목록
1818
/// 참고: "hwp"는 파서 미지원 (파싱 실패 시 변환 대상으로 수집됨, pipeline.rs 참조)
1919
pub const SUPPORTED_EXTENSIONS: &[&str] = &[
20-
"txt", "md", "hwpx", "hwp", "docx", "pptx", "xlsx", "xls", "pdf",
20+
"txt", "md", "hwpx", "hwp", "docx", "pptx", "xlsx", "xls", "pdf", "eml",
2121
];
2222

2323
/// OCR 대상 이미지 확장자 (ocr_enabled 설정 시에만 인덱싱)

src-tauri/src/parsers/eml.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use super::{chunk_text, DocumentMetadata, ParseError, ParsedDocument};
2+
use mail_parser::{Address, MessageParser};
3+
use std::fs;
4+
use std::path::Path;
5+
6+
/// EML 파서 최대 파일 크기 (50MB) - 첨부 포함 메일 대비 + 메모리 보호
7+
const MAX_EML_FILE_SIZE: u64 = 50 * 1024 * 1024;
8+
9+
/// .eml 메일 파일 파싱 (RFC822 MIME) — 이슈 #29.
10+
///
11+
/// 헤더(제목·보낸사람·받는사람·날짜)를 본문 앞에 붙여 검색 대상에 포함하고,
12+
/// 본문은 text/plain 파트를 우선, 없으면 text/html 을 텍스트로 변환해 추출한다.
13+
/// charset(EUC-KR/CP949 등) 디코딩과 quoted-printable/base64 해제는 mail-parser 가 처리.
14+
/// 첨부파일 본문은 파싱하지 않는다(메일 본문 검색이 목적).
15+
pub fn parse(path: &Path) -> Result<ParsedDocument, ParseError> {
16+
let file_size = fs::metadata(path)?.len();
17+
if file_size > MAX_EML_FILE_SIZE {
18+
return Err(ParseError::ParseError(format!(
19+
"File too large: {}MB (max {}MB)",
20+
file_size / 1024 / 1024,
21+
MAX_EML_FILE_SIZE / 1024 / 1024
22+
)));
23+
}
24+
25+
let bytes = fs::read(path)?;
26+
let message = MessageParser::default()
27+
.parse(&bytes)
28+
.ok_or_else(|| ParseError::ParseError("EML 파싱 실패 (RFC822 형식 아님)".to_string()))?;
29+
30+
let subject = message.subject().map(|s| s.to_string());
31+
let from = format_address(message.from());
32+
let to = format_address(message.to());
33+
let date = message.date();
34+
35+
// 본문: text/plain 우선, 없으면 html → text 변환
36+
let body = message
37+
.body_text(0)
38+
.map(|c| c.into_owned())
39+
.or_else(|| {
40+
message
41+
.body_html(0)
42+
.map(|h| mail_parser::decoders::html::html_to_text(&h))
43+
})
44+
.unwrap_or_default();
45+
46+
// 헤더를 본문 앞에 붙여 검색 대상에 포함 (제목·보낸사람·받는사람·날짜)
47+
let mut content = String::new();
48+
if let Some(s) = &subject {
49+
content.push_str("제목: ");
50+
content.push_str(s);
51+
content.push('\n');
52+
}
53+
if let Some(f) = &from {
54+
content.push_str("보낸사람: ");
55+
content.push_str(f);
56+
content.push('\n');
57+
}
58+
if let Some(t) = &to {
59+
content.push_str("받는사람: ");
60+
content.push_str(t);
61+
content.push('\n');
62+
}
63+
if let Some(d) = date {
64+
content.push_str("날짜: ");
65+
content.push_str(&d.to_string());
66+
content.push('\n');
67+
}
68+
if !content.is_empty() {
69+
content.push('\n');
70+
}
71+
content.push_str(&body);
72+
73+
let chunks = chunk_text(
74+
&content,
75+
super::DEFAULT_CHUNK_SIZE,
76+
super::DEFAULT_CHUNK_OVERLAP,
77+
);
78+
79+
Ok(ParsedDocument {
80+
content,
81+
metadata: DocumentMetadata {
82+
title: subject.or_else(|| path.file_stem().and_then(|s| s.to_str()).map(String::from)),
83+
author: from,
84+
created_at: date.map(|d| d.to_timestamp()),
85+
page_count: None,
86+
},
87+
chunks,
88+
})
89+
}
90+
91+
/// 메일 주소 헤더를 "이름 <주소>, ..." 형태 문자열로 변환.
92+
/// 그룹/리스트 모두 평탄화하고, 표시할 게 없으면 None.
93+
fn format_address(addr: Option<&Address>) -> Option<String> {
94+
let addr = addr?;
95+
let parts: Vec<String> = addr
96+
.iter()
97+
.filter_map(|a| match (a.name(), a.address()) {
98+
(Some(n), Some(e)) => Some(format!("{} <{}>", n.trim(), e.trim())),
99+
(Some(n), None) => Some(n.trim().to_string()),
100+
(None, Some(e)) => Some(e.trim().to_string()),
101+
(None, None) => None,
102+
})
103+
.filter(|s| !s.is_empty())
104+
.collect();
105+
if parts.is_empty() {
106+
None
107+
} else {
108+
Some(parts.join(", "))
109+
}
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use std::io::Write;
116+
117+
fn write_eml(content: &[u8]) -> tempfile::NamedTempFile {
118+
let mut f = tempfile::Builder::new().suffix(".eml").tempfile().unwrap();
119+
f.write_all(content).unwrap();
120+
f.flush().unwrap();
121+
f
122+
}
123+
124+
#[test]
125+
fn parses_headers_and_plain_body() {
126+
let raw = b"From: \"Hong Gildong\" <hong@example.com>\r\n\
127+
To: receiver@example.com\r\n\
128+
Subject: Test Mail Subject\r\n\
129+
Date: Mon, 1 Jun 2026 09:00:00 +0900\r\n\
130+
Content-Type: text/plain; charset=utf-8\r\n\
131+
\r\n\
132+
This is the mail body.\r\n";
133+
let f = write_eml(raw);
134+
let doc = parse(f.path()).unwrap();
135+
136+
assert!(doc.content.contains("Test Mail Subject"));
137+
assert!(doc.content.contains("This is the mail body."));
138+
assert!(doc.content.contains("hong@example.com"));
139+
assert_eq!(doc.metadata.title.as_deref(), Some("Test Mail Subject"));
140+
assert!(doc
141+
.metadata
142+
.author
143+
.as_deref()
144+
.unwrap()
145+
.contains("hong@example.com"));
146+
assert!(doc.metadata.created_at.is_some());
147+
assert!(!doc.chunks.is_empty());
148+
}
149+
150+
#[test]
151+
fn falls_back_to_html_body() {
152+
let raw = b"Subject: HTML only\r\n\
153+
Content-Type: text/html; charset=utf-8\r\n\
154+
\r\n\
155+
<html><body><p>Hello <b>world</b></p></body></html>\r\n";
156+
let f = write_eml(raw);
157+
let doc = parse(f.path()).unwrap();
158+
assert!(doc.content.contains("Hello"));
159+
assert!(doc.content.contains("world"));
160+
// 태그는 제거돼야 함
161+
assert!(!doc.content.contains("<p>"));
162+
}
163+
164+
#[test]
165+
fn title_falls_back_to_filename_when_no_subject() {
166+
let raw = b"From: a@b.com\r\n\r\nno subject body\r\n";
167+
let f = write_eml(raw);
168+
let doc = parse(f.path()).unwrap();
169+
// 제목 헤더가 없으면 파일명(stem)이 title 로
170+
assert!(doc.metadata.title.is_some());
171+
assert!(doc.content.contains("no subject body"));
172+
}
173+
}

src-tauri/src/parsers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod docx;
2+
pub mod eml;
23
pub mod hwpx;
34
pub mod image_ocr;
45
pub mod kordoc;
@@ -178,6 +179,7 @@ fn parse_file_inner(path: &Path, ocr: Option<&OcrEngine>) -> Result<ParsedDocume
178179

179180
match extension.as_str() {
180181
"txt" | "md" => txt::parse(path),
182+
"eml" => eml::parse(path),
181183
// HWP5 바이너리: kordoc 전용 (Rust 파서 없음). kordoc 실제 에러를 그대로 반환해
182184
// 사용자가 "kordoc 필요"라는 잘못된 안내 대신 진짜 원인 (구버전 HWP3, 비표준 변종 등)을
183185
// 볼 수 있도록 한다 — 이슈 #22 진단 가시성 개선.

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Anything",
4-
"version": "3.0.0",
4+
"version": "3.0.1",
55
"identifier": "com.anything.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/components/ui/Badge.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export function getFileTypeBadgeVariant(fileName: string): BadgeVariant {
143143
return "pdf";
144144
case "txt":
145145
case "md":
146+
case "eml":
146147
return "txt";
147148
default:
148149
return "default";

0 commit comments

Comments
 (0)