olm2mbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
olm2mbox-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tony Wu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: olm2mbox
3
+ Version: 0.1.0
4
+ Summary: Convert Outlook for Mac .olm archives to standard mbox format, with a disk-space-efficient mbox splitter for oversized files.
5
+ Author: Tony Wu
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tonywut/olm2mbox
8
+ Project-URL: Repository, https://github.com/tonywut/olm2mbox
9
+ Project-URL: Issues, https://github.com/tonywut/olm2mbox/issues
10
+ Keywords: olm,mbox,outlook,email,mail,converter,thunderbird,apple-mail
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Utilities
16
+ Classifier: Environment :: Console
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # olm2mbox
25
+
26
+ olm2mbox 是一个将 Microsoft Outlook for Mac 的 `.olm` 邮件存档转换为标准 `mbox` 格式的命令行工具,可导入 Thunderbird、Apple Mail 等几乎所有支持 mbox 的邮件客户端或归档工具。
27
+
28
+ olm2mbox is a command-line tool that converts Microsoft Outlook for Mac `.olm` mail archives into standard `mbox` format, ready to import into Thunderbird, Apple Mail, or any other mbox-compatible mail client or archival tool.
29
+
30
+ ## 缘起 / Backstory
31
+
32
+ 从 Outlook 迁移到 Apple Mail,官方并没有提供直接的一键迁移工具:唯一的路径是先把 Outlook for Mac 的邮箱导出成 `.olm` 存档,再想办法转换成 `mbox` 格式才能导入 Apple Mail。网上能搜到的现成转换工具要么收费不低,要么面对真实的大邮箱(几万封邮件、几十个多级子文件夹、上百 GB 附件)就处理不了——导入导到一半崩溃、中文乱码、子文件夹被漏掉等问题层出不穷。于是干脆让 AI(Claude Code)帮忙把这个工具从头写了出来,自己先拿一个 115GB、8.7万+封邮件的真实邮箱完整跑通、校验无损,现在开源出来,希望能帮到有同样需求的人。
33
+
34
+ There's no official one-click way to migrate from Outlook to Apple Mail: the only path is exporting your Outlook for Mac mailbox as an `.olm` archive, then somehow converting it to `mbox` so Apple Mail can import it. The existing conversion tools I found online were either expensive or simply couldn't handle a real-world mailbox with tens of thousands of messages, dozens of nested subfolders, and hundreds of gigabytes of attachments — crashing mid-import, mangling CJK text, or silently dropping subfolders. So I had AI (Claude Code) build this tool from scratch instead, ran it end-to-end against my own 115GB, 87,000+ message mailbox, verified it byte-for-byte, and I'm open-sourcing it here in case it helps someone else in the same spot.
35
+
36
+ ## 特性 / Features
37
+
38
+ - **完整保留嵌套子文件夹结构 / Full nested-subfolder support** — 不只处理收件箱/已发送/草稿等标准文件夹,用户在 Outlook 里自建的多级子文件夹也会被扫描并各自导出为独立的 mbox 文件,镜像原有目录结构。
39
+ - **对脏数据健壮 / Resilient to malformed data** — 自动清理 XML 中的非法控制字符和噪声字符,修复包含换行符的邮件头导致的写入失败,处理地址中混入非 ASCII 字符导致标准库 `formataddr` 崩溃等真实遇到过的边界情况。
40
+ - **中文 / CJK 友好** — 正文使用 8bit 传输编码而非 quoted-printable,避免中文等多字节字符被编码放大近 3 倍,转换前后文件体积基本 1:1。
41
+ - **可续跑 / Resumable** — 超大邮箱转换到一半中断后,可用 `--resume` 跳过已完成的文件夹接着跑。
42
+ - **零依赖 / Zero dependencies** — 只用 Python 标准库,无需安装任何第三方包。
43
+ - **配套拆分工具 / Companion mbox splitter** — 转换后的单个 mbox 文件如果有几十 GB,导入 Apple Mail 等客户端容易直接闪退。`olm2mbox split` 可以把大 mbox 按体积拆成多份,采用"从文件尾部往前搬移 + 截断"的方式而非整份复制,拆分过程中几乎不额外占用磁盘空间,即使剩余空间小于原文件体积也能安全拆分。
44
+
45
+ 已在一个约 115GB、8.7万+封邮件(32个多级文件夹)的真实邮箱存档上完整跑通并校验字节级无损。
46
+ Battle-tested end-to-end and byte-verified against a real ~115GB, 87,000+ message archive spanning 32 nested folders.
47
+
48
+ ## 安装 / Installation
49
+
50
+ ```bash
51
+ git clone https://github.com/tonywut/olm2mbox.git
52
+ cd olm2mbox
53
+ pip install .
54
+ ```
55
+
56
+ 需要 Python 3.8+,无第三方依赖。
57
+ Requires Python 3.8+, no third-party dependencies.
58
+
59
+ ## 使用方法 / Usage
60
+
61
+ ### 转换 .olm 为 mbox / Convert .olm to mbox
62
+
63
+ ```bash
64
+ olm2mbox convert path/to/archive.olm path/to/output_dir
65
+ ```
66
+
67
+ 每个 OLM 文件夹(含嵌套子文件夹)会在 `output_dir` 下生成对应的 `.mbox` 文件,目录结构与原邮箱一致。
68
+ Each OLM folder (including nested subfolders) becomes a matching `.mbox` file under `output_dir`, mirroring the original folder tree.
69
+
70
+ 常用参数 / Common options:
71
+
72
+ ```bash
73
+ --folder NAME # 只转换指定的顶层文件夹(原始显示名,如 Inbox)
74
+ # only convert this top-level folder (its original display name, e.g. Inbox)
75
+ --limit N # 每个文件夹最多转换 N 封(用于测试)
76
+ # convert at most N messages per folder (for testing)
77
+ --resume # 跳过已经生成过 .mbox 的文件夹,用于续跑中断的转换
78
+ # skip folders whose .mbox already exists, to resume an interrupted run
79
+ --stats-only # 只扫描统计大小,不实际写出文件
80
+ # scan and report sizes without writing any output
81
+ ```
82
+
83
+ ### 拆分超大 mbox 文件 / Split an oversized mbox file
84
+
85
+ ```bash
86
+ olm2mbox split path/to/big.mbox --max-bytes 2000000000
87
+ ```
88
+
89
+ 会在原文件旁生成 `big_part01.mbox`、`big_part02.mbox` ... 等按时间顺序排列的小文件,原文件在确认每一份都安全写入磁盘后被逐步截断,最终整个替换掉,全程几乎不产生额外磁盘占用。
90
+ This produces `big_part01.mbox`, `big_part02.mbox`, ... in chronological order alongside the source file. The source is progressively truncated as each part is safely flushed to disk, and is fully replaced by the parts at the end — with almost no extra disk usage along the way.
91
+
92
+ 常用参数 / Common options:
93
+
94
+ ```bash
95
+ --max-bytes N # 每份的最大字节数,默认 2,000,000,000(约2GB)
96
+ # maximum size per part in bytes, default 2,000,000,000 (~2GB)
97
+ --dry-run # 只生成拆分文件,不改动(截断/删除)原文件,用于先行校验
98
+ # write part files without touching (truncating/deleting) the source
99
+ --out-dir DIR # 拆分文件的输出目录,默认与原文件同目录
100
+ # directory to write part files into, default alongside the source
101
+ ```
102
+
103
+ ## 已知限制 / Known limitations
104
+
105
+ - OLM 数据里正文的"纯文本版本"和"HTML 版本"在实践中几乎总是相同内容;当邮件是 HTML 格式时,纯文本备选版本是对 HTML 做简单去标签处理生成的近似结果,原始 HTML 始终完整保留在 HTML 分支里。
106
+ OLM rarely exposes a genuinely distinct plain-text body; for HTML messages, the plain-text alternative is a best-effort tag-stripped approximation, while the original HTML is always preserved verbatim.
107
+ - 邮件时间在 OLM 中不带时区信息,统一按 UTC 处理。
108
+ OLM timestamps carry no timezone info and are treated as UTC.
109
+
110
+ ## 支持 / Support
111
+
112
+ 如果这个工具帮你省了不少事,欢迎请我喝一杯咖啡 ☕️
113
+
114
+ If this tool saved you some time, feel free to buy me a coffee ☕️
115
+
116
+ <p>
117
+ <img src="assets/alipay-qr.jpg" alt="支付宝 Alipay" width="220">
118
+ <img src="assets/wechat-qr.jpg" alt="微信支付 WeChat Pay" width="220">
119
+ </p>
120
+
121
+ ## 许可证 / License
122
+
123
+ [MIT](LICENSE)
@@ -0,0 +1,100 @@
1
+ # olm2mbox
2
+
3
+ olm2mbox 是一个将 Microsoft Outlook for Mac 的 `.olm` 邮件存档转换为标准 `mbox` 格式的命令行工具,可导入 Thunderbird、Apple Mail 等几乎所有支持 mbox 的邮件客户端或归档工具。
4
+
5
+ olm2mbox is a command-line tool that converts Microsoft Outlook for Mac `.olm` mail archives into standard `mbox` format, ready to import into Thunderbird, Apple Mail, or any other mbox-compatible mail client or archival tool.
6
+
7
+ ## 缘起 / Backstory
8
+
9
+ 从 Outlook 迁移到 Apple Mail,官方并没有提供直接的一键迁移工具:唯一的路径是先把 Outlook for Mac 的邮箱导出成 `.olm` 存档,再想办法转换成 `mbox` 格式才能导入 Apple Mail。网上能搜到的现成转换工具要么收费不低,要么面对真实的大邮箱(几万封邮件、几十个多级子文件夹、上百 GB 附件)就处理不了——导入导到一半崩溃、中文乱码、子文件夹被漏掉等问题层出不穷。于是干脆让 AI(Claude Code)帮忙把这个工具从头写了出来,自己先拿一个 115GB、8.7万+封邮件的真实邮箱完整跑通、校验无损,现在开源出来,希望能帮到有同样需求的人。
10
+
11
+ There's no official one-click way to migrate from Outlook to Apple Mail: the only path is exporting your Outlook for Mac mailbox as an `.olm` archive, then somehow converting it to `mbox` so Apple Mail can import it. The existing conversion tools I found online were either expensive or simply couldn't handle a real-world mailbox with tens of thousands of messages, dozens of nested subfolders, and hundreds of gigabytes of attachments — crashing mid-import, mangling CJK text, or silently dropping subfolders. So I had AI (Claude Code) build this tool from scratch instead, ran it end-to-end against my own 115GB, 87,000+ message mailbox, verified it byte-for-byte, and I'm open-sourcing it here in case it helps someone else in the same spot.
12
+
13
+ ## 特性 / Features
14
+
15
+ - **完整保留嵌套子文件夹结构 / Full nested-subfolder support** — 不只处理收件箱/已发送/草稿等标准文件夹,用户在 Outlook 里自建的多级子文件夹也会被扫描并各自导出为独立的 mbox 文件,镜像原有目录结构。
16
+ - **对脏数据健壮 / Resilient to malformed data** — 自动清理 XML 中的非法控制字符和噪声字符,修复包含换行符的邮件头导致的写入失败,处理地址中混入非 ASCII 字符导致标准库 `formataddr` 崩溃等真实遇到过的边界情况。
17
+ - **中文 / CJK 友好** — 正文使用 8bit 传输编码而非 quoted-printable,避免中文等多字节字符被编码放大近 3 倍,转换前后文件体积基本 1:1。
18
+ - **可续跑 / Resumable** — 超大邮箱转换到一半中断后,可用 `--resume` 跳过已完成的文件夹接着跑。
19
+ - **零依赖 / Zero dependencies** — 只用 Python 标准库,无需安装任何第三方包。
20
+ - **配套拆分工具 / Companion mbox splitter** — 转换后的单个 mbox 文件如果有几十 GB,导入 Apple Mail 等客户端容易直接闪退。`olm2mbox split` 可以把大 mbox 按体积拆成多份,采用"从文件尾部往前搬移 + 截断"的方式而非整份复制,拆分过程中几乎不额外占用磁盘空间,即使剩余空间小于原文件体积也能安全拆分。
21
+
22
+ 已在一个约 115GB、8.7万+封邮件(32个多级文件夹)的真实邮箱存档上完整跑通并校验字节级无损。
23
+ Battle-tested end-to-end and byte-verified against a real ~115GB, 87,000+ message archive spanning 32 nested folders.
24
+
25
+ ## 安装 / Installation
26
+
27
+ ```bash
28
+ git clone https://github.com/tonywut/olm2mbox.git
29
+ cd olm2mbox
30
+ pip install .
31
+ ```
32
+
33
+ 需要 Python 3.8+,无第三方依赖。
34
+ Requires Python 3.8+, no third-party dependencies.
35
+
36
+ ## 使用方法 / Usage
37
+
38
+ ### 转换 .olm 为 mbox / Convert .olm to mbox
39
+
40
+ ```bash
41
+ olm2mbox convert path/to/archive.olm path/to/output_dir
42
+ ```
43
+
44
+ 每个 OLM 文件夹(含嵌套子文件夹)会在 `output_dir` 下生成对应的 `.mbox` 文件,目录结构与原邮箱一致。
45
+ Each OLM folder (including nested subfolders) becomes a matching `.mbox` file under `output_dir`, mirroring the original folder tree.
46
+
47
+ 常用参数 / Common options:
48
+
49
+ ```bash
50
+ --folder NAME # 只转换指定的顶层文件夹(原始显示名,如 Inbox)
51
+ # only convert this top-level folder (its original display name, e.g. Inbox)
52
+ --limit N # 每个文件夹最多转换 N 封(用于测试)
53
+ # convert at most N messages per folder (for testing)
54
+ --resume # 跳过已经生成过 .mbox 的文件夹,用于续跑中断的转换
55
+ # skip folders whose .mbox already exists, to resume an interrupted run
56
+ --stats-only # 只扫描统计大小,不实际写出文件
57
+ # scan and report sizes without writing any output
58
+ ```
59
+
60
+ ### 拆分超大 mbox 文件 / Split an oversized mbox file
61
+
62
+ ```bash
63
+ olm2mbox split path/to/big.mbox --max-bytes 2000000000
64
+ ```
65
+
66
+ 会在原文件旁生成 `big_part01.mbox`、`big_part02.mbox` ... 等按时间顺序排列的小文件,原文件在确认每一份都安全写入磁盘后被逐步截断,最终整个替换掉,全程几乎不产生额外磁盘占用。
67
+ This produces `big_part01.mbox`, `big_part02.mbox`, ... in chronological order alongside the source file. The source is progressively truncated as each part is safely flushed to disk, and is fully replaced by the parts at the end — with almost no extra disk usage along the way.
68
+
69
+ 常用参数 / Common options:
70
+
71
+ ```bash
72
+ --max-bytes N # 每份的最大字节数,默认 2,000,000,000(约2GB)
73
+ # maximum size per part in bytes, default 2,000,000,000 (~2GB)
74
+ --dry-run # 只生成拆分文件,不改动(截断/删除)原文件,用于先行校验
75
+ # write part files without touching (truncating/deleting) the source
76
+ --out-dir DIR # 拆分文件的输出目录,默认与原文件同目录
77
+ # directory to write part files into, default alongside the source
78
+ ```
79
+
80
+ ## 已知限制 / Known limitations
81
+
82
+ - OLM 数据里正文的"纯文本版本"和"HTML 版本"在实践中几乎总是相同内容;当邮件是 HTML 格式时,纯文本备选版本是对 HTML 做简单去标签处理生成的近似结果,原始 HTML 始终完整保留在 HTML 分支里。
83
+ OLM rarely exposes a genuinely distinct plain-text body; for HTML messages, the plain-text alternative is a best-effort tag-stripped approximation, while the original HTML is always preserved verbatim.
84
+ - 邮件时间在 OLM 中不带时区信息,统一按 UTC 处理。
85
+ OLM timestamps carry no timezone info and are treated as UTC.
86
+
87
+ ## 支持 / Support
88
+
89
+ 如果这个工具帮你省了不少事,欢迎请我喝一杯咖啡 ☕️
90
+
91
+ If this tool saved you some time, feel free to buy me a coffee ☕️
92
+
93
+ <p>
94
+ <img src="assets/alipay-qr.jpg" alt="支付宝 Alipay" width="220">
95
+ <img src="assets/wechat-qr.jpg" alt="微信支付 WeChat Pay" width="220">
96
+ </p>
97
+
98
+ ## 许可证 / License
99
+
100
+ [MIT](LICENSE)
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "olm2mbox"
7
+ version = "0.1.0"
8
+ description = "Convert Outlook for Mac .olm archives to standard mbox format, with a disk-space-efficient mbox splitter for oversized files."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tony Wu" }]
13
+ keywords = ["olm", "mbox", "outlook", "email", "mail", "converter", "thunderbird", "apple-mail"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Communications :: Email",
19
+ "Topic :: Utilities",
20
+ "Environment :: Console",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/tonywut/olm2mbox"
28
+ Repository = "https://github.com/tonywut/olm2mbox"
29
+ Issues = "https://github.com/tonywut/olm2mbox/issues"
30
+
31
+ [project.scripts]
32
+ olm2mbox = "olm2mbox.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """olm2mbox: convert Outlook for Mac .olm archives to mbox, and split oversized mbox files."""
2
+
3
+ from .convert import convert
4
+ from .split import split
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["convert", "split", "__version__"]
@@ -0,0 +1,83 @@
1
+ """Command-line interface for olm2mbox."""
2
+ import argparse
3
+ import sys
4
+
5
+ from . import __version__
6
+ from .convert import convert
7
+ from .split import split
8
+
9
+
10
+ def build_parser():
11
+ parser = argparse.ArgumentParser(
12
+ prog='olm2mbox',
13
+ description='Convert Outlook for Mac .olm archives to mbox, and split oversized mbox files.',
14
+ )
15
+ parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
16
+ sub = parser.add_subparsers(dest='command', required=True)
17
+
18
+ p_convert = sub.add_parser(
19
+ 'convert',
20
+ help='Convert an .olm archive to one mbox file per folder',
21
+ description='Convert an .olm archive to one mbox file per folder, mirroring the original folder tree.',
22
+ )
23
+ p_convert.add_argument('input', help='Path to the source .olm file')
24
+ p_convert.add_argument('output_dir', help='Directory to write the .mbox files into')
25
+ p_convert.add_argument(
26
+ '--folder', default=None,
27
+ help="Only convert this top-level OLM folder (matches the folder's original display name, e.g. Inbox)",
28
+ )
29
+ p_convert.add_argument(
30
+ '--limit', type=int, default=None,
31
+ help='Convert at most N messages per folder (useful for testing)',
32
+ )
33
+ p_convert.add_argument(
34
+ '--stats-only', action='store_true',
35
+ help='Scan and report sizes without writing any output',
36
+ )
37
+ p_convert.add_argument(
38
+ '--resume', action='store_true',
39
+ help='Skip folders whose output .mbox file already exists, to continue an interrupted run',
40
+ )
41
+
42
+ p_split = sub.add_parser(
43
+ 'split',
44
+ help='Split an oversized mbox file into size-capped parts',
45
+ description='Split an oversized mbox file into size-capped parts, without needing extra disk space for a full copy.',
46
+ )
47
+ p_split.add_argument('source', help='Path to the mbox file to split')
48
+ p_split.add_argument(
49
+ '--max-bytes', type=int, default=2_000_000_000,
50
+ help='Maximum size per part, in bytes (default: 2,000,000,000, about 2 GB)',
51
+ )
52
+ p_split.add_argument(
53
+ '--dry-run', action='store_true',
54
+ help='Write part files without touching (truncating or deleting) the source',
55
+ )
56
+ p_split.add_argument(
57
+ '--out-dir', default=None,
58
+ help='Directory to write part files into (default: alongside the source file)',
59
+ )
60
+
61
+ return parser
62
+
63
+
64
+ def main(argv=None):
65
+ parser = build_parser()
66
+ args = parser.parse_args(argv)
67
+
68
+ if args.command == 'convert':
69
+ convert(
70
+ args.input, args.output_dir,
71
+ only_folder=args.folder, limit=args.limit,
72
+ stats_only=args.stats_only, resume=args.resume,
73
+ )
74
+ elif args.command == 'split':
75
+ split(
76
+ args.source, args.max_bytes,
77
+ dry_run=args.dry_run, out_dir=args.out_dir,
78
+ )
79
+ return 0
80
+
81
+
82
+ if __name__ == '__main__':
83
+ sys.exit(main())
@@ -0,0 +1,348 @@
1
+ """Convert a Microsoft Outlook for Mac .olm archive to standard mbox file(s).
2
+
3
+ An .olm file is a zip archive containing one XML file per message
4
+ (message_NNNNN.xml) plus a sibling directory of raw attachment files, laid
5
+ out per mail folder. This module walks every folder in the archive -
6
+ including user-created subfolders nested arbitrarily deep under the standard
7
+ Inbox/Sent/Drafts/etc. folders - and writes one standard mbox file per
8
+ folder, mirroring the original folder tree under the given output directory.
9
+
10
+ Design notes
11
+ ------------
12
+ * Message bodies are attached with Content-Transfer-Encoding "8bit" rather
13
+ than the email package's default "quoted-printable". For CJK-heavy text,
14
+ quoted-printable roughly triples the encoded size (each multi-byte UTF-8
15
+ character becomes a string of "=XX" escapes); 8bit keeps output size close
16
+ to 1:1 with input size.
17
+ * OLM data is occasionally malformed in ways that trip up the standard
18
+ library: raw control characters or noncharacters (U+FFFE/U+FFFF) that
19
+ break XML well-formedness, header values containing embedded newlines
20
+ (rejected by EmailMessage's header assignment), and non-ASCII characters
21
+ in the address part of a From/To/Cc field (which makes
22
+ email.utils.formataddr raise). All three are sanitized/worked around here.
23
+ * FOLDER_MAP renames a handful of well-known localized default folder names
24
+ (as produced by a Chinese-localized Outlook) to their canonical English
25
+ equivalents purely for nicer output filenames. Any folder name not in the
26
+ map - including default folders in other languages - passes through
27
+ unchanged.
28
+ """
29
+ import sys
30
+ import os
31
+ import re
32
+ import time
33
+ import html as htmllib
34
+ import zipfile
35
+ import xml.etree.ElementTree as ET
36
+ from email.message import EmailMessage
37
+ from email import policy
38
+ from email.utils import format_datetime
39
+ import datetime
40
+
41
+ TAG_RE = re.compile(r'<[^>]+>')
42
+ HEADER_UNSAFE_RE = re.compile(r'[\r\n]+')
43
+ # XML 1.0 disallows most C0 control chars and the U+FFFE/U+FFFF
44
+ # noncharacters; strip anything not in the allowed set before parsing, since
45
+ # some OLM message bodies embed raw control bytes or the odd noncharacter
46
+ # that break well-formedness.
47
+ XML_INVALID_RE = re.compile(
48
+ '[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff￾￿]'
49
+ )
50
+ ADDR_SPECIALS_RE = re.compile(r'[",()<>@:;\\\[\]]')
51
+
52
+ # Cosmetic only: rename a few well-known localized default folder names to
53
+ # their canonical English equivalents. Anything not listed here passes
54
+ # through unchanged (see module docstring).
55
+ FOLDER_MAP = {
56
+ '收件箱': 'Inbox',
57
+ '发件箱': 'Outbox',
58
+ '已发送邮件': 'Sent',
59
+ '草稿': 'Drafts',
60
+ '已删除邮件': 'Deleted',
61
+ '垃圾邮件': 'Junk',
62
+ }
63
+
64
+ # Fixed internal path prefix used by Microsoft's OLM exporter, independent of
65
+ # Outlook's UI language.
66
+ PREFIX = 'Local/com.microsoft.__Messages/'
67
+
68
+
69
+ def clean_header(s):
70
+ """Collapse embedded CR/LF in a header value to a single space.
71
+
72
+ EmailMessage's header assignment raises on values containing newlines;
73
+ OLM data sometimes has them (e.g. a Subject copy-pasted from elsewhere).
74
+ """
75
+ if not s:
76
+ return s
77
+ return HEADER_UNSAFE_RE.sub(' ', s).strip()
78
+
79
+
80
+ def sanitize_xml(raw_bytes):
81
+ """Strip characters that are not legal in XML 1.0 content."""
82
+ text = raw_bytes.decode('utf-8', errors='replace')
83
+ return XML_INVALID_RE.sub('', text).encode('utf-8')
84
+
85
+
86
+ def strip_html_to_text(htmltext):
87
+ """Very small HTML->text fallback, used when no distinct plain-text body exists."""
88
+ text = TAG_RE.sub(' ', htmltext)
89
+ text = htmllib.unescape(text)
90
+ text = re.sub(r'[ \t]+', ' ', text)
91
+ text = re.sub(r'\n\s*\n+', '\n\n', text)
92
+ return text.strip()
93
+
94
+
95
+ def parse_olm_datetime(s):
96
+ if not s:
97
+ return None
98
+ try:
99
+ # OLM stores e.g. "2026-05-27T03:12:52" (naive, assumed UTC).
100
+ dt = datetime.datetime.strptime(s.strip(), '%Y-%m-%dT%H:%M:%S')
101
+ return dt.replace(tzinfo=datetime.timezone.utc)
102
+ except ValueError:
103
+ return None
104
+
105
+
106
+ def addr_list(el):
107
+ out = []
108
+ if el is None:
109
+ return out
110
+ for a in el.findall('emailAddress'):
111
+ addr = clean_header(a.get('OPFContactEmailAddressAddress') or '')
112
+ name = clean_header(a.get('OPFContactEmailAddressName') or '')
113
+ if not addr:
114
+ continue
115
+ if name and name != addr:
116
+ out.append((name, addr))
117
+ else:
118
+ out.append((None, addr))
119
+ return out
120
+
121
+
122
+ def format_one_addr(name, addr):
123
+ # Avoid email.utils.formataddr: it hard-fails (UnicodeEncodeError) on a
124
+ # non-ASCII addr-spec, which OLM data sometimes contains. Build manually
125
+ # instead and let the email policy's header registry RFC 2047-encode
126
+ # whatever needs it at serialization time.
127
+ if name:
128
+ if ADDR_SPECIALS_RE.search(name):
129
+ name = '"' + name.replace('\\', '\\\\').replace('"', '\\"') + '"'
130
+ return f'{name} <{addr}>'
131
+ return addr
132
+
133
+
134
+ def format_addrs(pairs):
135
+ return ', '.join(format_one_addr(name, addr) for name, addr in pairs)
136
+
137
+
138
+ def text_of(root, tag):
139
+ el = root.find(tag)
140
+ if el is None or el.text is None:
141
+ return ''
142
+ return el.text
143
+
144
+
145
+ def truthy_flag(root, tag):
146
+ el = root.find(tag)
147
+ if el is None or el.text is None:
148
+ return False
149
+ try:
150
+ return float(el.text) != 0
151
+ except ValueError:
152
+ return el.text.strip() not in ('', '0')
153
+
154
+
155
+ def build_message(zf, email_el, folder_path):
156
+ """Build an EmailMessage from one OLM <email> element."""
157
+ subject = clean_header(text_of(email_el, 'OPFMessageCopySubject'))
158
+ message_id = clean_header(text_of(email_el, 'OPFMessageCopyMessageID').strip())
159
+ sent_time = parse_olm_datetime(text_of(email_el, 'OPFMessageCopySentTime'))
160
+ received_time = parse_olm_datetime(text_of(email_el, 'OPFMessageCopyReceivedTime'))
161
+ dt = sent_time or received_time or datetime.datetime.now(datetime.timezone.utc)
162
+
163
+ from_addrs = addr_list(email_el.find('OPFMessageCopyFromAddresses'))
164
+ to_addrs = addr_list(email_el.find('OPFMessageCopyToAddresses'))
165
+ cc_addrs = addr_list(email_el.find('OPFMessageCopyCCAddresses'))
166
+ reply_to_addrs = addr_list(email_el.find('OPFMessageCopyReplyToAddresses'))
167
+
168
+ has_html = truthy_flag(email_el, 'OPFMessageGetHasHTML')
169
+ body_raw = text_of(email_el, 'OPFMessageCopyBody')
170
+
171
+ msg = EmailMessage(policy=policy.default)
172
+ if subject:
173
+ msg['Subject'] = subject
174
+ if from_addrs:
175
+ msg['From'] = format_addrs(from_addrs)
176
+ if to_addrs:
177
+ msg['To'] = format_addrs(to_addrs)
178
+ if cc_addrs:
179
+ msg['Cc'] = format_addrs(cc_addrs)
180
+ if reply_to_addrs:
181
+ msg['Reply-To'] = format_addrs(reply_to_addrs)
182
+ msg['Date'] = format_datetime(dt)
183
+ if message_id:
184
+ mid = message_id if message_id.startswith('<') else f'<{message_id}>'
185
+ msg['Message-ID'] = mid
186
+ in_reply_to = clean_header(text_of(email_el, 'OPFMessageCopyInReplyTo').strip())
187
+ if in_reply_to:
188
+ msg['In-Reply-To'] = in_reply_to if in_reply_to.startswith('<') else f'<{in_reply_to}>'
189
+ references = clean_header(text_of(email_el, 'OPFMessageCopyReferences').strip())
190
+ if references:
191
+ msg['References'] = references
192
+ msg['X-OLM-Source-Folder'] = folder_path
193
+
194
+ if has_html and body_raw:
195
+ # OLM does not reliably expose a distinct plain-text body, so the
196
+ # "plain" alternative is a best-effort tag-strip of the HTML rather
197
+ # than a source-accurate plain-text version. The original HTML is
198
+ # always preserved verbatim in the html alternative.
199
+ plain_fallback = strip_html_to_text(body_raw) or ' '
200
+ msg.set_content(plain_fallback, cte='8bit')
201
+ msg.add_alternative(body_raw, subtype='html', cte='8bit')
202
+ else:
203
+ msg.set_content(body_raw or ' ', cte='8bit')
204
+
205
+ attach_list_el = email_el.find('OPFMessageCopyAttachmentList')
206
+ if attach_list_el is not None:
207
+ for att in attach_list_el.findall('messageAttachment'):
208
+ url = att.get('OPFAttachmentURL')
209
+ if not url:
210
+ continue
211
+ ctype = att.get('OPFAttachmentContentType') or 'application/octet-stream'
212
+ if '/' in ctype:
213
+ maintype, subtype = ctype.split('/', 1)
214
+ else:
215
+ maintype, subtype = 'application', 'octet-stream'
216
+ name = clean_header(att.get('OPFAttachmentName') or os.path.basename(url))
217
+ cid = clean_header(att.get('OPFAttachmentContentID') or '')
218
+ try:
219
+ data = zf.read(url)
220
+ except KeyError:
221
+ continue
222
+ kwargs = dict(maintype=maintype, subtype=subtype, filename=name)
223
+ if cid:
224
+ kwargs['cid'] = f'<{cid}>' if not cid.startswith('<') else cid
225
+ try:
226
+ msg.add_attachment(data, **kwargs)
227
+ except Exception:
228
+ msg.add_attachment(data, maintype='application', subtype='octet-stream', filename=name)
229
+
230
+ return msg, dt
231
+
232
+
233
+ def mbox_from_line(from_addr, dt):
234
+ ts = dt.astimezone(datetime.timezone.utc).strftime('%a %b %d %H:%M:%S %Y')
235
+ addr = from_addr if from_addr else 'MAILER-DAEMON'
236
+ return f'From {addr} {ts}\n'
237
+
238
+
239
+ def escape_mbox_body(raw_bytes):
240
+ # mboxrd style: escape any line starting with "From " (or ">" + "From ") by prefixing '>'.
241
+ return re.sub(rb'(?m)^(>*From )', rb'>\1', raw_bytes)
242
+
243
+
244
+ def iter_folder_messages(zf, folder_path):
245
+ names = sorted(
246
+ n for n in zf.namelist()
247
+ if n.startswith(folder_path + '/message_') and n.endswith('.xml')
248
+ )
249
+ for n in names:
250
+ yield n
251
+
252
+
253
+ def discover_message_dirs(zf):
254
+ """Find every directory (at any depth) that directly contains message_*.xml files.
255
+
256
+ OLM mailboxes commonly have user-created subfolders nested under the
257
+ standard top-level folders (e.g. Inbox/ProjectX/message_0000.xml), and
258
+ those would otherwise be silently skipped if only the standard folders
259
+ were scanned.
260
+ """
261
+ dirs = set()
262
+ for n in zf.namelist():
263
+ if n.endswith('.xml') and '/message_' in n:
264
+ d = n.rsplit('/message_', 1)[0]
265
+ if d.startswith(PREFIX):
266
+ dirs.add(d)
267
+ return dirs
268
+
269
+
270
+ def out_path_for_dir(output_dir, folder_path):
271
+ rel = folder_path[len(PREFIX):]
272
+ parts = rel.split('/')
273
+ parts[0] = FOLDER_MAP.get(parts[0], parts[0])
274
+ out_dir = os.path.join(output_dir, *parts[:-1]) if len(parts) > 1 else output_dir
275
+ return os.path.join(out_dir, f'{parts[-1]}.mbox'), '/'.join(parts)
276
+
277
+
278
+ def convert(input_path, output_dir, only_folder=None, limit=None, stats_only=False, resume=False):
279
+ """Convert every folder in the .olm archive at input_path into a matching
280
+ mbox file tree under output_dir.
281
+
282
+ :param only_folder: if set, only convert the top-level OLM folder with
283
+ this exact display name (e.g. "Inbox" or a localized name).
284
+ :param limit: convert at most this many messages per folder (for testing).
285
+ :param stats_only: scan and report sizes without writing any output.
286
+ :param resume: skip folders whose output .mbox file already exists,
287
+ letting an interrupted run be continued without redoing completed
288
+ folders.
289
+ :returns: dict of {folder_name: {count, errors, bytes, seconds}}.
290
+ """
291
+ os.makedirs(output_dir, exist_ok=True)
292
+ zf = zipfile.ZipFile(input_path)
293
+ folders = discover_message_dirs(zf)
294
+
295
+ total_stats = {}
296
+ for folder_path in sorted(folders):
297
+ top_folder_name = folder_path[len(PREFIX):].split('/', 1)[0]
298
+ if only_folder and top_folder_name != only_folder:
299
+ continue
300
+ out_path, out_name = out_path_for_dir(output_dir, folder_path)
301
+ if resume and os.path.exists(out_path):
302
+ print(f'-- skipping {out_name} (already done, {os.path.getsize(out_path)/1e9:.2f} GB)', flush=True)
303
+ continue
304
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
305
+ count = 0
306
+ errors = 0
307
+ t0 = time.time()
308
+ out_f = None if stats_only else open(out_path, 'wb')
309
+ bytes_written = 0
310
+ try:
311
+ for n in iter_folder_messages(zf, folder_path):
312
+ if limit and count >= limit:
313
+ break
314
+ try:
315
+ raw_xml = sanitize_xml(zf.read(n))
316
+ root = ET.fromstring(raw_xml)
317
+ email_el = root.find('email')
318
+ if email_el is None:
319
+ errors += 1
320
+ continue
321
+ msg, dt = build_message(zf, email_el, folder_path)
322
+ from_hdr = msg.get('From', '')
323
+ m = re.search(r'[\w.+-]+@[\w.-]+', from_hdr)
324
+ from_addr = m.group(0) if m else ''
325
+ payload_bytes = msg.as_bytes()
326
+ if out_f is not None:
327
+ fl = mbox_from_line(from_addr, dt).encode('utf-8')
328
+ body = escape_mbox_body(payload_bytes)
329
+ if not body.endswith(b'\n'):
330
+ body += b'\n'
331
+ out_f.write(fl)
332
+ out_f.write(body)
333
+ out_f.write(b'\n')
334
+ bytes_written += len(fl) + len(body) + 1
335
+ except Exception as e:
336
+ errors += 1
337
+ print(f' ! error on {n}: {e}', file=sys.stderr)
338
+ count += 1
339
+ if count % 2000 == 0:
340
+ elapsed = time.time() - t0
341
+ print(f' [{out_name}] {count} messages, {bytes_written/1e9:.2f} GB, {elapsed:.0f}s elapsed', flush=True)
342
+ finally:
343
+ if out_f is not None:
344
+ out_f.close()
345
+ elapsed = time.time() - t0
346
+ total_stats[out_name] = dict(count=count, errors=errors, bytes=bytes_written, seconds=elapsed)
347
+ print(f'== {out_name}: {count} messages, {errors} errors, {bytes_written/1e9:.2f} GB, {elapsed:.1f}s', flush=True)
348
+ return total_stats
@@ -0,0 +1,138 @@
1
+ """Split an oversized mbox file into size-capped parts without needing 2x disk space.
2
+
3
+ A single multi-gigabyte mbox file is a common cause of mail clients (Apple
4
+ Mail in particular) crashing on import. The straightforward way to split one
5
+ - read the whole file and write out N smaller files - needs as much free
6
+ disk space as the original file's size, on top of the original itself. That
7
+ is often not available for a mailbox archive large enough to need splitting
8
+ in the first place.
9
+
10
+ This module avoids that by carving chunks off the *end* of the source file
11
+ first: write the last chunk's bytes to a new part file, fsync it, then
12
+ os.truncate() the source down to the start of that chunk. Because truncate
13
+ only needs to shrink a file (no rewrite of the remaining data), peak extra
14
+ disk usage is about one chunk's size, not the whole file. Repeating this
15
+ from the last chunk back to the first "moves" the data out of the source
16
+ into the part files, leaving an empty source file that is removed at the
17
+ end.
18
+
19
+ Message boundaries are found by scanning for lines that start a new mbox
20
+ "From " envelope, so no message is ever split across two part files.
21
+ """
22
+ import os
23
+ import re
24
+ import argparse
25
+
26
+ FROM_LINE_RE = re.compile(rb'From [^\r\n]*\d{4}\r?\n')
27
+ SCAN_BLOCK = 64 * 1024 * 1024
28
+ OVERLAP = 4096
29
+
30
+
31
+ def find_message_offsets(path):
32
+ """Return the sorted list of byte offsets where a message ("From ") begins."""
33
+ offsets = [0]
34
+ size = os.path.getsize(path)
35
+ with open(path, 'rb') as f:
36
+ pos = 0
37
+ carry = b''
38
+ while pos < size:
39
+ f.seek(pos)
40
+ block = f.read(SCAN_BLOCK)
41
+ if not block:
42
+ break
43
+ search_area = carry + block
44
+ base = pos - len(carry)
45
+ # Find every "\nFrom " boundary in this window.
46
+ idx = 0
47
+ while True:
48
+ i = search_area.find(b'\nFrom ', idx)
49
+ if i == -1:
50
+ break
51
+ start = base + i + 1
52
+ # Confirm it looks like a real envelope line (From <addr> ... <year>).
53
+ line_end = search_area.find(b'\n', i + 1)
54
+ if line_end == -1:
55
+ line = search_area[i + 1:i + 1 + 200]
56
+ else:
57
+ line = search_area[i + 1:line_end + 1]
58
+ if FROM_LINE_RE.match(line):
59
+ offsets.append(start)
60
+ idx = i + 1
61
+ carry = search_area[-OVERLAP:] if len(search_area) >= OVERLAP else search_area
62
+ pos += len(block)
63
+ offsets = sorted(set(offsets))
64
+ return offsets, size
65
+
66
+
67
+ def build_chunks(offsets, size, max_bytes):
68
+ chunks = [] # list of (start, end)
69
+ chunk_start = offsets[0]
70
+ prev = offsets[0]
71
+ for off in offsets[1:] + [size]:
72
+ if off - chunk_start > max_bytes and prev != chunk_start:
73
+ chunks.append((chunk_start, prev))
74
+ chunk_start = prev
75
+ prev = off
76
+ chunks.append((chunk_start, size))
77
+ return chunks
78
+
79
+
80
+ def part_name(base_path, idx, total):
81
+ root, ext = os.path.splitext(base_path)
82
+ width = max(2, len(str(total)))
83
+ return f'{root}_part{str(idx).zfill(width)}{ext}'
84
+
85
+
86
+ def split(source, max_bytes, dry_run=False, out_dir=None):
87
+ """Split source into <= max_bytes parts, numbered oldest-first.
88
+
89
+ :param dry_run: write part files but do not touch (truncate/delete) the
90
+ source, for verification before committing to the destructive path.
91
+ :param out_dir: directory to write part files into; defaults to
92
+ alongside the source file.
93
+ :returns: (part_paths, offsets, size)
94
+ """
95
+ print(f'scanning {source} for message boundaries...', flush=True)
96
+ offsets, size = find_message_offsets(source)
97
+ print(f'found {len(offsets)} messages, {size/1e9:.2f} GB total', flush=True)
98
+ chunks = build_chunks(offsets, size, max_bytes)
99
+ print(f'planned {len(chunks)} parts', flush=True)
100
+
101
+ total = len(chunks)
102
+ part_paths = []
103
+ for i, (start, end) in enumerate(chunks, 1):
104
+ if out_dir:
105
+ base = os.path.join(out_dir, os.path.basename(source))
106
+ else:
107
+ base = source
108
+ part_paths.append(part_name(base, i, total))
109
+
110
+ # Process chunks from LAST to FIRST so the source can be truncated after
111
+ # each part is safely written, keeping peak extra disk usage to ~one chunk.
112
+ with open(source, 'r+b' if not dry_run else 'rb') as src:
113
+ for i in range(len(chunks) - 1, -1, -1):
114
+ start, end = chunks[i]
115
+ out_path = part_paths[i]
116
+ length = end - start
117
+ print(f' part {i+1}/{total}: bytes [{start},{end}) -> {out_path} ({length/1e9:.2f} GB)', flush=True)
118
+ src.seek(start)
119
+ remaining = length
120
+ with open(out_path, 'wb') as outf:
121
+ while remaining > 0:
122
+ buf = src.read(min(SCAN_BLOCK, remaining))
123
+ if not buf:
124
+ break
125
+ outf.write(buf)
126
+ remaining -= len(buf)
127
+ outf.flush()
128
+ os.fsync(outf.fileno())
129
+ if not dry_run:
130
+ src.flush()
131
+ os.truncate(source, start)
132
+ print(f' truncated source to {start/1e9:.2f} GB', flush=True)
133
+
134
+ if not dry_run:
135
+ os.remove(source)
136
+ print(f'removed emptied source {source}', flush=True)
137
+
138
+ return part_paths, offsets, size
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: olm2mbox
3
+ Version: 0.1.0
4
+ Summary: Convert Outlook for Mac .olm archives to standard mbox format, with a disk-space-efficient mbox splitter for oversized files.
5
+ Author: Tony Wu
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tonywut/olm2mbox
8
+ Project-URL: Repository, https://github.com/tonywut/olm2mbox
9
+ Project-URL: Issues, https://github.com/tonywut/olm2mbox/issues
10
+ Keywords: olm,mbox,outlook,email,mail,converter,thunderbird,apple-mail
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Utilities
16
+ Classifier: Environment :: Console
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # olm2mbox
25
+
26
+ olm2mbox 是一个将 Microsoft Outlook for Mac 的 `.olm` 邮件存档转换为标准 `mbox` 格式的命令行工具,可导入 Thunderbird、Apple Mail 等几乎所有支持 mbox 的邮件客户端或归档工具。
27
+
28
+ olm2mbox is a command-line tool that converts Microsoft Outlook for Mac `.olm` mail archives into standard `mbox` format, ready to import into Thunderbird, Apple Mail, or any other mbox-compatible mail client or archival tool.
29
+
30
+ ## 缘起 / Backstory
31
+
32
+ 从 Outlook 迁移到 Apple Mail,官方并没有提供直接的一键迁移工具:唯一的路径是先把 Outlook for Mac 的邮箱导出成 `.olm` 存档,再想办法转换成 `mbox` 格式才能导入 Apple Mail。网上能搜到的现成转换工具要么收费不低,要么面对真实的大邮箱(几万封邮件、几十个多级子文件夹、上百 GB 附件)就处理不了——导入导到一半崩溃、中文乱码、子文件夹被漏掉等问题层出不穷。于是干脆让 AI(Claude Code)帮忙把这个工具从头写了出来,自己先拿一个 115GB、8.7万+封邮件的真实邮箱完整跑通、校验无损,现在开源出来,希望能帮到有同样需求的人。
33
+
34
+ There's no official one-click way to migrate from Outlook to Apple Mail: the only path is exporting your Outlook for Mac mailbox as an `.olm` archive, then somehow converting it to `mbox` so Apple Mail can import it. The existing conversion tools I found online were either expensive or simply couldn't handle a real-world mailbox with tens of thousands of messages, dozens of nested subfolders, and hundreds of gigabytes of attachments — crashing mid-import, mangling CJK text, or silently dropping subfolders. So I had AI (Claude Code) build this tool from scratch instead, ran it end-to-end against my own 115GB, 87,000+ message mailbox, verified it byte-for-byte, and I'm open-sourcing it here in case it helps someone else in the same spot.
35
+
36
+ ## 特性 / Features
37
+
38
+ - **完整保留嵌套子文件夹结构 / Full nested-subfolder support** — 不只处理收件箱/已发送/草稿等标准文件夹,用户在 Outlook 里自建的多级子文件夹也会被扫描并各自导出为独立的 mbox 文件,镜像原有目录结构。
39
+ - **对脏数据健壮 / Resilient to malformed data** — 自动清理 XML 中的非法控制字符和噪声字符,修复包含换行符的邮件头导致的写入失败,处理地址中混入非 ASCII 字符导致标准库 `formataddr` 崩溃等真实遇到过的边界情况。
40
+ - **中文 / CJK 友好** — 正文使用 8bit 传输编码而非 quoted-printable,避免中文等多字节字符被编码放大近 3 倍,转换前后文件体积基本 1:1。
41
+ - **可续跑 / Resumable** — 超大邮箱转换到一半中断后,可用 `--resume` 跳过已完成的文件夹接着跑。
42
+ - **零依赖 / Zero dependencies** — 只用 Python 标准库,无需安装任何第三方包。
43
+ - **配套拆分工具 / Companion mbox splitter** — 转换后的单个 mbox 文件如果有几十 GB,导入 Apple Mail 等客户端容易直接闪退。`olm2mbox split` 可以把大 mbox 按体积拆成多份,采用"从文件尾部往前搬移 + 截断"的方式而非整份复制,拆分过程中几乎不额外占用磁盘空间,即使剩余空间小于原文件体积也能安全拆分。
44
+
45
+ 已在一个约 115GB、8.7万+封邮件(32个多级文件夹)的真实邮箱存档上完整跑通并校验字节级无损。
46
+ Battle-tested end-to-end and byte-verified against a real ~115GB, 87,000+ message archive spanning 32 nested folders.
47
+
48
+ ## 安装 / Installation
49
+
50
+ ```bash
51
+ git clone https://github.com/tonywut/olm2mbox.git
52
+ cd olm2mbox
53
+ pip install .
54
+ ```
55
+
56
+ 需要 Python 3.8+,无第三方依赖。
57
+ Requires Python 3.8+, no third-party dependencies.
58
+
59
+ ## 使用方法 / Usage
60
+
61
+ ### 转换 .olm 为 mbox / Convert .olm to mbox
62
+
63
+ ```bash
64
+ olm2mbox convert path/to/archive.olm path/to/output_dir
65
+ ```
66
+
67
+ 每个 OLM 文件夹(含嵌套子文件夹)会在 `output_dir` 下生成对应的 `.mbox` 文件,目录结构与原邮箱一致。
68
+ Each OLM folder (including nested subfolders) becomes a matching `.mbox` file under `output_dir`, mirroring the original folder tree.
69
+
70
+ 常用参数 / Common options:
71
+
72
+ ```bash
73
+ --folder NAME # 只转换指定的顶层文件夹(原始显示名,如 Inbox)
74
+ # only convert this top-level folder (its original display name, e.g. Inbox)
75
+ --limit N # 每个文件夹最多转换 N 封(用于测试)
76
+ # convert at most N messages per folder (for testing)
77
+ --resume # 跳过已经生成过 .mbox 的文件夹,用于续跑中断的转换
78
+ # skip folders whose .mbox already exists, to resume an interrupted run
79
+ --stats-only # 只扫描统计大小,不实际写出文件
80
+ # scan and report sizes without writing any output
81
+ ```
82
+
83
+ ### 拆分超大 mbox 文件 / Split an oversized mbox file
84
+
85
+ ```bash
86
+ olm2mbox split path/to/big.mbox --max-bytes 2000000000
87
+ ```
88
+
89
+ 会在原文件旁生成 `big_part01.mbox`、`big_part02.mbox` ... 等按时间顺序排列的小文件,原文件在确认每一份都安全写入磁盘后被逐步截断,最终整个替换掉,全程几乎不产生额外磁盘占用。
90
+ This produces `big_part01.mbox`, `big_part02.mbox`, ... in chronological order alongside the source file. The source is progressively truncated as each part is safely flushed to disk, and is fully replaced by the parts at the end — with almost no extra disk usage along the way.
91
+
92
+ 常用参数 / Common options:
93
+
94
+ ```bash
95
+ --max-bytes N # 每份的最大字节数,默认 2,000,000,000(约2GB)
96
+ # maximum size per part in bytes, default 2,000,000,000 (~2GB)
97
+ --dry-run # 只生成拆分文件,不改动(截断/删除)原文件,用于先行校验
98
+ # write part files without touching (truncating/deleting) the source
99
+ --out-dir DIR # 拆分文件的输出目录,默认与原文件同目录
100
+ # directory to write part files into, default alongside the source
101
+ ```
102
+
103
+ ## 已知限制 / Known limitations
104
+
105
+ - OLM 数据里正文的"纯文本版本"和"HTML 版本"在实践中几乎总是相同内容;当邮件是 HTML 格式时,纯文本备选版本是对 HTML 做简单去标签处理生成的近似结果,原始 HTML 始终完整保留在 HTML 分支里。
106
+ OLM rarely exposes a genuinely distinct plain-text body; for HTML messages, the plain-text alternative is a best-effort tag-stripped approximation, while the original HTML is always preserved verbatim.
107
+ - 邮件时间在 OLM 中不带时区信息,统一按 UTC 处理。
108
+ OLM timestamps carry no timezone info and are treated as UTC.
109
+
110
+ ## 支持 / Support
111
+
112
+ 如果这个工具帮你省了不少事,欢迎请我喝一杯咖啡 ☕️
113
+
114
+ If this tool saved you some time, feel free to buy me a coffee ☕️
115
+
116
+ <p>
117
+ <img src="assets/alipay-qr.jpg" alt="支付宝 Alipay" width="220">
118
+ <img src="assets/wechat-qr.jpg" alt="微信支付 WeChat Pay" width="220">
119
+ </p>
120
+
121
+ ## 许可证 / License
122
+
123
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/olm2mbox/__init__.py
5
+ src/olm2mbox/cli.py
6
+ src/olm2mbox/convert.py
7
+ src/olm2mbox/split.py
8
+ src/olm2mbox.egg-info/PKG-INFO
9
+ src/olm2mbox.egg-info/SOURCES.txt
10
+ src/olm2mbox.egg-info/dependency_links.txt
11
+ src/olm2mbox.egg-info/entry_points.txt
12
+ src/olm2mbox.egg-info/requires.txt
13
+ src/olm2mbox.egg-info/top_level.txt
14
+ tests/test_convert.py
15
+ tests/test_split.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ olm2mbox = olm2mbox.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ olm2mbox
@@ -0,0 +1,136 @@
1
+ """Tests for olm2mbox.convert against a small synthetic .olm-shaped zip.
2
+
3
+ Real .olm files can be tens or hundreds of gigabytes and contain private
4
+ mail, so they can't be checked into the repo. Instead these tests build a
5
+ minimal zip archive that mimics the on-disk layout OLM uses, covering the
6
+ cases the converter specifically has to handle: a nested user subfolder, a
7
+ name attribute on an address, an attachment with a Content-ID, and a header
8
+ value containing an embedded newline.
9
+ """
10
+ import email.policy
11
+ import mailbox
12
+ import os
13
+ import zipfile
14
+ from xml.sax.saxutils import escape as xml_escape
15
+
16
+ import pytest
17
+
18
+ from olm2mbox.convert import convert
19
+
20
+ PREFIX = 'Local/com.microsoft.__Messages/'
21
+
22
+ MESSAGE_XML_TEMPLATE = """<emails xml:space="preserve" elementCount="1"><email xml:space="preserve">{attachments}<OPFMessageCopyBody xml:space="preserve">{body}</OPFMessageCopyBody><OPFMessageCopyFromAddresses xml:space="preserve"><emailAddress xml:space="preserve" OPFContactEmailAddressAddress="{from_addr}" OPFContactEmailAddressName="{from_name}" OPFContactEmailAddressType="0"></emailAddress></OPFMessageCopyFromAddresses><OPFMessageCopyHTMLBody xml:space="preserve">{body}</OPFMessageCopyHTMLBody><OPFMessageCopyMessageID xml:space="preserve">{message_id}</OPFMessageCopyMessageID><OPFMessageCopyReceivedTime xml:space="preserve">2026-05-27T03:12:52</OPFMessageCopyReceivedTime><OPFMessageCopySentTime xml:space="preserve">2026-05-27T03:12:52</OPFMessageCopySentTime><OPFMessageCopySubject xml:space="preserve">{subject}</OPFMessageCopySubject><OPFMessageCopyToAddresses xml:space="preserve"><emailAddress xml:space="preserve" OPFContactEmailAddressAddress="{to_addr}" OPFContactEmailAddressType="0"></emailAddress></OPFMessageCopyToAddresses><OPFMessageGetHasHTML xml:space="preserve">1E0</OPFMessageGetHasHTML></email></emails>"""
23
+
24
+ ATTACHMENT_XML = (
25
+ '<messageAttachment xml:space="preserve" OPFAttachmentContentExtension="png" '
26
+ 'OPFAttachmentContentFileSize="4.22E2" OPFAttachmentContentID="{cid}" '
27
+ 'OPFAttachmentContentType="image/png" OPFAttachmentName="{name}" '
28
+ 'OPFAttachmentURL="{url}"></messageAttachment>'
29
+ )
30
+
31
+
32
+ def _build_olm(path, extra_subject_has_newline=False):
33
+ with zipfile.ZipFile(path, 'w') as zf:
34
+ # A message directly in the Inbox, with an attachment.
35
+ attach_url = f'{PREFIX}收件箱/com.microsoft.__Attachments/att0000_0000'
36
+ zf.writestr(attach_url, b'\x89PNG\r\n\x1a\nfake-png-bytes')
37
+ subject = 'Hello\r\nWorld' if extra_subject_has_newline else 'Hello World'
38
+ xml = MESSAGE_XML_TEMPLATE.format(
39
+ attachments=(
40
+ '<OPFMessageCopyAttachmentList xml:space="preserve">'
41
+ + ATTACHMENT_XML.format(cid='img1@example.com', name='pic.png', url=attach_url)
42
+ + '</OPFMessageCopyAttachmentList>'
43
+ ),
44
+ # OLM stores the HTML body double-escaped: it's XML-escaped text
45
+ # content whose *value* is itself HTML markup.
46
+ body=xml_escape('<html><body>Hi &amp; welcome, 你好</body></html>'),
47
+ from_addr='alice@example.com', from_name='Alice',
48
+ message_id='msg1@example.com',
49
+ subject=subject,
50
+ to_addr='bob@example.com',
51
+ )
52
+ zf.writestr(f'{PREFIX}收件箱/message_00000.xml', xml)
53
+
54
+ # A message in a nested, user-created subfolder.
55
+ xml2 = MESSAGE_XML_TEMPLATE.format(
56
+ attachments='',
57
+ body=xml_escape('<html><body>Second message</body></html>'),
58
+ from_addr='carol@example.com', from_name='carol@example.com',
59
+ message_id='msg2@example.com',
60
+ subject='Nested folder message',
61
+ to_addr='bob@example.com',
62
+ )
63
+ zf.writestr(f'{PREFIX}收件箱/ProjectX/message_00000.xml', xml2)
64
+
65
+
66
+ @pytest.fixture
67
+ def olm_path(tmp_path):
68
+ path = tmp_path / 'sample.olm'
69
+ _build_olm(path)
70
+ return path
71
+
72
+
73
+ def test_discovers_top_level_and_nested_folders(olm_path, tmp_path):
74
+ out_dir = tmp_path / 'out'
75
+ stats = convert(str(olm_path), str(out_dir))
76
+
77
+ assert os.path.exists(out_dir / 'Inbox.mbox')
78
+ assert os.path.exists(out_dir / 'Inbox' / 'ProjectX.mbox')
79
+ assert stats['Inbox']['count'] == 1
80
+ assert stats['Inbox/ProjectX']['count'] == 1
81
+ assert stats['Inbox']['errors'] == 0
82
+ assert stats['Inbox/ProjectX']['errors'] == 0
83
+
84
+
85
+ def test_message_headers_body_and_attachment(olm_path, tmp_path):
86
+ out_dir = tmp_path / 'out'
87
+ convert(str(olm_path), str(out_dir))
88
+
89
+ mb = mailbox.mbox(
90
+ str(out_dir / 'Inbox.mbox'),
91
+ factory=lambda f: email.message_from_binary_file(f, policy=email.policy.default),
92
+ )
93
+ messages = list(mb)
94
+ assert len(messages) == 1
95
+ msg = messages[0]
96
+
97
+ assert msg['Subject'] == 'Hello World'
98
+ assert msg['From'] == 'Alice <alice@example.com>'
99
+ assert msg['To'] == 'bob@example.com'
100
+ assert msg['Message-ID'] == '<msg1@example.com>'
101
+
102
+ html_part = msg.get_body(preferencelist=('html',))
103
+ assert '你好' in html_part.get_content() # CJK preserved, not entity-escaped
104
+
105
+ attachments = list(msg.iter_attachments())
106
+ assert len(attachments) == 1
107
+ assert attachments[0].get_filename() == 'pic.png'
108
+ assert attachments[0].get_content_type() == 'image/png'
109
+ assert attachments[0].get_content() == b'\x89PNG\r\n\x1a\nfake-png-bytes'
110
+
111
+
112
+ def test_embedded_newline_in_subject_does_not_crash(tmp_path):
113
+ olm = tmp_path / 'sample.olm'
114
+ _build_olm(olm, extra_subject_has_newline=True)
115
+ out_dir = tmp_path / 'out'
116
+
117
+ stats = convert(str(olm), str(out_dir))
118
+ assert stats['Inbox']['errors'] == 0
119
+
120
+ mb = mailbox.mbox(
121
+ str(out_dir / 'Inbox.mbox'),
122
+ factory=lambda f: email.message_from_binary_file(f, policy=email.policy.default),
123
+ )
124
+ msg = list(mb)[0]
125
+ assert '\n' not in msg['Subject']
126
+ assert '\r' not in msg['Subject']
127
+
128
+
129
+ def test_resume_skips_completed_folders(olm_path, tmp_path):
130
+ out_dir = tmp_path / 'out'
131
+ convert(str(olm_path), str(out_dir))
132
+ mtime_before = os.path.getmtime(out_dir / 'Inbox.mbox')
133
+
134
+ stats = convert(str(olm_path), str(out_dir), resume=True)
135
+ assert 'Inbox' not in stats # skipped, not reprocessed
136
+ assert os.path.getmtime(out_dir / 'Inbox.mbox') == mtime_before
@@ -0,0 +1,79 @@
1
+ """Tests for olm2mbox.split.
2
+
3
+ Builds a small synthetic mbox file (valid "From " envelopes + mboxrd-style
4
+ body escaping) and exercises the chunking, dry-run, and real truncate-based
5
+ split paths against it.
6
+ """
7
+ import glob
8
+ import os
9
+
10
+ import pytest
11
+
12
+ from olm2mbox.split import split
13
+
14
+ MSG_TEMPLATE = (
15
+ b'From sender%d@example.com Fri Aug %02d 10:00:00 2022\n'
16
+ b'Subject: message %d\n'
17
+ b'\n'
18
+ b'%s\n'
19
+ )
20
+
21
+
22
+ def _build_mbox(path, n_messages, payload_size):
23
+ with open(path, 'wb') as f:
24
+ for i in range(n_messages):
25
+ body = (b'x' * payload_size)
26
+ f.write(MSG_TEMPLATE % (i, (i % 28) + 1, i, body))
27
+ f.write(b'\n')
28
+
29
+
30
+ def test_dry_run_leaves_source_untouched(tmp_path):
31
+ src = tmp_path / 'test.mbox'
32
+ _build_mbox(src, n_messages=20, payload_size=1000)
33
+ original_bytes = src.read_bytes()
34
+
35
+ part_paths, offsets, size = split(str(src), max_bytes=5000, dry_run=True)
36
+
37
+ assert src.read_bytes() == original_bytes # untouched
38
+ assert len(part_paths) > 1
39
+ assert all(os.path.exists(p) for p in part_paths)
40
+ for p in part_paths:
41
+ os.remove(p)
42
+
43
+
44
+ def test_real_split_is_byte_exact_and_message_exact(tmp_path):
45
+ src = tmp_path / 'test.mbox'
46
+ _build_mbox(src, n_messages=37, payload_size=2000)
47
+ original_bytes = src.read_bytes()
48
+ original_message_count = original_bytes.count(b'\nFrom sender') + 1
49
+
50
+ part_paths, offsets, size = split(str(src), max_bytes=8000, dry_run=False)
51
+
52
+ # Source is fully consumed and removed.
53
+ assert not os.path.exists(src)
54
+ assert len(part_paths) > 1
55
+
56
+ # Reassembling the parts in order reproduces the original exactly.
57
+ reassembled = b''.join(open(p, 'rb').read() for p in sorted(part_paths))
58
+ assert reassembled == original_bytes
59
+
60
+ # No message is split across a part boundary, and the total count matches.
61
+ total_messages = sum(
62
+ open(p, 'rb').read().count(b'\nFrom sender')
63
+ + (1 if open(p, 'rb').read().startswith(b'From sender') else 0)
64
+ for p in part_paths
65
+ )
66
+ assert total_messages == original_message_count
67
+
68
+
69
+ def test_out_dir_places_parts_elsewhere(tmp_path):
70
+ src = tmp_path / 'test.mbox'
71
+ _build_mbox(src, n_messages=10, payload_size=500)
72
+ out_dir = tmp_path / 'parts'
73
+ out_dir.mkdir()
74
+
75
+ part_paths, offsets, size = split(str(src), max_bytes=3000, dry_run=True, out_dir=str(out_dir))
76
+
77
+ assert all(p.startswith(str(out_dir)) for p in part_paths)
78
+ for p in part_paths:
79
+ os.remove(p)