Compare commits
4 Commits
4482c4041e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
113a72f1d6
|
|||
|
e0b0c5e964
|
|||
|
d1dd56341f
|
|||
|
610d10fd1e
|
@@ -0,0 +1,42 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-12-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Email header normalization to ensure RFC 5322 compliance
|
||||||
|
- New `normalize-imap` binary tool to fix existing malformed emails in IMAP inbox
|
||||||
|
- IMAP fetch functionality to retrieve all messages from inbox
|
||||||
|
- IMAP delete and expunge functionality for message management
|
||||||
|
- Dry-run mode for the normalization tool (`--dry-run` flag)
|
||||||
|
- Confirmation prompts with skip option (`--yes` flag)
|
||||||
|
- Comprehensive email header continuation line fixing
|
||||||
|
- Analysis and reporting of which emails need normalization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored project structure to use library + multiple binaries
|
||||||
|
- Main binary renamed to `pop-to-imap` for clarity
|
||||||
|
- All modules made public for library usage
|
||||||
|
- Updated README with detailed usage instructions for both tools
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Email headers with missing whitespace on continuation lines now properly formatted
|
||||||
|
- RFC 5322 Section 2.2.3 compliance for header folding
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
- POP3 to IMAP email migration
|
||||||
|
- TLS/SSL support for secure connections
|
||||||
|
- Environment-based configuration via .env files
|
||||||
|
- Docker support with multi-stage builds
|
||||||
|
- Basic error handling and logging
|
||||||
Generated
+9
-248
@@ -56,7 +56,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -88,12 +88,6 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.21.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -222,7 +216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -252,17 +246,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -311,7 +294,7 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d"
|
checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.13.1",
|
"base64",
|
||||||
"bufstream",
|
"bufstream",
|
||||||
"chrono",
|
"chrono",
|
||||||
"imap-proto 0.10.2",
|
"imap-proto 0.10.2",
|
||||||
@@ -566,55 +549,15 @@ version = "0.8.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ring"
|
|
||||||
version = "0.16.20"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"spin",
|
|
||||||
"untrusted 0.7.1",
|
|
||||||
"web-sys",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ring"
|
|
||||||
version = "0.17.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom 0.2.16",
|
|
||||||
"libc",
|
|
||||||
"untrusted 0.9.0",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rs_pop_imap_importer"
|
name = "rs_pop_imap_importer"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"imap",
|
"imap",
|
||||||
"imap-proto 0.16.6",
|
"imap-proto 0.16.6",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"rust-pop3-client",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust-pop3-client"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "778e42fdc52a54878a9331d2b1cb2521092f43432f1fafab16f71337a886fb38"
|
|
||||||
dependencies = [
|
|
||||||
"rustls",
|
|
||||||
"rustls-native-certs",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -627,40 +570,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls"
|
|
||||||
version = "0.20.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"ring 0.16.20",
|
|
||||||
"sct",
|
|
||||||
"webpki",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-native-certs"
|
|
||||||
version = "0.6.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
|
|
||||||
dependencies = [
|
|
||||||
"openssl-probe",
|
|
||||||
"rustls-pemfile",
|
|
||||||
"schannel",
|
|
||||||
"security-framework",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pemfile"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.21.7",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -681,17 +591,7 @@ version = "0.1.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sct"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
|
||||||
dependencies = [
|
|
||||||
"ring 0.17.14",
|
|
||||||
"untrusted 0.9.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -723,12 +623,6 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "static_assertions"
|
name = "static_assertions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -759,10 +653,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -771,18 +665,6 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "untrusted"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "untrusted"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -801,12 +683,6 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.1+wasi-0.2.4"
|
version = "1.0.1+wasi-0.2.4"
|
||||||
@@ -861,48 +737,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-sys"
|
|
||||||
version = "0.3.82"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki"
|
|
||||||
version = "0.22.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
|
|
||||||
dependencies = [
|
|
||||||
"ring 0.17.14",
|
|
||||||
"untrusted 0.9.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-i686-pc-windows-gnu",
|
|
||||||
"winapi-x86_64-pc-windows-gnu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-i686-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -962,15 +796,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.52.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -980,70 +805,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
|
||||||
dependencies = [
|
|
||||||
"windows_aarch64_gnullvm",
|
|
||||||
"windows_aarch64_msvc",
|
|
||||||
"windows_i686_gnu",
|
|
||||||
"windows_i686_gnullvm",
|
|
||||||
"windows_i686_msvc",
|
|
||||||
"windows_x86_64_gnu",
|
|
||||||
"windows_x86_64_gnullvm",
|
|
||||||
"windows_x86_64_msvc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.52.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
|||||||
+13
-2
@@ -1,12 +1,23 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rs_pop_imap_importer"
|
name = "rs_pop_imap_importer"
|
||||||
version = "0.1.0"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "rs_pop_imap_importer"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pop-to-imap"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "normalize-imap"
|
||||||
|
path = "src/bin/normalize_imap.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
imap = "2.4.1"
|
imap = "2.4.1"
|
||||||
imap-proto = "0.16.6"
|
imap-proto = "0.16.6"
|
||||||
native-tls = "0.2.14"
|
native-tls = "0.2.14"
|
||||||
rust-pop3-client = "0.2.2"
|
|
||||||
|
|||||||
+10
-5
@@ -6,9 +6,13 @@ WORKDIR /usr/src/pop_imap_importer
|
|||||||
# Copy manifests first to leverage Docker layer caching
|
# Copy manifests first to leverage Docker layer caching
|
||||||
COPY Cargo.* ./
|
COPY Cargo.* ./
|
||||||
|
|
||||||
# Create a dummy src/main.rs file to build dependencies
|
# Create dummy source files to build dependencies
|
||||||
RUN mkdir -p src && \
|
RUN mkdir -p src/bin && \
|
||||||
echo "fn main() {}" > src/main.rs
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
echo "" > src/lib.rs && \
|
||||||
|
echo "fn main() {}" > src/bin/normalize_imap.rs && \
|
||||||
|
echo "fn main() {}" > src/bin/fetch_email.rs && \
|
||||||
|
echo "fn main() {}" > src/bin/list_uids.rs
|
||||||
|
|
||||||
# Build dependencies only - this will be cached as long as Cargo.toml/Cargo.lock don't change
|
# Build dependencies only - this will be cached as long as Cargo.toml/Cargo.lock don't change
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
@@ -16,7 +20,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
|
|
||||||
# Copy the actual source code
|
# Copy the actual source code
|
||||||
COPY src src
|
COPY src src
|
||||||
RUN touch src/main.rs
|
# Touch source files to force rebuild
|
||||||
|
RUN touch src/main.rs src/lib.rs
|
||||||
|
|
||||||
# Build the actual application
|
# Build the actual application
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
@@ -32,7 +37,7 @@ RUN apt-get update && \
|
|||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# RUN apt-get update && apt-get install -y extra-runtime-dependencies && rm -rf /var/lib/apt/lists/*
|
# RUN apt-get update && apt-get install -y extra-runtime-dependencies && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=builder /usr/src/pop_imap_importer/target/release/rs_pop_imap_importer /usr/local/bin/pop_imap_importer
|
COPY --from=builder /usr/src/pop_imap_importer/target/release/pop-to-imap /usr/local/bin/pop_imap_importer
|
||||||
|
|
||||||
CMD ["pop_imap_importer"]
|
CMD ["pop_imap_importer"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Makefile for building and running the POP3 to IMAP Importer Docker image
|
# Makefile for building and running the POP3 to IMAP Importer
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
IMAGE_NAME = pop-imap-importer
|
IMAGE_NAME = pop-imap-importer
|
||||||
@@ -12,6 +12,29 @@ help: ## Show this help message
|
|||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# Local build targets
|
||||||
|
.PHONY: build-local
|
||||||
|
build-local: ## Build both binaries locally (release mode)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
.PHONY: pop-to-imap
|
||||||
|
pop-to-imap: ## Run the POP3 to IMAP migration tool
|
||||||
|
cargo run --release --bin pop-to-imap
|
||||||
|
|
||||||
|
.PHONY: normalize-imap
|
||||||
|
normalize-imap: ## Run the IMAP normalization tool
|
||||||
|
cargo run --release --bin normalize-imap
|
||||||
|
|
||||||
|
.PHONY: normalize-dry-run
|
||||||
|
normalize-dry-run: ## Run the IMAP normalization tool in dry-run mode
|
||||||
|
cargo run --release --bin normalize-imap -- --dry-run
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Docker targets
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Build the Docker image for linux/amd64 platform
|
build: ## Build the Docker image for linux/amd64 platform
|
||||||
docker build --platform=$(DOCKER_PLATFORM) -t $(IMAGE_NAME) .
|
docker build --platform=$(DOCKER_PLATFORM) -t $(IMAGE_NAME) .
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ A Rust application that downloads emails from a POP3 server and imports them int
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Downloads all emails from a POP3 server
|
- **POP3 to IMAP Migration**: Downloads all emails from a POP3 server and imports them to IMAP
|
||||||
- Imports emails to the INBOX of an IMAP server
|
- **IMAP Email Normalization**: Fix existing malformed emails already in your IMAP inbox
|
||||||
- Secure TLS connections
|
- Secure TLS connections
|
||||||
- Environment-based configuration
|
- Environment-based configuration
|
||||||
|
- Automatic email header normalization (RFC 5322 compliance)
|
||||||
|
- Fixes improperly formatted header continuation lines
|
||||||
|
- Ensures continuation lines start with proper whitespace
|
||||||
|
- Safe operation with dry-run mode and confirmation prompts
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -35,8 +39,35 @@ A Rust application that downloads emails from a POP3 server and imports them int
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Run the application:
|
### POP3 to IMAP Migration
|
||||||
|
|
||||||
|
Run the main importer to migrate emails from POP3 to IMAP:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run
|
cargo run --bin pop-to-imap
|
||||||
|
# or in release mode
|
||||||
|
cargo run --release --bin pop-to-imap
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Normalize Existing IMAP Emails
|
||||||
|
|
||||||
|
If you already have malformed emails in your IMAP inbox, use the normalization tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry run to see what would be changed
|
||||||
|
cargo run --release --bin normalize-imap -- --dry-run
|
||||||
|
|
||||||
|
# Actually normalize the emails (will prompt for confirmation)
|
||||||
|
cargo run --release --bin normalize-imap
|
||||||
|
|
||||||
|
# Skip confirmation prompt
|
||||||
|
cargo run --release --bin normalize-imap --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The normalize-imap tool will:
|
||||||
|
|
||||||
|
1. Fetch all emails from your IMAP INBOX
|
||||||
|
2. Analyze which emails have malformed headers
|
||||||
|
3. Show you a summary of what needs to be fixed
|
||||||
|
4. Delete and re-import only the emails that need normalization
|
||||||
|
5. Keep emails that are already RFC 5322 compliant unchanged
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use rs_pop_imap_importer::{config::Settings, imap_client::ImapClient};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Fetch a specific email from IMAP server by UID
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[clap(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to the .env file containing server configurations
|
||||||
|
#[clap(short, long, default_value = ".env")]
|
||||||
|
env_file: String,
|
||||||
|
|
||||||
|
/// UID of the email to fetch
|
||||||
|
#[clap(short, long)]
|
||||||
|
uid: u32,
|
||||||
|
|
||||||
|
/// Output file path (optional, otherwise prints to stdout)
|
||||||
|
#[clap(short, long)]
|
||||||
|
output: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
println!("Connecting to IMAP server...");
|
||||||
|
let settings = Settings::from_env_file(&args.env_file)?;
|
||||||
|
let mut imap_client = ImapClient::new(&settings.imap)?;
|
||||||
|
imap_client.login(&settings.imap)?;
|
||||||
|
imap_client.select_inbox()?;
|
||||||
|
|
||||||
|
println!("Fetching all messages to find UID {}...", args.uid);
|
||||||
|
let messages = imap_client.fetch_all_messages()?;
|
||||||
|
|
||||||
|
let mut found = false;
|
||||||
|
for (msg_id, content) in messages {
|
||||||
|
if msg_id == args.uid {
|
||||||
|
found = true;
|
||||||
|
println!("Found message with UID {}", args.uid);
|
||||||
|
println!("Size: {} bytes", content.len());
|
||||||
|
|
||||||
|
if let Some(output_path) = args.output {
|
||||||
|
fs::write(&output_path, &content)?;
|
||||||
|
println!("Saved to: {}", output_path);
|
||||||
|
} else {
|
||||||
|
println!("\n--- Email Content ---\n");
|
||||||
|
print!("{}", content);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
eprintln!("Error: Email with UID {} not found", args.uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
imap_client.logout()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use rs_pop_imap_importer::{config::Settings, imap_client::ImapClient};
|
||||||
|
|
||||||
|
/// List all email UIDs in IMAP inbox
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[clap(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to the .env file containing server configurations
|
||||||
|
#[clap(short, long, default_value = ".env")]
|
||||||
|
env_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let settings = Settings::from_env_file(&args.env_file)?;
|
||||||
|
let mut imap_client = ImapClient::new(&settings.imap)?;
|
||||||
|
imap_client.login(&settings.imap)?;
|
||||||
|
imap_client.select_inbox()?;
|
||||||
|
|
||||||
|
println!("Fetching all messages...");
|
||||||
|
let messages = imap_client.fetch_all_messages()?;
|
||||||
|
|
||||||
|
println!("\nFound {} messages:", messages.len());
|
||||||
|
println!("UIDs:");
|
||||||
|
for (uid, _) in messages {
|
||||||
|
println!(" {}", uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
imap_client.logout()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use rs_pop_imap_importer::{config::Settings, imap_client::ImapClient, normalize_headers};
|
||||||
|
|
||||||
|
/// IMAP Email Normalizer
|
||||||
|
///
|
||||||
|
/// This utility fetches emails from an IMAP server, normalizes their headers
|
||||||
|
/// to ensure RFC 5322 compliance, and re-imports them.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[clap(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to the .env file containing server configurations
|
||||||
|
#[clap(short, long, default_value = ".env")]
|
||||||
|
env_file: String,
|
||||||
|
|
||||||
|
/// Perform a dry run without making changes
|
||||||
|
#[clap(short, long)]
|
||||||
|
dry_run: bool,
|
||||||
|
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[clap(short, long)]
|
||||||
|
yes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
println!("Starting IMAP email header normalization tool...");
|
||||||
|
|
||||||
|
// Load configuration from specified .env file
|
||||||
|
let settings = Settings::from_env_file(&args.env_file)?;
|
||||||
|
|
||||||
|
// Connect to IMAP server
|
||||||
|
println!("Connecting to IMAP server at {}:{}...", settings.imap.host, settings.imap.port);
|
||||||
|
let mut imap_client = ImapClient::new(&settings.imap)?;
|
||||||
|
imap_client.login(&settings.imap)?;
|
||||||
|
imap_client.select_inbox()?;
|
||||||
|
println!("Successfully connected to IMAP server");
|
||||||
|
|
||||||
|
// Fetch all messages
|
||||||
|
println!("Fetching all messages from INBOX...");
|
||||||
|
let messages = imap_client.fetch_all_messages()?;
|
||||||
|
println!("Found {} messages in INBOX", messages.len());
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
println!("No messages to process. Exiting.");
|
||||||
|
imap_client.logout()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze messages and count how many need normalization
|
||||||
|
let mut needs_normalization = 0;
|
||||||
|
let mut normalized_messages = Vec::new();
|
||||||
|
|
||||||
|
for (msg_id, content) in &messages {
|
||||||
|
match normalize_headers(content) {
|
||||||
|
Ok(normalized) => {
|
||||||
|
if &normalized != content {
|
||||||
|
needs_normalization += 1;
|
||||||
|
|
||||||
|
// Debug output to show what changed
|
||||||
|
if args.dry_run {
|
||||||
|
println!("\nMessage {} needs normalization:", msg_id);
|
||||||
|
|
||||||
|
// Show first difference
|
||||||
|
let orig_lines: Vec<&str> = content.lines().collect();
|
||||||
|
let norm_lines: Vec<&str> = normalized.lines().collect();
|
||||||
|
for (i, (o, n)) in orig_lines.iter().zip(norm_lines.iter()).enumerate() {
|
||||||
|
if o != n {
|
||||||
|
println!(" Line {}: Missing whitespace on header continuation", i + 1);
|
||||||
|
println!(" Before: {:?}", o);
|
||||||
|
println!(" After: {:?}", n);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_messages.push((*msg_id, normalized));
|
||||||
|
} else {
|
||||||
|
normalized_messages.push((*msg_id, content.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Failed to normalize message {}: {}", msg_id, e);
|
||||||
|
normalized_messages.push((*msg_id, content.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nAnalysis complete:");
|
||||||
|
println!(" Total messages: {}", messages.len());
|
||||||
|
println!(" Messages needing normalization: {}", needs_normalization);
|
||||||
|
println!(" Messages already compliant: {}", messages.len() - needs_normalization);
|
||||||
|
|
||||||
|
if needs_normalization == 0 {
|
||||||
|
println!("\nAll messages are already RFC 5322 compliant. No changes needed.");
|
||||||
|
imap_client.logout()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.dry_run {
|
||||||
|
println!("\nDry run mode - no changes will be made.");
|
||||||
|
println!("\nTo normalize these messages, run without --dry-run flag.");
|
||||||
|
imap_client.logout()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation prompt
|
||||||
|
if !args.yes {
|
||||||
|
println!("\nWARNING: This operation will:");
|
||||||
|
println!(" 1. Delete {} messages with malformed headers from INBOX", needs_normalization);
|
||||||
|
println!(" 2. Re-import them with normalized headers");
|
||||||
|
println!("\nThis operation cannot be undone!");
|
||||||
|
println!("\nDo you want to proceed? (yes/no)");
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
let input = input.trim().to_lowercase();
|
||||||
|
|
||||||
|
if input != "yes" && input != "y" {
|
||||||
|
println!("Operation cancelled.");
|
||||||
|
imap_client.logout()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nStarting normalization process...");
|
||||||
|
|
||||||
|
// Process messages that need normalization
|
||||||
|
let mut processed = 0;
|
||||||
|
let mut errors = 0;
|
||||||
|
|
||||||
|
for (i, (msg_id, content)) in messages.iter().enumerate() {
|
||||||
|
let normalized = &normalized_messages[i].1;
|
||||||
|
|
||||||
|
// Only process if normalization changed something
|
||||||
|
if normalized != content {
|
||||||
|
print!("Processing message {} ({}/{})... ", msg_id, processed + 1, needs_normalization);
|
||||||
|
|
||||||
|
// Delete the original message
|
||||||
|
match imap_client.delete_message(*msg_id) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Re-import with normalized headers
|
||||||
|
match imap_client.append_message(normalized) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("✓ normalized");
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("✗ failed to re-import: {}", e);
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("✗ failed to delete: {}", e);
|
||||||
|
errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expunge deleted messages
|
||||||
|
println!("\nExpunging deleted messages...");
|
||||||
|
imap_client.expunge()?;
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
println!("\n=== Normalization Summary ===");
|
||||||
|
println!("Successfully processed: {}", processed);
|
||||||
|
if errors > 0 {
|
||||||
|
println!("Errors encountered: {}", errors);
|
||||||
|
}
|
||||||
|
println!("Operation completed!");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
imap_client.logout()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/// Email processing utilities for normalizing and fixing email format issues
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
/// Normalizes a section of headers by fixing continuation lines
|
||||||
|
fn normalize_header_section(headers: &str, line_ending: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(headers.len());
|
||||||
|
let mut previous_line_was_header = false;
|
||||||
|
let lines: Vec<&str> = headers.lines().collect();
|
||||||
|
let line_count = lines.len();
|
||||||
|
|
||||||
|
for (idx, line) in lines.iter().enumerate() {
|
||||||
|
let is_last_line = idx == line_count - 1;
|
||||||
|
|
||||||
|
// Check if this is a header line (starts with a field name followed by colon)
|
||||||
|
// RFC 5322: field names consist of printable ASCII except colon
|
||||||
|
let is_header_start = line.chars().next().map_or(false, |c| c.is_ascii_alphabetic())
|
||||||
|
&& line.find(':').map_or(false, |pos| {
|
||||||
|
// Ensure all characters before the colon are valid field-name characters
|
||||||
|
line[..pos].chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_header_start {
|
||||||
|
result.push_str(line);
|
||||||
|
if !is_last_line {
|
||||||
|
result.push_str(line_ending);
|
||||||
|
}
|
||||||
|
previous_line_was_header = true;
|
||||||
|
} else if previous_line_was_header {
|
||||||
|
// This is a continuation line
|
||||||
|
if line.chars().next().map_or(false, |c| c.is_whitespace()) {
|
||||||
|
// Already has leading whitespace
|
||||||
|
result.push_str(line);
|
||||||
|
} else if !line.is_empty() {
|
||||||
|
// Missing leading whitespace - add a space
|
||||||
|
result.push(' ');
|
||||||
|
result.push_str(line);
|
||||||
|
} else {
|
||||||
|
result.push_str(line);
|
||||||
|
}
|
||||||
|
if !is_last_line {
|
||||||
|
result.push_str(line_ending);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(line);
|
||||||
|
if !is_last_line {
|
||||||
|
result.push_str(line_ending);
|
||||||
|
}
|
||||||
|
previous_line_was_header = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes email headers to ensure RFC 5322 compliance
|
||||||
|
///
|
||||||
|
/// This function fixes improperly formatted header continuation lines by ensuring
|
||||||
|
/// that continuation lines start with at least one whitespace character (space or tab).
|
||||||
|
///
|
||||||
|
/// According to RFC 5322 Section 2.2.3:
|
||||||
|
/// - Header fields can be continued on subsequent lines
|
||||||
|
/// - Continuation lines MUST begin with at least one LWSP (space or tab)
|
||||||
|
///
|
||||||
|
/// This function processes both main email headers AND MIME part headers within the body.
|
||||||
|
/// It preserves the original line endings (CRLF or LF) of the email.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `email` - The raw email content as a string
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * The normalized email with properly formatted header continuation lines
|
||||||
|
pub fn normalize_headers(email: &str) -> Result<String, Box<dyn Error>> {
|
||||||
|
// Detect line ending style: CRLF (Windows/SMTP) or LF (Unix)
|
||||||
|
let line_ending = if email.contains("\r\n") { "\r\n" } else { "\n" };
|
||||||
|
let separator = if line_ending == "\r\n" { "\r\n\r\n" } else { "\n\n" };
|
||||||
|
|
||||||
|
// Find the end of main headers
|
||||||
|
let main_headers_end = match email.find(separator) {
|
||||||
|
Some(pos) => pos,
|
||||||
|
None => return Ok(email.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process main headers
|
||||||
|
let main_headers = &email[..main_headers_end];
|
||||||
|
let normalized_main_headers = normalize_header_section(main_headers, line_ending);
|
||||||
|
|
||||||
|
// Process the body - look for MIME part headers
|
||||||
|
let body_start = main_headers_end + separator.len();
|
||||||
|
let body = &email[body_start..];
|
||||||
|
|
||||||
|
let mut result = normalized_main_headers;
|
||||||
|
result.push_str(separator);
|
||||||
|
|
||||||
|
// Process body, looking for MIME part headers
|
||||||
|
// MIME part headers appear after boundary markers and before the next empty line
|
||||||
|
let mut current_pos = 0;
|
||||||
|
|
||||||
|
while current_pos < body.len() {
|
||||||
|
// Look for next empty line (potential MIME part header separator)
|
||||||
|
if let Some(next_sep_pos) = body[current_pos..].find(separator) {
|
||||||
|
let absolute_sep_pos = current_pos + next_sep_pos;
|
||||||
|
let section_before = &body[current_pos..absolute_sep_pos];
|
||||||
|
|
||||||
|
// Check if this section is MIME part headers:
|
||||||
|
// - Must contain at least one header line
|
||||||
|
// - MIME part headers typically include Content-Type, Content-Transfer-Encoding, etc.
|
||||||
|
// - Should NOT be mixed with body content (HTML, text, etc.)
|
||||||
|
|
||||||
|
let lines: Vec<&str> = section_before.lines().collect();
|
||||||
|
let mut header_count = 0;
|
||||||
|
let mut non_header_count = 0;
|
||||||
|
let mut has_mime_headers = false;
|
||||||
|
|
||||||
|
let mut last_was_header = false;
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a MIME boundary marker
|
||||||
|
if line.starts_with("--") && line.len() > 2 {
|
||||||
|
continue; // Skip boundary markers in the analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a header start line
|
||||||
|
let is_header_start = line.chars().next().map_or(false, |c| c.is_ascii_alphabetic())
|
||||||
|
&& line.find(':').map_or(false, |pos| {
|
||||||
|
line[..pos].chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this is a continuation line (starts with whitespace)
|
||||||
|
let is_continuation = line.chars().next().map_or(false, |c| c.is_whitespace());
|
||||||
|
|
||||||
|
if is_header_start {
|
||||||
|
header_count += 1;
|
||||||
|
last_was_header = true;
|
||||||
|
// Check for typical MIME headers
|
||||||
|
if line.starts_with("Content-") || line.starts_with("MIME-Version") || line.starts_with("X-WS-") {
|
||||||
|
has_mime_headers = true;
|
||||||
|
}
|
||||||
|
} else if is_continuation || last_was_header {
|
||||||
|
// This is either a proper continuation line OR a line following a header
|
||||||
|
// (which might be a malformed continuation line missing whitespace)
|
||||||
|
// In either case, don't count it as body content
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Not a header, not a continuation - this is body content
|
||||||
|
non_header_count += 1;
|
||||||
|
last_was_header = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only normalize if this section contains MIME headers and no body content
|
||||||
|
// (boundary markers are OK and expected)
|
||||||
|
if header_count > 0 && has_mime_headers && non_header_count == 0 {
|
||||||
|
let normalized_section = normalize_header_section(section_before, line_ending);
|
||||||
|
result.push_str(&normalized_section);
|
||||||
|
result.push_str(separator);
|
||||||
|
current_pos = absolute_sep_pos + separator.len();
|
||||||
|
} else {
|
||||||
|
// Not MIME headers, copy as-is
|
||||||
|
result.push_str(&body[current_pos..absolute_sep_pos + separator.len()]);
|
||||||
|
current_pos = absolute_sep_pos + separator.len();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No more separators, copy rest of body as-is
|
||||||
|
result.push_str(&body[current_pos..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_with_proper_continuation() {
|
||||||
|
let email = "From: test@example.com\nSubject: Test\n line 2\nTo: user@example.com\n\nBody";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("Subject: Test\n line 2\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_with_missing_whitespace() {
|
||||||
|
let email = "From: test@example.com\nSubject: Test\nline 2\nTo: user@example.com\n\nBody";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("Subject: Test\n line 2\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_preserves_body() {
|
||||||
|
let email = "From: test@example.com\nSubject: Test\n\nBody line 1\nBody line 2";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("Body line 1\nBody line 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_complex_continuation() {
|
||||||
|
let email = concat!(
|
||||||
|
"ARC-Seal: i=1; a=rsa-sha256; t=1764789271; cv=none;\n",
|
||||||
|
"d=google.com; s=arc-20240605;\n",
|
||||||
|
"b=WzYePPFoiBLQx6r6obqcdcSu658wc1rT9O383Yux3i6ngaTS4Z4Jc1vKOZ128wn1rR\n",
|
||||||
|
"To: test@example.com\n",
|
||||||
|
"\n",
|
||||||
|
"Body"
|
||||||
|
);
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains(" d=google.com; s=arc-20240605;"));
|
||||||
|
assert!(result.contains(" b=WzYePPFoiBLQx6r6obqcdcSu658wc1rT9O383Yux3i6ngaTS4Z4Jc1vKOZ128wn1rR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_preserves_crlf() {
|
||||||
|
let email = "From: test@example.com\r\nSubject: Test\r\n\r\nBody";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("\r\n"));
|
||||||
|
assert!(!result.contains("\n\n")); // Should not have double LF
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_crlf_continuation() {
|
||||||
|
let email = "From: test@example.com\r\nSubject: Test\r\nline 2\r\nTo: user@example.com\r\n\r\nBody";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("Subject: Test\r\n line 2\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_headers_no_changes_needed() {
|
||||||
|
let email = "From: test@example.com\r\nSubject: Test\r\n line 2\r\nTo: user@example.com\r\n\r\nBody";
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert_eq!(email, result, "Email should not be modified if already compliant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_attachment_headers() {
|
||||||
|
let email = concat!(
|
||||||
|
"From: test@example.com\r\n",
|
||||||
|
"Subject: Test\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--boundary\r\n",
|
||||||
|
"X-WS-Attachment-UUID: 123\r\n",
|
||||||
|
"Content-Type: application/pdf;\r\n",
|
||||||
|
"name=test.pdf\r\n",
|
||||||
|
"Content-Disposition: attachment;\r\n",
|
||||||
|
"filename=test.pdf\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"data"
|
||||||
|
);
|
||||||
|
let result = normalize_headers(email).unwrap();
|
||||||
|
assert!(result.contains("Content-Type: application/pdf;\r\n name=test.pdf"),
|
||||||
|
"Should add space to Content-Type continuation");
|
||||||
|
assert!(result.contains("Content-Disposition: attachment;\r\n filename=test.pdf"),
|
||||||
|
"Should add space to Content-Disposition continuation");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,4 +40,52 @@ impl ImapClient {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fetch_all_messages(&mut self) -> Result<Vec<(u32, String)>, Box<dyn std::error::Error>> {
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref mut session) = self.session {
|
||||||
|
// Fetch all messages using UID FETCH command
|
||||||
|
let message_stream = session.uid_fetch("1:*", "RFC822")?;
|
||||||
|
|
||||||
|
for msg in message_stream.iter() {
|
||||||
|
let uid = msg.uid.ok_or("Message missing UID")?;
|
||||||
|
if let Some(body) = msg.body() {
|
||||||
|
let message_content = String::from_utf8_lossy(body).to_string();
|
||||||
|
messages.push((uid, message_content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_message_by_uid(&mut self, uid: u32) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||||
|
if let Some(ref mut session) = self.session {
|
||||||
|
let message_stream = session.uid_fetch(format!("{}", uid), "RFC822")?;
|
||||||
|
|
||||||
|
for msg in message_stream.iter() {
|
||||||
|
if let Some(body) = msg.body() {
|
||||||
|
return Ok(Some(String::from_utf8_lossy(body).to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_message(&mut self, uid: u32) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Some(ref mut session) = self.session {
|
||||||
|
// Mark message as deleted using UID STORE command
|
||||||
|
session.uid_store(format!("{}", uid), "+FLAGS (\\Deleted)")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expunge(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Some(ref mut session) = self.session {
|
||||||
|
// Permanently remove messages marked as deleted
|
||||||
|
session.expunge()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod pop3_client;
|
||||||
|
pub mod imap_client;
|
||||||
|
pub mod email_processor;
|
||||||
|
|
||||||
|
// Re-export the normalize_headers function for external use
|
||||||
|
pub use email_processor::normalize_headers;
|
||||||
+5
-8
@@ -1,11 +1,5 @@
|
|||||||
mod config;
|
|
||||||
mod pop3_client;
|
|
||||||
mod imap_client;
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::Settings;
|
use rs_pop_imap_importer::{config::Settings, pop3_client::Pop3Client, imap_client::ImapClient, normalize_headers};
|
||||||
use pop3_client::Pop3Client;
|
|
||||||
use imap_client::ImapClient;
|
|
||||||
|
|
||||||
/// POP3 to IMAP Email Importer
|
/// POP3 to IMAP Email Importer
|
||||||
///
|
///
|
||||||
@@ -50,8 +44,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Retrieve message content
|
// Retrieve message content
|
||||||
let message_content = pop3_client.retrieve_message(msg_id)?;
|
let message_content = pop3_client.retrieve_message(msg_id)?;
|
||||||
|
|
||||||
|
// Normalize email headers to ensure RFC 5322 compliance
|
||||||
|
let normalized_content = normalize_headers(&message_content)?;
|
||||||
|
|
||||||
// Append message to IMAP inbox
|
// Append message to IMAP inbox
|
||||||
imap_client.append_message(&message_content)?;
|
imap_client.append_message(&normalized_content)?;
|
||||||
println!("Message {} imported successfully", msg_id);
|
println!("Message {} imported successfully", msg_id);
|
||||||
|
|
||||||
// Optionally delete message from POP3 server after successful import
|
// Optionally delete message from POP3 server after successful import
|
||||||
|
|||||||
+114
-15
@@ -1,43 +1,142 @@
|
|||||||
use crate::config::settings::Pop3Config;
|
use crate::config::settings::Pop3Config;
|
||||||
use rust_pop3_client::Pop3Connection;
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use native_tls::TlsConnector;
|
||||||
|
|
||||||
pub struct Pop3Client {
|
pub struct Pop3Client {
|
||||||
connection: Pop3Connection,
|
stream: BufReader<native_tls::TlsStream<TcpStream>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pop3Client {
|
impl Pop3Client {
|
||||||
pub fn new(config: &Pop3Config) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn new(config: &Pop3Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let connection = Pop3Connection::new(&config.host, config.port)?;
|
// Connect to POP3 server
|
||||||
Ok(Pop3Client { connection })
|
let tcp_stream = TcpStream::connect((&config.host[..], config.port))?;
|
||||||
|
|
||||||
|
// Wrap with TLS
|
||||||
|
let connector = TlsConnector::new()?;
|
||||||
|
let tls_stream = connector.connect(&config.host, tcp_stream)?;
|
||||||
|
let stream = BufReader::new(tls_stream);
|
||||||
|
|
||||||
|
let mut client = Pop3Client { stream };
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
client.read_response()?;
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login(&mut self, config: &Pop3Config) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn login(&mut self, config: &Pop3Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.connection.login(&config.username, &config.password)?;
|
// Send USER command
|
||||||
|
self.send_command(&format!("USER {}\r\n", config.username))?;
|
||||||
|
self.read_response()?;
|
||||||
|
|
||||||
|
// Send PASS command
|
||||||
|
self.send_command(&format!("PASS {}\r\n", config.password))?;
|
||||||
|
self.read_response()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_messages(&mut self) -> Result<Vec<(u32, u32)>, Box<dyn std::error::Error>> {
|
pub fn list_messages(&mut self) -> Result<Vec<(u32, u32)>, Box<dyn std::error::Error>> {
|
||||||
let infos = self.connection.list()?;
|
self.send_command("LIST\r\n")?;
|
||||||
let list = infos.into_iter().map(|info| (info.message_id, info.message_size)).collect();
|
|
||||||
Ok(list)
|
let mut messages = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
// Read first response line
|
||||||
|
self.stream.read_line(&mut line)?;
|
||||||
|
if !line.starts_with("+OK") {
|
||||||
|
return Err(format!("LIST failed: {}", line).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read message list
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
self.stream.read_line(&mut line)?;
|
||||||
|
|
||||||
|
if line.trim() == "." {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
let id: u32 = parts[0].parse()?;
|
||||||
|
let size: u32 = parts[1].parse()?;
|
||||||
|
messages.push((id, size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn retrieve_message(&mut self, msg_id: u32) -> Result<String, Box<dyn std::error::Error>> {
|
pub fn retrieve_message(&mut self, msg_id: u32) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let mut buffer = Vec::new();
|
self.send_command(&format!("RETR {}\r\n", msg_id))?;
|
||||||
self.connection.retrieve(msg_id, &mut buffer)?;
|
|
||||||
let message = String::from_utf8(buffer)?;
|
let mut line = String::new();
|
||||||
Ok(message)
|
|
||||||
|
// Read first response line
|
||||||
|
self.stream.read_line(&mut line)?;
|
||||||
|
if !line.starts_with("+OK") {
|
||||||
|
return Err(format!("RETR failed: {}", line).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read message content as raw bytes
|
||||||
|
let mut message_bytes = Vec::new();
|
||||||
|
let mut line_bytes = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line_bytes.clear();
|
||||||
|
let bytes_read = self.stream.read_until(b'\n', &mut line_bytes)?;
|
||||||
|
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for termination (lone period)
|
||||||
|
if line_bytes == b".\r\n" || line_bytes == b".\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle byte-stuffing (POP3 doubles leading dots)
|
||||||
|
if line_bytes.starts_with(b"..") {
|
||||||
|
message_bytes.extend_from_slice(&line_bytes[1..]);
|
||||||
|
} else {
|
||||||
|
message_bytes.extend_from_slice(&line_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to String using lossy conversion
|
||||||
|
// This preserves the structure while handling any encoding issues
|
||||||
|
Ok(String::from_utf8_lossy(&message_bytes).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn delete_message(&mut self, msg_id: u32) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn delete_message(&mut self, msg_id: u32) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
self.connection.delete(msg_id)?;
|
self.send_command(&format!("DELE {}\r\n", msg_id))?;
|
||||||
|
self.read_response()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn quit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn quit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// The rust-pop3-client doesn't seem to have an explicit quit method
|
self.send_command("QUIT\r\n")?;
|
||||||
// The connection should be closed when the object is dropped
|
self.read_response()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn send_command(&mut self, command: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
self.stream.get_mut().write_all(command.as_bytes())?;
|
||||||
|
self.stream.get_mut().flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_response(&mut self) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let mut line = String::new();
|
||||||
|
self.stream.read_line(&mut line)?;
|
||||||
|
|
||||||
|
if !line.starts_with("+OK") {
|
||||||
|
return Err(format!("POP3 error: {}", line).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user