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
17 changes: 17 additions & 0 deletions Feature/Sources/IMAP/IMAPError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// ``IMAPClient`` throws `IMAPError`.
public enum IMAPError: Error, CustomStringConvertible, Equatable {
case underlying(String)
case example

init(_ error: Error) {
self = error as? Self ?? .underlying(error.localizedDescription)
}

// MARK: CustomStringConvertible
public var description: String {
switch self {
case .underlying(let string): "Underlying: \(string)"
case .example: "Example"
}
}
}
21 changes: 8 additions & 13 deletions Feature/Sources/IMAP/LoggingHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,20 @@ final class LoggingHandler: ChannelDuplexHandler, @unchecked Sendable {
typealias OutboundIn = ByteBuffer

func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
logger?.debug("\(self.unwrapOutboundIn(data).readableLogView(redactBase64Encoded: true))")
context.write(data, promise: promise)
logger?.debug("\(self.unwrapOutboundIn(data).stringValue)")
context.write(data, promise: promise) // Handler only observes; pass unmodified data to next handler
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
logger?.debug("\(self.unwrapInboundIn(data).readableLogView())")
context.fireChannelRead(data)
logger?.debug("\(self.unwrapInboundIn(data).stringValue)")
context.fireChannelRead(data) // Pass unmodified data to next handler
}
}

extension ByteBuffer {
// Format bytes as debug description; optionally redact user names and passwords
func readableLogView(redactBase64Encoded: Bool = false) -> String {
private extension ByteBuffer {
var stringValue: String {
let string: String = String(decoding: readableBytesView, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
guard let data: Data = Data(base64Encoded: string),
let string: String = String(data: data, encoding: .utf8)
else {
return string // String not base64-encoded; return as is
}
return redactBase64Encoded ? "[redacted]" : string
// TODO: Redact IMAP credential
return string
}
}
75 changes: 36 additions & 39 deletions Feature/Sources/IMAP/Message.swift
Original file line number Diff line number Diff line change
@@ -1,48 +1,45 @@
import EmailAddress
import Foundation
import MIME
import NIOIMAP

/// [IMAP message attributes](https://www.ietf.org/rfc/rfc9051.html#section-2.3)
public struct Message {
public enum Flag: String, CaseIterable, CustomStringConvertible, Sendable {
case seen = "\\Seen"
case answered = "\\Answered"
case flagged = "\\Flagged"
case deleted = "\\Deleted"
case draft = "\\Draft"
case forwarded = "$Forwarded"
case mdnSent = "$MDNSent"
case junk = "$Junk"
case notJunk = "$NotJunk"
case phishing = "$Phishing"
public typealias Flag = NIOIMAPCore.Flag

// MARK: CustomStringConvertible
public var description: String { rawValue }
}

public let isDeleted: Bool
public let folderID: Int
public let uid: String
public let subject: String
public let date: Date
public let body: Body
public let contentType: ContentType
public let flags: [Flag]
public let senderList: String
public let toList: String
public let ccList: String
public let bccList: String
public let replyToList: String
public let attachmentCount: Int
public let internalDate: Date
public let folderID: Int
public let inReplyTo: String?
public let messageID: String
public let previewType: String
public let preview: String
public let mimeType: String
public let normalizedSubjectHash: Int
public let isEmpty: Bool
public let isRead: Bool
public let isFlagged: Bool
public let isAnswered: Bool
public let isForwarded: Bool
public let messagePartID: Int
public let encryptionType: String
public let isNewMessage: Bool
public let replyTo: String?
public let size: Int
public let subject: String
public let uid: UID

public var isAnswered: Bool { flags.isAnswered }
public var isDeleted: Bool { flags.isDeleted }
public var isDraft: Bool { flags.isDraft }
public var isEmpty: Bool { body.isEmpty }
public var isFlagged: Bool { flags.isFlagged }
public var isForwarded: Bool { flags.isForwarded }
public var isSeen: Bool { flags.isSeen }
}

extension Message.Flag: @retroactive CustomStringConvertible {

// MARK: CustomStringConvertible
public var description: String { debugDescription }
}

public typealias UID = NIOIMAP.UID

private extension [Message.Flag] {
var isAnswered: Bool { contains(.answered) }
var isDeleted: Bool { contains(.deleted) }
var isDraft: Bool { contains(.draft) }
var isFlagged: Bool { contains(.flagged) }
var isForwarded: Bool { contains(.keyword(.forwarded)) }
var isSeen: Bool { contains(.seen) }
}
4 changes: 2 additions & 2 deletions Feature/Sources/IMAP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ public struct Server: Equatable, Sendable {
public let port: Int

public init(
_ connectionSecurity: ConnectionSecurity = .startTLS,
_ connectionSecurity: ConnectionSecurity = .tls,
hostname: String,
username: String,
password: String,
port: Int = 143 // 993
port: Int = 993
) {
self.connectionSecurity = connectionSecurity
self.hostname = hostname
Expand Down
15 changes: 14 additions & 1 deletion Feature/Sources/MIME/Body.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Multipart body element described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.6)
public struct Body: CustomStringConvertible, RawRepresentable {
public struct Body: CustomStringConvertible, RawRepresentable, Sendable {
public let contentTransferEncoding: ContentTransferEncoding?
public let contentType: ContentType // Body encoding is always ASCII
public let parts: [Part]
Expand Down Expand Up @@ -85,3 +85,16 @@ public struct Body: CustomStringConvertible, RawRepresentable {
try? self.init(rawValue)
}
}

extension Body {
public static var empty: Self {
try! Self(
parts: [
try! Part(data: "".data(using: .ascii)!, contentType: .text(.plain, .ascii))
], contentType: .text(.plain, .ascii))
}

public var isEmpty: Bool {
parts.count == 1 && (String(data: parts[0].data, encoding: .ascii) ?? "").isEmpty
}
}
2 changes: 0 additions & 2 deletions Feature/Sources/MIME/CharacterSet.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import Foundation

/// Character encoding described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.2)
public struct CharacterSet: CustomStringConvertible, Equatable, RawRepresentable, Sendable {
public static var ascii: Self { try! Self("US-ASCII") } // Default character encoding
Expand Down
2 changes: 1 addition & 1 deletion Feature/Sources/MIME/ContentTransferEncoding.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Multipart email body must be ASCII in transfer. Parts encoded by this library use `base64` transfer encoding exclusively, but we expect to decode parts in any of the enumerated encodings, especially `quotedPrintable`.
public enum ContentTransferEncoding: String, CaseIterable, CustomStringConvertible, RawRepresentable {
public enum ContentTransferEncoding: String, CaseIterable, CustomStringConvertible, RawRepresentable, Sendable {
case ascii = "7bit"
case base64
case binary
Expand Down
2 changes: 1 addition & 1 deletion Feature/Sources/MIME/Part.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// MIME part described in [RFC 2045](https://www.rfc-editor.org/rfc/rfc2045#section-2.5)
public struct Part: CustomStringConvertible, RawRepresentable {
public struct Part: CustomStringConvertible, RawRepresentable, Sendable {

/// Instruct mail client to display decoded body part inline, in message, or link as an attachment. Optionally include file name and other metadata for source file.
public let contentDisposition: ContentDisposition?
Expand Down
4 changes: 2 additions & 2 deletions Feature/Sources/MIME/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ extension String {
}
}

public var crlf: String { .crlf }

extension [Character] {
static var quotes: Self { ["\"", "'"] }
}

var crlf: String { .crlf }
2 changes: 1 addition & 1 deletion Feature/Sources/MIME/UUID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ extension UUID {
try! Boundary("\(prefix?.ascii ?? "")\(uuidString(segments, separator: separator))\(suffix?.ascii ?? "")")
}

func uuidString(_ segments: Int, separator: String = .separator) -> String {
public func uuidString(_ segments: Int, separator: String = .separator) -> String {
uuidString
.components(separatedBy: String.separator)
.prefix(max(segments, 1))
Expand Down
38 changes: 4 additions & 34 deletions Feature/Sources/SMTP/Email.swift
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
import EmailAddress
import Foundation
import MIME
import UniformTypeIdentifiers

/// ``SMTPClient`` sends `Email`.
public struct Email: Identifiable, Sendable {
public struct Part {
public let data: Data
public let contentType: String
}

public let sender: EmailAddress
public let recipients: [EmailAddress]
public let copied: [EmailAddress]
public let blindCopied: [EmailAddress]
public let subject: String
public let date: Date
public let parts: [Data]

public var body: Data {
var data: Data = Data()
for part in parts {
data.append(line.data(using: .utf8)!)
data.append(part)
}
data.append(line.data(using: .utf8)!)
return data
}
public let body: Body
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multipart logic moved into MIME library


public var contentType: String { "text/plain; charset=\"UTF-8\"" }
public var contentType: MIME.ContentType { body.contentType }

public var messageID: String {
// Format: https://www.jwz.org/doc/mid.html
Expand All @@ -46,7 +30,7 @@ public struct Email: Identifiable, Sendable {
blindCopied: [EmailAddress] = [],
subject: String = "",
date: Date = Date(),
body parts: [Data] = [],
body: Body = .empty,
id: UUID = UUID()
) {
self.sender = sender
Expand All @@ -55,26 +39,12 @@ public struct Email: Identifiable, Sendable {
self.blindCopied = blindCopied
self.subject = subject
self.date = date
self.parts = parts
self.body = body
self.id = id
}

var iso8601Date: String { ISO8601DateFormatter().string(from: date) }
var allRecipients: [EmailAddress] { recipients + copied + blindCopied }
var dataBoundary: String { "\(id.uuidString(4))_part" }

// MARK: Identifiable
public let id: UUID
}

extension UUID {
func uuidString(_ segments: Int) -> String {
uuidString.components(separatedBy: "-").prefix(max(segments, 0)).joined(separator: "-")
}
}

extension String {
static var line: Self { "\r\n" }
}

var line: String { .line }
20 changes: 12 additions & 8 deletions Feature/Sources/SMTP/LoggingHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,29 @@ final class LoggingHandler: ChannelDuplexHandler, @unchecked Sendable {
typealias OutboundIn = ByteBuffer

func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
logger?.debug("\(self.unwrapOutboundIn(data).readableLogView(redactBase64Encoded: true))")
context.write(data, promise: promise)
logger?.debug("\(self.unwrapOutboundIn(data).stringValue)")
context.write(data, promise: promise) // Handler only observes; pass unmodified data to next handler
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
logger?.debug("\(self.unwrapInboundIn(data).readableLogView())")
context.fireChannelRead(data)
logger?.debug("\(self.unwrapInboundIn(data).stringValue)")
context.fireChannelRead(data) // Pass unmodified data to next handler
}
}

extension ByteBuffer {
// Format bytes as debug description; optionally redact user names and passwords
func readableLogView(redactBase64Encoded: Bool = false) -> String {
var stringValue: String {
let string: String = String(decoding: readableBytesView, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
// Deocde base64-encoded credentials
guard let data: Data = Data(base64Encoded: string),
let string: String = String(data: data, encoding: .utf8)
else {
return string // String not base64-encoded; return as is
return string
}
return redactBase64Encoded ? "[redacted]" : string
#if DEBUG
return string
#else
return String(repeating: "•", count: string.count) // Redact decoded SMTP credential
#endif
}
}
19 changes: 9 additions & 10 deletions Feature/Sources/SMTP/RequestEncoder.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import EmailAddress
import Foundation
import MIME
import NIOCore

enum Request {
Expand Down Expand Up @@ -37,18 +38,16 @@ struct RequestEncoder: MessageToByteEncoder {
case .data:
out.writeString("DATA")
case .transferData(let email):
out.writeString("From: \(email.sender)\(line)")
out.writeString("To: \(email.recipients.map { $0.description }.joined(separator: " "))\(line)")
out.writeString("Date: \(email.iso8601Date)\(line)") // "EEE, dd MMM yyyy HH:mm:ss Z"
out.writeString("Message-ID: \(email.messageID)\(line)")
out.writeString("Subject: \(email.subject)\(line)")
// out.writeString("MIME-Version: 1.0\(line)")
out.writeString("Content-Type: \(email.contentType)\(line)")
out.writeBytes(email.body)
out.writeString("\(line).")
out.writeString("From: \(email.sender)\(crlf)")
out.writeString("To: \(email.recipients.map { $0.description }.joined(separator: " "))\(crlf)")
out.writeString("Date: \(email.date.rfc822Format())\(crlf)")
out.writeString("Message-ID: \(email.messageID)\(crlf)")
out.writeString("Subject: \(email.subject)\(crlf)")
out.writeBytes(email.body.rawValue)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multipart body and headers already natively data now

out.writeString("\(crlf).")
case .quit:
out.writeString("QUIT")
}
out.writeString(line)
out.writeString(crlf)
}
}
1 change: 0 additions & 1 deletion Feature/Sources/SMTP/ResponseHandler.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Foundation
import NIOCore

enum Response {
Expand Down
2 changes: 1 addition & 1 deletion Feature/Sources/SMTP/SMTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public struct SMTPClient {

public init(
_ server: Server,
logger: Logger? = Logger(subsystem: "net.thunderbird.ios", category: "SMTP")
logger: Logger? = Logger(subsystem: "net.thunderbird", category: "SMTP")
) {
group = NIOTSEventLoopGroup(loopCount: 1, defaultQoS: .utility)
self.server = server
Expand Down
Loading