From b3402fed6d1117503619dd2a75a716efb3ba641a Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Fri, 9 Oct 2026 12:58:19 +0200 Subject: [PATCH 1/2] Save attachments named after a path into subfolders Outlook names a file attached from a folder after its Windows path (zpo-berufung\reference\akt.md). When --output names a directory, `post attachment` and `post eml --part` now split such a name on \ and / and write the file into those subfolders, instead of one file with backslashes in its name (attachment) or the folders run together (eml --part, through suggestedFilename). Only relativePath(forAttachmentNamed:) decides what of a sender's name is used: empty components, ".", ".." and a leading drive letter are dropped and each component is sanitized, so a name can no longer reach outside the output directory ("../../x" used to go straight into appendingPathComponent). Co-Authored-By: Claude Opus 5.5 --- Sources/post/Commands/Attachment.swift | 5 ++- Sources/post/Commands/LocalMessageFile.swift | 8 +++- Sources/post/OutputPath.swift | 40 ++++++++++++++++++++ Tests/PostCLITests/OutputPathTests.swift | 36 ++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/Sources/post/Commands/Attachment.swift b/Sources/post/Commands/Attachment.swift index c1352b9..375b36f 100644 --- a/Sources/post/Commands/Attachment.swift +++ b/Sources/post/Commands/Attachment.swift @@ -21,7 +21,8 @@ extension PostCLI { @Option(name: .long, help: "Mailbox name") var mailbox: String = "INBOX" - @Option(name: .long, help: "Output path — an existing directory, or a path ending in /, takes the attachment's own filename; anything else is the filename to write") + @Option(name: .long, help: ArgumentHelp("Output path — an existing directory, or a path ending in /, takes the attachment's own filename " + + "(folders in it, as Outlook writes them, become subfolders); anything else is the filename to write")) var output: String = "." func validate() throws { @@ -40,7 +41,7 @@ extension PostCLI { return } - let destination = try OutputPath.destination(for: output, named: attachment.filename) + let destination = try OutputPath.destination(for: output, attachmentNamed: attachment.filename) let displayName = destination.lastPathComponent try data.write(to: destination) print("Saved \(displayName) (\(attachment.contentType), \(attachment.size.formattedAsBytes())) to \(destination.path)") diff --git a/Sources/post/Commands/LocalMessageFile.swift b/Sources/post/Commands/LocalMessageFile.swift index 89f006e..6b15f9f 100644 --- a/Sources/post/Commands/LocalMessageFile.swift +++ b/Sources/post/Commands/LocalMessageFile.swift @@ -134,7 +134,8 @@ enum LocalMessageFile { @Option(name: .long, help: "Section of a single part to write out, as `--list-parts` prints it (e.g. 4.2)") var part: String? - @Option(name: .long, help: "Output path for --part — an existing directory, or a path ending in /, takes a file named after the part; anything else is the filename to write") + @Option(name: .long, help: ArgumentHelp("Output path for --part — an existing directory, or a path ending in /, takes a file named after the part " + + "(folders in its name become subfolders); anything else is the filename to write")) var output: String = "." func validate() throws { @@ -226,7 +227,10 @@ enum LocalMessageFile { throw ValidationError("Part \(section) (\(part.contentType)) carries no bytes of its own.\(hint)") } - let destination = try OutputPath.destination(for: output, named: part.suggestedFilename) + // The part's own name, not `suggestedFilename`, which strips `\` and + // `/` and would run a path's folders together into one name. + let destination = try OutputPath.destination( + for: output, attachmentNamed: part.filename ?? part.suggestedFilename) try data.write(to: destination) print("Saved \(destination.lastPathComponent) (\(part.contentType), \(data.count.formattedAsBytes())) " + "to \(destination.path)") diff --git a/Sources/post/OutputPath.swift b/Sources/post/OutputPath.swift index 7a3b508..0911170 100644 --- a/Sources/post/OutputPath.swift +++ b/Sources/post/OutputPath.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftMail /// How every `--output` option decides whether it was given a directory or a /// filename. @@ -35,4 +36,43 @@ enum OutputPath { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return wantsDirectory ? directory.appendingPathComponent(filename) : url } + + /// The file to write for an attachment whose name a sender chose. + /// + /// Like ``destination(for:named:)``, except that when `output` names a + /// directory the attachment's name may be a path: Outlook names files it + /// attaches from a folder `docs\reference\a.md`, and that lands as + /// `docs/reference/a.md` under the directory instead of one file with + /// backslashes in its name. Only ``relativePath(forAttachmentNamed:)`` + /// decides what of the name is kept, so it cannot leave the directory. + static func destination(for output: String, attachmentNamed name: String) throws -> URL { + guard namesDirectory(output) else { + return try destination(for: output, named: name) + } + let file = relativePath(forAttachmentNamed: name).reduce(URL(fileURLWithPath: output)) { + $0.appendingPathComponent($1) + } + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + return file + } + + /// The folders and filename an attachment's name stands for, each safe to + /// use as one path component. + /// + /// `/` and `\` both separate folders. Empty components, `.` and `..` are + /// dropped, and so is a leading drive letter (`C:`), so the path stays + /// relative and inside the directory it is written to. Each component is + /// stripped of the characters file systems refuse. A name that leaves + /// nothing is `attachment`. + static func relativePath(forAttachmentNamed name: String) -> [String] { + var components = name.split(whereSeparator: { $0 == "/" || $0 == "\\" }).map(String.init) + if let first = components.first, first.count == 2, first.last == ":", first.first?.isLetter == true { + components.removeFirst() + } + let kept = components + .map { $0.sanitizedFileName().trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != "." && $0 != ".." } + return kept.isEmpty ? ["attachment"] : kept + } } diff --git a/Tests/PostCLITests/OutputPathTests.swift b/Tests/PostCLITests/OutputPathTests.swift index 75a2784..d3eb1e8 100644 --- a/Tests/PostCLITests/OutputPathTests.swift +++ b/Tests/PostCLITests/OutputPathTests.swift @@ -98,4 +98,40 @@ final class OutputPathTests: XCTestCase { XCTAssertTrue(OutputPath.namesDirectory(directory.path + "/")) XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) } + + // MARK: - Attachment names with folders + + func testOutlookPathBecomesSubfolders() throws { + // Outlook names a file attached from a folder after its Windows path. + let directory = try makeTemporaryDirectory() + + let destination = try OutputPath.destination( + for: directory.path, attachmentNamed: #"zpo-berufung\reference\akt.md"#) + + XCTAssertEqual(destination, directory.appendingPathComponent("zpo-berufung/reference/akt.md")) + XCTAssertTrue(isDirectory(directory.appendingPathComponent("zpo-berufung/reference"))) + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + } + + func testAttachmentPathStaysInsideTheDirectory() { + XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: "../../.zshrc"), [".zshrc"]) + XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: "/etc/passwd"), ["etc", "passwd"]) + XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"C:\Users\x\a.pdf"#), ["Users", "x", "a.pdf"]) + XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"\\server\share\.\b.pdf"#), + ["server", "share", "b.pdf"]) + XCTAssertEqual(OutputPath.relativePath(forAttachmentNamed: #"..\.."#), ["attachment"]) + } + + func testPlainAttachmentNameIsUnchanged() throws { + let directory = try makeTemporaryDirectory() + + XCTAssertEqual(try OutputPath.destination(for: directory.path, attachmentNamed: "vertrag.pdf"), + directory.appendingPathComponent("vertrag.pdf")) + } + + func testAttachmentNameIsIgnoredWhenOutputIsAFilename() throws { + let destination = try makeTemporaryDirectory().appendingPathComponent("renamed.md") + + XCTAssertEqual(try OutputPath.destination(for: destination.path, attachmentNamed: #"a\b\c.md"#), destination) + } } From 2a8dae4134911fe9ca716f8ef3219b60a4b158f0 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Fri, 9 Oct 2026 13:07:10 +0200 Subject: [PATCH 2/2] Name a part with an empty filename after its section again `part.filename ?? part.suggestedFilename` took `filename=""` as the name, which relativePath turned into a bare `attachment`. An empty name now counts as none, in `post eml/msg --part` and in downloadAttachment, so the part is named `part_2.pdf` as before. Co-Authored-By: Claude Opus 5.5 --- Sources/PostServer/PostServer.swift | 2 +- Sources/post/Commands/LocalMessageFile.swift | 7 ++++--- .../PostCLITests/LocalPartExtractionTests.swift | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Sources/PostServer/PostServer.swift b/Sources/PostServer/PostServer.swift index 369f821..bf24ad0 100644 --- a/Sources/PostServer/PostServer.swift +++ b/Sources/PostServer/PostServer.swift @@ -823,7 +823,7 @@ public actor PostServer { } return AttachmentData( - filename: part.filename ?? part.suggestedFilename, + filename: part.filename.flatMap { $0.isEmpty ? nil : $0 } ?? part.suggestedFilename, contentType: part.contentType, data: data.base64EncodedString(), size: data.count diff --git a/Sources/post/Commands/LocalMessageFile.swift b/Sources/post/Commands/LocalMessageFile.swift index 6b15f9f..52b99aa 100644 --- a/Sources/post/Commands/LocalMessageFile.swift +++ b/Sources/post/Commands/LocalMessageFile.swift @@ -228,9 +228,10 @@ enum LocalMessageFile { } // The part's own name, not `suggestedFilename`, which strips `\` and - // `/` and would run a path's folders together into one name. - let destination = try OutputPath.destination( - for: output, attachmentNamed: part.filename ?? part.suggestedFilename) + // `/` and would run a path's folders together into one name. An empty + // name counts as none, so `suggestedFilename` names it (`part_2.pdf`). + let name = part.filename.flatMap { $0.isEmpty ? nil : $0 } ?? part.suggestedFilename + let destination = try OutputPath.destination(for: output, attachmentNamed: name) try data.write(to: destination) print("Saved \(destination.lastPathComponent) (\(part.contentType), \(data.count.formattedAsBytes())) " + "to \(destination.path)") diff --git a/Tests/PostCLITests/LocalPartExtractionTests.swift b/Tests/PostCLITests/LocalPartExtractionTests.swift index 450d504..e4abdef 100644 --- a/Tests/PostCLITests/LocalPartExtractionTests.swift +++ b/Tests/PostCLITests/LocalPartExtractionTests.swift @@ -37,10 +37,10 @@ final class LocalPartExtractionTests: XCTestCase { --outer-- """ - private func writeSampleEML() throws -> String { + private func writeSampleEML(_ eml: String = sampleEML) throws -> String { let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("post-parts-\(UUID().uuidString).eml") - try Data(Self.sampleEML.utf8).write(to: url) + try Data(eml.utf8).write(to: url) addTeardownBlock { try? FileManager.default.removeItem(at: url) } return url.path } @@ -112,6 +112,18 @@ final class LocalPartExtractionTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: destination), Data("%PDF-1.7 fake".utf8)) } + func testExtractNamesAnEmptyFilenameAfterThePart() throws { + // `filename=""` is no name; the part is named after its section and type. + let eml = Self.sampleEML.replacingOccurrences(of: "\"vertrag.pdf\"", with: "\"\"") + let message = try LocalMessageFile.read(try writeSampleEML(eml), as: .eml) + let directory = try makeOutputDirectory() + + try LocalMessageFile.extract(message, section: "2", to: directory.path) + + let written = directory.appendingPathComponent("part_2.pdf") + XCTAssertEqual(try Data(contentsOf: written), Data("%PDF-1.7 fake".utf8)) + } + func testExtractNamesTheAvailableSectionsWhenOneIsWrong() throws { let message = try LocalMessageFile.read(try writeSampleEML(), as: .eml) let directory = try makeOutputDirectory()