Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/Cocoanetics/SwiftMCP", .upToNextMajor(from: "1.4.3")),
.package(url: "https://github.com/Cocoanetics/SwiftMail", .upToNextMajor(from: "1.4.0")),
.package(url: "https://github.com/Cocoanetics/SwiftText", .upToNextMajor(from: "1.1.4")),
.package(url: "https://github.com/Cocoanetics/SwiftText", .upToNextMajor(from: "1.1.7")),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0")
],
Expand All @@ -38,6 +38,7 @@ let package = Package(
.product(name: "SwiftMCP", package: "SwiftMCP"),
.product(name: "SwiftMail", package: "SwiftMail"),
.product(name: "SwiftTextHTML", package: "SwiftText"),
.product(name: "SwiftTextCore", package: "SwiftText"),
.product(name: "Logging", package: "swift-log")
],
plugins: [
Expand Down
30 changes: 26 additions & 4 deletions Sources/PostServer/Models/MessageDetail.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import SwiftMCP
import SwiftTextCore
import SwiftTextHTML

@Schema
Expand All @@ -19,6 +20,8 @@ public struct MessageDetail: Codable, Sendable {
public let messageId: String?
/// The References header value (RFC 822, space-separated Message-IDs)
public let references: String?
/// Optional description of Unicode abuse removed from subject and/or body.
public let unicodeAbuse: String?

public init(
uid: Int,
Expand All @@ -32,7 +35,8 @@ public struct MessageDetail: Codable, Sendable {
attachments: [AttachmentInfo],
additionalHeaders: [String: String]? = nil,
messageId: String? = nil,
references: String? = nil
references: String? = nil,
unicodeAbuse: String? = nil
) {
self.uid = uid
self.from = from
Expand All @@ -46,22 +50,40 @@ public struct MessageDetail: Codable, Sendable {
self.additionalHeaders = additionalHeaders
self.messageId = messageId
self.references = references
self.unicodeAbuse = unicodeAbuse
}
}

extension MessageDetail {
public func sanitizedSubject() -> SanitizedText {
UnicodeAbuseSummary.sanitize(subject, field: "Subject")
}

public func sanitizedTextBody() -> SanitizedText {
UnicodeAbuseSummary.sanitize(textBody ?? "", field: "Body")
}

public func sanitizedHTMLBody() -> SanitizedText {
UnicodeAbuseSummary.sanitize(htmlBody ?? textBody ?? "", field: "Body")
}

/// Returns the message body as markdown.
/// Converts HTML to markdown when available, falls back to plain text.
public func markdown() async throws -> String {
try await markdownSanitized().text
}

public func markdownSanitized() async throws -> SanitizedText {
if let htmlBody, !htmlBody.isEmpty {
let converter = HTMLToMarkdown(data: Data(htmlBody.utf8))
return try await converter.markdown()
let raw = try await converter.markdown()
return UnicodeAbuseSummary.sanitize(raw, field: "Body")
}

if let textBody, !textBody.isEmpty {
return textBody
return UnicodeAbuseSummary.sanitize(textBody, field: "Body")
}

return ""
return SanitizedText(text: "")
}
}
7 changes: 7 additions & 0 deletions Sources/PostServer/Models/MessageHeader+Sanitization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

public extension MessageHeader {
func sanitizedSubject() -> SanitizedText {
UnicodeAbuseSummary.sanitize(subject, field: "Subject")
}
}
12 changes: 11 additions & 1 deletion Sources/PostServer/Models/MessageHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ public struct MessageHeader: Codable, Sendable {
public let subject: String
public let date: String
public let flags: MessageFlags
/// Optional description of Unicode abuse removed from subject.
public let unicodeAbuse: String?

public init(uid: Int, from: String, subject: String, date: String, flags: MessageFlags) {
public init(
uid: Int,
from: String,
subject: String,
date: String,
flags: MessageFlags,
unicodeAbuse: String? = nil
) {
self.uid = uid
self.from = from
self.subject = subject
self.date = date
self.flags = flags
self.unicodeAbuse = unicodeAbuse
}
}
42 changes: 28 additions & 14 deletions Sources/PostServer/PostServer+IDLE.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,10 @@ extension PostServer {
let replyTo = extractReplyTo(from: decodedAdditionalHeaders)
let decodedFrom = decodeHeaderValue(messageInfo.from ?? header.from)
let decodedTo = decodeRecipientList(messageInfo.to)
let decodedSubject = decodeHeaderValue(messageInfo.subject ?? header.subject)
let decodedSubject = UnicodeAbuseSummary.sanitize(
decodeHeaderValue(messageInfo.subject ?? header.subject),
field: "Subject"
)
let attachmentParts = messageInfo.parts.filter(isAttachmentPart)
let attachments: [HookAttachmentPayload] = attachmentParts.map { part in
let filename = canonicalAttachmentFilename(part)
Expand All @@ -368,7 +371,7 @@ extension PostServer {
from: decodedFrom,
to: decodedTo,
replyTo: replyTo,
subject: decodedSubject,
subject: decodedSubject.text,
date: formatHookDate(resolvedDate)
)
return HookMessagePayload(
Expand All @@ -378,8 +381,9 @@ extension PostServer {
to: decodedTo,
replyTo: replyTo,
date: resolvedDate,
subject: decodedSubject,
markdown: markdown,
subject: decodedSubject.text,
markdown: markdown?.text,
unicodeAbuse: UnicodeAbuseSummary.combine([decodedSubject.unicodeAbuse, markdown?.unicodeAbuse]),
flags: messageInfo.flags.map(Self.flagToString),
attachments: attachments,
headers: headers
Expand All @@ -406,15 +410,17 @@ extension PostServer {
header: MessageHeader
) async -> HookMessagePayload {
guard (1...Int(UInt32.max)).contains(header.uid) else {
let subject = UnicodeAbuseSummary.sanitize(decodeHeaderValue(header.subject), field: "Subject")
return HookMessagePayload(
uid: header.uid,
mailbox: mailbox,
from: decodeHeaderValue(header.from),
to: [],
replyTo: nil,
date: resolveHookDate(messageDate: nil, headerDate: header.date),
subject: decodeHeaderValue(header.subject),
subject: subject.text,
markdown: nil,
unicodeAbuse: subject.unicodeAbuse,
flags: [],
attachments: [],
headers: [:]
Expand All @@ -425,15 +431,17 @@ extension PostServer {
_ = try await connection.selectMailbox(mailbox)
let identifier = UID(UInt32(header.uid))
guard let messageInfo = try await connection.fetchMessageInfo(for: identifier) else {
let subject = UnicodeAbuseSummary.sanitize(decodeHeaderValue(header.subject), field: "Subject")
return HookMessagePayload(
uid: header.uid,
mailbox: mailbox,
from: decodeHeaderValue(header.from),
to: [],
replyTo: nil,
date: resolveHookDate(messageDate: nil, headerDate: header.date),
subject: decodeHeaderValue(header.subject),
subject: subject.text,
markdown: nil,
unicodeAbuse: subject.unicodeAbuse,
flags: [],
attachments: [],
headers: [:]
Expand All @@ -452,7 +460,10 @@ extension PostServer {
let replyTo = extractReplyTo(from: decodedAdditionalHeaders)
let decodedFrom = decodeHeaderValue(messageInfo.from ?? header.from)
let decodedTo = decodeRecipientList(messageInfo.to)
let decodedSubject = decodeHeaderValue(messageInfo.subject ?? header.subject)
let decodedSubject = UnicodeAbuseSummary.sanitize(
decodeHeaderValue(messageInfo.subject ?? header.subject),
field: "Subject"
)
let attachmentParts = messageInfo.parts.filter(isAttachmentPart)
let attachments: [HookAttachmentPayload] = attachmentParts.map { part in
let filename = canonicalAttachmentFilename(part)
Expand All @@ -473,7 +484,7 @@ extension PostServer {
from: decodedFrom,
to: decodedTo,
replyTo: replyTo,
subject: decodedSubject,
subject: decodedSubject.text,
date: formatHookDate(resolvedDate)
)
return HookMessagePayload(
Expand All @@ -483,8 +494,9 @@ extension PostServer {
to: decodedTo,
replyTo: replyTo,
date: resolvedDate,
subject: decodedSubject,
markdown: markdown,
subject: decodedSubject.text,
markdown: markdown?.text,
unicodeAbuse: UnicodeAbuseSummary.combine([decodedSubject.unicodeAbuse, markdown?.unicodeAbuse]),
flags: messageInfo.flags.map(Self.flagToString),
attachments: attachments,
headers: headers
Expand All @@ -493,15 +505,17 @@ extension PostServer {
Self.logDiagnostic("ERROR failed to fetch hook message details for \(mailbox) uid=\(header.uid): \(String(describing: error))")
}

let subject = UnicodeAbuseSummary.sanitize(decodeHeaderValue(header.subject), field: "Subject")
return HookMessagePayload(
uid: header.uid,
mailbox: mailbox,
from: decodeHeaderValue(header.from),
to: [],
replyTo: nil,
date: resolveHookDate(messageDate: nil, headerDate: header.date),
subject: decodeHeaderValue(header.subject),
subject: subject.text,
markdown: nil,
unicodeAbuse: subject.unicodeAbuse,
flags: [],
attachments: [],
headers: [:]
Expand All @@ -527,7 +541,7 @@ extension PostServer {
/// Fetches ALL RFC 822 headers by fetching the raw message and parsing headers.
/// This is a workaround for SwiftMail not populating MessageInfo.additionalFields.
/// Produces markdown by fetching only text/html body parts (no attachment download).
fileprivate static func fetchHookMarkdown(using connection: IMAPNamedConnection, messageInfo: MessageInfo) async -> String? {
fileprivate static func fetchHookMarkdown(using connection: IMAPNamedConnection, messageInfo: MessageInfo) async -> SanitizedText? {
let textPart = messageInfo.parts.first { part in
part.contentType.lowercased().hasPrefix("text/plain")
&& part.disposition?.lowercased() != "attachment"
Expand Down Expand Up @@ -585,7 +599,7 @@ extension PostServer {
)

do {
return try await detail.markdown()
return try await detail.markdownSanitized()
} catch {
let uid = messageInfo.uid?.value ?? 0
Self.logDiagnostic("ERROR failed to convert body to markdown uid=\(uid): \(String(describing: error))")
Expand All @@ -609,7 +623,7 @@ extension PostServer {
}
}

return textBody
return UnicodeAbuseSummary.sanitize(textBody ?? "", field: "Body")
}
}

Expand Down
28 changes: 21 additions & 7 deletions Sources/PostServer/PostServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public actor PostServer {
let date: Date
let subject: String
let markdown: String?
let unicodeAbuse: String?
let flags: [String]
let attachments: [HookAttachmentPayload]
let headers: [String: String]
Expand All @@ -131,6 +132,7 @@ public actor PostServer {
case date
case subject
case markdown
case unicodeAbuse
case flags
case attachments
case headers
Expand All @@ -144,6 +146,7 @@ public actor PostServer {
try container.encode(date, forKey: .date)
try container.encode(subject, forKey: .subject)
try container.encode(markdown ?? "", forKey: .markdown)
try container.encodeIfPresent(unicodeAbuse, forKey: .unicodeAbuse)
try container.encode(flags, forKey: .flags)
try container.encode(attachments, forKey: .attachments)
try container.encode(headers, forKey: .headers)
Expand Down Expand Up @@ -1359,12 +1362,15 @@ public actor PostServer {
}

internal func messageHeader(from message: Message) -> MessageHeader {
MessageHeader(
let sanitizedSubject = UnicodeAbuseSummary.sanitize(message.subject ?? "(No Subject)", field: "Subject")

return MessageHeader(
uid: messageUID(from: message),
from: message.from ?? "Unknown",
subject: message.subject ?? "(No Subject)",
subject: sanitizedSubject.text,
date: formatDate(message.date),
flags: MessageFlags(message.flags)
flags: MessageFlags(message.flags),
unicodeAbuse: sanitizedSubject.unicodeAbuse
)
}

Expand All @@ -1380,20 +1386,28 @@ public actor PostServer {
let referencesString = message.header.references?.map { $0.description }.joined(separator: " ")

let filteredHeaders = Self.filterNoiseHeaders(additionalHeaders ?? message.header.additionalFields ?? [:])
let sanitizedSubject = UnicodeAbuseSummary.sanitize(message.subject ?? "(No Subject)", field: "Subject")
let sanitizedTextBody = message.textBody.map { UnicodeAbuseSummary.sanitize($0, field: "Body") }
let sanitizedHTMLBody = message.htmlBody.map { UnicodeAbuseSummary.sanitize($0, field: "Body") }

return MessageDetail(
uid: messageUID(from: message),
from: message.from ?? "Unknown",
to: message.to,
cc: message.cc.isEmpty ? nil : message.cc,
subject: message.subject ?? "(No Subject)",
subject: sanitizedSubject.text,
date: formatDate(message.date),
textBody: message.textBody,
htmlBody: message.htmlBody,
textBody: sanitizedTextBody?.text,
htmlBody: sanitizedHTMLBody?.text,
attachments: attachments,
additionalHeaders: filteredHeaders.isEmpty ? nil : filteredHeaders,
messageId: messageId,
references: referencesString
references: referencesString,
unicodeAbuse: UnicodeAbuseSummary.combine([
sanitizedSubject.unicodeAbuse,
sanitizedTextBody?.unicodeAbuse,
sanitizedHTMLBody?.unicodeAbuse
])
)
}

Expand Down
Loading
Loading