From cea1ecca04ebde9a6c7fcda48376a3d0967e4943 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Fri, 7 Mar 2025 11:34:13 +0100 Subject: [PATCH] Linux building Related to #3 Let's test if that actually works --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/Cocoanetics/SwiftMail/issues/3?shareId=XXXX-XXXX-XXXX-XXXX). --- .github/workflows/swift.yml | 20 + Demos/SwiftIMAPCLI/OSLogHandler.swift | 3 + Demos/SwiftIMAPCLI/main.swift | 129 +++--- Demos/SwiftSMTPCLI/OSLogHandler.swift | 3 + Demos/SwiftSMTPCLI/main.swift | 25 +- .../Extensions/StringExtensions.swift | 413 +++++++++--------- Sources/SwiftSMTP/SMTPServer.swift | 44 +- 7 files changed, 336 insertions(+), 301 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 778981b..f2c10f1 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -26,3 +26,23 @@ jobs: - name: Run tests run: swift test -v + + build-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Swift 6.0 + uses: swift-actions/setup-swift@v2.2.0 + with: + swift-version: "6.0" + + - name: Verify Swift version + run: swift --version + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v diff --git a/Demos/SwiftIMAPCLI/OSLogHandler.swift b/Demos/SwiftIMAPCLI/OSLogHandler.swift index 5de0378..5c15022 100644 --- a/Demos/SwiftIMAPCLI/OSLogHandler.swift +++ b/Demos/SwiftIMAPCLI/OSLogHandler.swift @@ -6,6 +6,8 @@ // import Foundation + +#if canImport(OSLog) import OSLog import Logging @@ -57,3 +59,4 @@ struct OSLogHandler: LogHandler { os_log("%{public}@", log: log, type: type, message.description) } } +#endif diff --git a/Demos/SwiftIMAPCLI/main.swift b/Demos/SwiftIMAPCLI/main.swift index 35303ba..70f6618 100644 --- a/Demos/SwiftIMAPCLI/main.swift +++ b/Demos/SwiftIMAPCLI/main.swift @@ -1,42 +1,43 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - import Foundation import SwiftIMAP -import os import Logging import SwiftDotenv import NIOIMAP -// Set default log level to info - will only show important logs -// Per the cursor rules: Use OS_LOG_DISABLE=1 to see log output as needed +#if canImport(os) +import os +#endif + +#if os(Linux) +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if ProcessInfo.processInfo.environment["ENABLE_DEBUG_OUTPUT"] == "1" { + handler.logLevel = .trace + } else { + handler.logLevel = .info + } + return handler +} +#else LoggingSystem.bootstrap { label in - // Create an OSLog-based logger let category = label.split(separator: ".").last?.description ?? "default" let osLogger = OSLog(subsystem: "com.cocoanetics.SwiftIMAPCLI", category: category) - - // Set log level to info by default (or trace if verbose logging is enabled) var handler = OSLogHandler(label: label, log: osLogger) - // Check if we need verbose logging if ProcessInfo.processInfo.environment["ENABLE_DEBUG_OUTPUT"] == "1" { handler.logLevel = .trace } else { handler.logLevel = .info } - return handler } +#endif -// Create a logger for the main application using Swift Logging let logger = Logger(label: "com.cocoanetics.SwiftIMAPCLI.Main") print("šŸ“§ SwiftIMAPCLI - Email Reading Test") do { - // Configure SwiftDotenv with the specified path print("šŸ” Looking for .env file...") - - // Try loading the .env file do { try Dotenv.configure() print("āœ… Environment configuration loaded successfully") @@ -44,85 +45,77 @@ do { print("āŒ Failed to load .env file: \(error.localizedDescription)") exit(1) } - - // Print the loaded variables to verify + print("šŸ“‹ Loaded environment variables:") - - // Access IMAP credentials using dynamic member lookup with case pattern matching guard case let .string(host) = Dotenv["IMAP_HOST"] else { print("āŒ IMAP_HOST not found in .env file") logger.error("IMAP_HOST not found in .env file") exit(1) } - + print(" IMAP_HOST: \(host)") - + guard case let .integer(port) = Dotenv["IMAP_PORT"] else { print("āŒ IMAP_PORT not found or invalid in .env file") logger.error("IMAP_PORT not found or invalid in .env file") exit(1) } - + print(" IMAP_PORT: \(port)") - + guard case let .string(username) = Dotenv["IMAP_USERNAME"] else { print("āŒ IMAP_USERNAME not found in .env file") logger.error("IMAP_USERNAME not found in .env file") exit(1) } - + print(" IMAP_USERNAME: \(username)") - + guard case let .string(password) = Dotenv["IMAP_PASSWORD"] else { logger.error("IMAP_PASSWORD not found in .env file") exit(1) } - + logger.info("IMAP credentials loaded successfully") logger.info("Host: \(host)") logger.info("Port: \(port)") logger.info("Username: \(username)") - - // Create an IMAP server instance + let server = IMAPServer(host: host, port: port) - - do { - try await server.connect() - try await server.login(username: username, password: password) - - // List special folders - let specialFolders = try await server.listSpecialUseMailboxes() - - // Display special folders - print("\nSpecial Folders:") - for folder in specialFolders { - print("- \(folder.name)") - } - - guard let inbox = specialFolders.inbox else { - fatalError("INBOX mailbox not found") - } - - // Select the INBOX mailbox and get mailbox information - let mailboxStatus = try await server.selectMailbox(inbox.name) - - // Use the convenience method to get the latest 10 messages - if let latestMessagesSet = mailboxStatus.latest(10) { - let emails = try await server.fetchMessages(using: latestMessagesSet) - - print("\nšŸ“§ Latest Emails (\(emails.count)) šŸ“§") - - for (index, email) in emails.enumerated() { - print("\n[\(index + 1)/\(emails.count)] \(email.debugDescription)") - print("---") - } - } else { - print("No messages found in INBOX") - } - - try await server.disconnect() - } catch { - logger.error("Error: \(error.localizedDescription)") - exit(1) - } + + do { + try await server.connect() + try await server.login(username: username, password: password) + + let specialFolders = try await server.listSpecialUseMailboxes() + + print("\nSpecial Folders:") + for folder in specialFolders { + print("- \(folder.name)") + } + + guard let inbox = specialFolders.inbox else { + fatalError("INBOX mailbox not found") + } + + let mailboxStatus = try await server.selectMailbox(inbox.name) + + if let latestMessagesSet = mailboxStatus.latest(10) { + let emails = try await server.fetchMessages(using: latestMessagesSet) + + print("\nšŸ“§ Latest Emails (\(emails.count)) šŸ“§") + + for (index, email) in emails.enumerated() { + print("\n[\(index + 1)/\(emails.count)] \(email.debugDescription)") + print("---") + } + } else { + print("No messages found in INBOX") + } + + try await server.disconnect() + } catch { + logger.error("Error: \(error.localizedDescription)") + exit(1) + } } diff --git a/Demos/SwiftSMTPCLI/OSLogHandler.swift b/Demos/SwiftSMTPCLI/OSLogHandler.swift index 5de0378..5c15022 100644 --- a/Demos/SwiftSMTPCLI/OSLogHandler.swift +++ b/Demos/SwiftSMTPCLI/OSLogHandler.swift @@ -6,6 +6,8 @@ // import Foundation + +#if canImport(OSLog) import OSLog import Logging @@ -57,3 +59,4 @@ struct OSLogHandler: LogHandler { os_log("%{public}@", log: log, type: type, message.description) } } +#endif diff --git a/Demos/SwiftSMTPCLI/main.swift b/Demos/SwiftSMTPCLI/main.swift index 03cc7d5..fd6f8ab 100644 --- a/Demos/SwiftSMTPCLI/main.swift +++ b/Demos/SwiftSMTPCLI/main.swift @@ -1,13 +1,24 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - import Foundation import SwiftSMTP -import OSLog import Logging import SwiftDotenv import SwiftMailCore +#if canImport(OSLog) +import OSLog +#endif + +#if os(Linux) +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if ProcessInfo.processInfo.environment["ENABLE_DEBUG_OUTPUT"] == "1" { + handler.logLevel = .trace + } else { + handler.logLevel = .info + } + return handler +} +#else // Set default log level to info - will only show important logs // Per the cursor rules: Use OS_LOG_DISABLE=1 to see log output as needed LoggingSystem.bootstrap { label in @@ -18,7 +29,7 @@ LoggingSystem.bootstrap { label in // Set log level to info by default (or trace if SWIFT_LOG_LEVEL is set to trace) var handler = OSLogHandler(label: label, log: osLogger) - // Check if we need verbose logging + // Check if we need verbose logging if ProcessInfo.processInfo.environment["ENABLE_DEBUG_OUTPUT"] == "1" { handler.logLevel = .trace } else { @@ -27,6 +38,7 @@ LoggingSystem.bootstrap { label in return handler } +#endif // Create a logger for the main application using Swift Logging let logger = Logger(label: "com.cocoanetics.SwiftSMTPCLI.Main") @@ -74,7 +86,7 @@ do { // Login with credentials print("Authenticating...") - let authSuccess = try await server.authenticate(username: username, password: password) + let authSuccess = try await server.authenticate(username: username, password: password) if authSuccess { logger.info("Authentication successful") @@ -114,4 +126,3 @@ do { logger.error("Error: \(error.localizedDescription)") exit(1) } - diff --git a/Sources/SwiftMailCore/Extensions/StringExtensions.swift b/Sources/SwiftMailCore/Extensions/StringExtensions.swift index 740c973..a7a63aa 100644 --- a/Sources/SwiftMailCore/Extensions/StringExtensions.swift +++ b/Sources/SwiftMailCore/Extensions/StringExtensions.swift @@ -1,208 +1,217 @@ -// StringExtensions.swift -// String extensions for the SwiftMailCore library - import Foundation + +#if canImport(Darwin) import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + import UniformTypeIdentifiers extension String { - /// Redacts sensitive information that appears after the specified keyword - /// For example, "A002 LOGIN username password" becomes "A002 LOGIN [credentials redacted]" - /// or "AUTH PLAIN base64data" becomes "AUTH [credentials redacted]" - /// - Parameter keyword: The keyword to look for (e.g., "LOGIN" or "AUTH") - /// - Returns: The redacted string, or the original string if no redaction was needed - public func redactAfter(_ keyword: String) -> String { - // Create a regex pattern that matches IMAP commands in both formats: - // 1. With a tag: tag + command (e.g., "A001 LOGIN") - // 2. Without a tag: just the command (e.g., "AUTH PLAIN") - let pattern = "(^\\s*\\w+\\s+\(keyword)\\b|^\\s*\(keyword)\\b)" - - do { - let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) - let range = NSRange(location: 0, length: self.utf16.count) - - // If we find a match, proceed with redaction - if let match = regex.firstMatch(in: self, options: [], range: range) { - // Convert the NSRange back to a String.Index range - guard let keywordRange = Range(match.range, in: self) else { - return self - } - - // Find the end of the keyword/command - let keywordEnd = keywordRange.upperBound - - // Check if there's content after the keyword/command - guard keywordEnd < self.endIndex else { - // If the keyword is at the end, return the original string - return self - } - - // Create the redacted string: preserve everything up to the keyword/command (inclusive) - let preservedPart = self[..? - - guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { - return nil - } - - defer { - freeifaddrs(ifaddr) - } - - // Iterate through linked list of interfaces - var currentAddr: UnsafeMutablePointer? = firstAddr - var foundAddress: String? = nil - - while let addr = currentAddr { - let interface = addr.pointee - - // Check for IPv4 or IPv6 interface - let addrFamily = interface.ifa_addr.pointee.sa_family - if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) { - // Check interface name starts with "en" (Ethernet) or "wl" (WiFi) - let name = String(cString: interface.ifa_name) - if name.hasPrefix("en") || name.hasPrefix("wl") { - // Convert interface address to a human readable string - var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - - // Get address info - getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len), - &hostname, socklen_t(hostname.count), - nil, socklen_t(0), NI_NUMERICHOST) - - if let address = String(validatingUTF8: hostname) { - foundAddress = address - break - } - } - } - - // Move to next interface - currentAddr = interface.ifa_next - } - - return foundAddress - } - - /// Sanitize a filename to ensure it's valid - /// - Returns: A sanitized filename - func sanitizedFileName() -> String { - let invalidCharacters = CharacterSet(charactersIn: ":/\\?%*|\"<>") - return self - .components(separatedBy: invalidCharacters) - .joined(separator: "_") - .replacingOccurrences(of: " ", with: "_") - } - - /// Get a file extension for a given MIME type - /// - Parameter mimeType: The full MIME type (e.g., "text/plain", "image/jpeg") - /// - Returns: An appropriate file extension (without the dot) - public static func fileExtension(for mimeType: String) -> String? { - // Try to get the UTType from the MIME type - if let utType = UTType(mimeType: mimeType) { - // Get the preferred file extension - if let preferredExtension = utType.preferredFilenameExtension { - return preferredExtension - } - } - - return nil - } - - // Helper function to get MIME type from file URL using UTI - public static func mimeType(for fileExtension: String) -> String { - // First try to get UTType from file extension - - if let utType = UTType(filenameExtension: fileExtension) { - // If we have a UTType, try to get its MIME type - if let mimeType = utType.preferredMIMEType { - return mimeType - } - } - - - // Fallback to common extensions if UTI doesn't work - let pathExtension = fileExtension.lowercased() - switch pathExtension { - case "jpg", "jpeg": - return "image/jpeg" - case "png": - return "image/png" - case "gif": - return "image/gif" - case "svg": - return "image/svg+xml" - case "pdf": - return "application/pdf" - case "txt": - return "text/plain" - case "html", "htm": - return "text/html" - case "doc", "docx": - return "application/msword" - case "xls", "xlsx": - return "application/vnd.ms-excel" - case "zip": - return "application/zip" - default: - return "application/octet-stream" - } - } + /// Redacts sensitive information that appears after the specified keyword + /// For example, "A002 LOGIN username password" becomes "A002 LOGIN [credentials redacted]" + /// or "AUTH PLAIN base64data" becomes "AUTH [credentials redacted]" + /// - Parameter keyword: The keyword to look for (e.g., "LOGIN" or "AUTH") + /// - Returns: The redacted string, or the original string if no redaction was needed + public func redactAfter(_ keyword: String) -> String { + // Create a regex pattern that matches IMAP commands in both formats: + // 1. With a tag: tag + command (e.g., "A001 LOGIN") + // 2. Without a tag: just the command (e.g., "AUTH PLAIN") + let pattern = "(^\\s*\\w+\\s+\(keyword)\\b|^\\s*\(keyword)\\b)" + + do { + let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + let range = NSRange(location: 0, length: self.utf16.count) + + // If we find a match, proceed with redaction + if let match = regex.firstMatch(in: self, options: [], range: range) { + // Convert the NSRange back to a String.Index range + guard let keywordRange = Range(match.range, in: self) else { + return self + } + + // Find the end of the keyword/command + let keywordEnd = keywordRange.upperBound + + // Check if there's content after the keyword/command + guard keywordEnd < self.endIndex else { + // If the keyword is at the end, return the original string + return self + } + + // Create the redacted string: preserve everything up to the keyword/command (inclusive) + let preservedPart = self[..? + + guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { + return nil + } + + defer { + freeifaddrs(ifaddr) + } + + // Iterate through linked list of interfaces + var currentAddr: UnsafeMutablePointer? = firstAddr + var foundAddress: String? = nil + + while let addr = currentAddr { + let interface = addr.pointee + + // Check for IPv4 or IPv6 interface + let addrFamily = interface.ifa_addr.pointee.sa_family + if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) { + // Check interface name starts with "en" (Ethernet) or "wl" (WiFi) + let name = String(cString: interface.ifa_name) + if name.hasPrefix("en") || name.hasPrefix("wl") { + // Convert interface address to a human readable string + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + // Get address info + getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len), + &hostname, socklen_t(hostname.count), + nil, socklen_t(0), NI_NUMERICHOST) + + if let address = String(validatingUTF8: hostname) { + foundAddress = address + break + } + } + } + + // Move to next interface + currentAddr = interface.ifa_next + } + + return foundAddress + } + + /// Sanitize a filename to ensure it's valid + /// - Returns: A sanitized filename + func sanitizedFileName() -> String { + let invalidCharacters = CharacterSet(charactersIn: ":/\\?%*|\"<>") + return self + .components(separatedBy: invalidCharacters) + .joined(separator: "_") + .replacingOccurrences(of: " ", with: "_") + } + + /// Get a file extension for a given MIME type + /// - Parameter mimeType: The full MIME type (e.g., "text/plain", "image/jpeg") + /// - Returns: An appropriate file extension (without the dot) + public static func fileExtension(for mimeType: String) -> String? { + // Try to get the UTType from the MIME type + if let utType = UTType(mimeType: mimeType) { + // Get the preferred file extension + if let preferredExtension = utType.preferredFilenameExtension { + return preferredExtension + } + } + + return nil + } + + // Helper function to get MIME type from file URL using UTI + public static func mimeType(for fileExtension: String) -> String { + // First try to get UTType from file extension + + if let utType = UTType(filenameExtension: fileExtension) { + // If we have a UTType, try to get its MIME type + if let mimeType = utType.preferredMIMEType { + return mimeType + } + } + + + // Fallback to common extensions if UTI doesn't work + let pathExtension = fileExtension.lowercased() + switch pathExtension { + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "svg": + return "image/svg+xml" + case "pdf": + return "application/pdf" + case "txt": + return "text/plain" + case "html", "htm": + return "text/html" + case "doc", "docx": + return "application/msword" + case "xls", "xlsx": + return "application/vnd.ms-excel" + case "zip": + return "application/zip" + default: + return "application/octet-stream" + } + } } diff --git a/Sources/SwiftSMTP/SMTPServer.swift b/Sources/SwiftSMTP/SMTPServer.swift index b6b4b20..0eb0202 100644 --- a/Sources/SwiftSMTP/SMTPServer.swift +++ b/Sources/SwiftSMTP/SMTPServer.swift @@ -1,8 +1,4 @@ -// SMTPServer.swift -// A Swift SMTP client that encapsulates connection logic - import Foundation -import os.log import NIO import NIOCore import NIOSSL @@ -40,8 +36,8 @@ public actor SMTPServer { */ private let logger = Logger(label: "com.cocoanetics.SwiftMail.SMTPServer") - // A logger on the channel that watches both directions - private let duplexLogger: SMTPLogger + // A logger on the channel that watches both directions + private let duplexLogger: SMTPLogger // MARK: - Initialization @@ -56,11 +52,11 @@ public actor SMTPServer { self.host = host self.port = port self.group = MultiThreadedEventLoopGroup(numberOfThreads: numberOfThreads) - - let outboundLogger = Logger(label: "com.cocoanetics.SwiftMail.SMTP_OUT") - let inboundLogger = Logger(label: "com.cocoanetics.SwiftMail.SMTP_IN") + + let outboundLogger = Logger(label: "com.cocoanetics.SwiftMail.SMTP_OUT") + let inboundLogger = Logger(label: "com.cocoanetics.SwiftMail.SMTP_IN") - self.duplexLogger = SMTPLogger(outboundLogger: outboundLogger, inboundLogger: inboundLogger) + self.duplexLogger = SMTPLogger(outboundLogger: outboundLogger, inboundLogger: inboundLogger) } deinit { @@ -201,9 +197,9 @@ public actor SMTPServer { } // Create a timeout for the command - let timeoutSeconds = command.timeoutSeconds - - let scheduledTask = group.next().scheduleTask(in: .seconds(Int64(timeoutSeconds))) { + let timeoutSeconds = command.timeoutSeconds + + let scheduledTask = group.next().scheduleTask(in: .seconds(Int64(timeoutSeconds))) { resultPromise.fail(SMTPError.connectionFailed("Response timeout")) } @@ -220,9 +216,9 @@ public actor SMTPServer { // Cancel the timeout scheduledTask.cancel() - - // Flush the DuplexLogger's buffer after command execution - duplexLogger.flushInboundBuffer() + + // Flush the DuplexLogger's buffer after command execution + duplexLogger.flushInboundBuffer() return result } catch { @@ -284,11 +280,11 @@ public actor SMTPServer { return } - // Use QuitCommand instead of directly sending a string - let quitCommand = QuitCommand() - - // Execute the QUIT command - it has its own timeout set to 10 seconds - try await executeCommand(quitCommand) + // Use QuitCommand instead of directly sending a string + let quitCommand = QuitCommand() + + // Execute the QUIT command - it has its own timeout set to 10 seconds + try await executeCommand(quitCommand) // Close the channel regardless of QUIT command result channel.close(promise: nil) @@ -724,10 +720,10 @@ public actor SMTPServer { // Extract and add each individual auth method let authMethods = capabilityPart.dropFirst(5).split(separator: " ") - for method in authMethods { + for method in authMethods { let authMethod = "AUTH \(method)" parsedCapabilities.append(authMethod) - } + } } else { // For other capabilities, add them as-is parsedCapabilities.append(capabilityPart) @@ -794,4 +790,4 @@ public actor SMTPServer { return result } } -} +}