176 lines
10 KiB
Swift
176 lines
10 KiB
Swift
import Foundation
|
|
|
|
/// MIME decoding keeps arbitrary attachment bytes separate from decoded text.
|
|
enum MIME {
|
|
static func headers(_ text: String) -> [String: String] {
|
|
let unfolded = text.replacingOccurrences(of: "\r\n", with: "\n")
|
|
.replacingOccurrences(of: "\n[ \t]+", with: " ", options: .regularExpression)
|
|
var result: [String: String] = [:]
|
|
for line in unfolded.components(separatedBy: "\n") {
|
|
guard let colon = line.firstIndex(of: ":") else { continue }
|
|
result[String(line[..<colon]).lowercased()] = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func split(_ data: Data) -> ([String: String], Data) {
|
|
for separator in [Data("\r\n\r\n".utf8), Data("\n\n".utf8)] {
|
|
if let range = data.range(of: separator) {
|
|
return (headers(String(decoding: data[..<range.lowerBound], as: UTF8.self)), Data(data[range.upperBound...]))
|
|
}
|
|
}
|
|
return ([:], data)
|
|
}
|
|
|
|
static func parameter(_ name: String, in value: String) -> String? {
|
|
let pattern = "(?:^|;)\\s*" + NSRegularExpression.escapedPattern(for: name) + "=\\s*(?:\"([^\"]*)\"|([^;\\s]+))"
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
|
let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) else { return nil }
|
|
for i in 1...2 {
|
|
if let range = Range(match.range(at: i), in: value) { return String(value[range]) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func quotedPrintable(_ data: Data, header: Bool = false) -> Data {
|
|
let bytes = Array(data)
|
|
var output = Data()
|
|
var i = 0
|
|
while i < bytes.count {
|
|
if bytes[i] == 61 {
|
|
if i + 2 < bytes.count, bytes[i + 1] == 13, bytes[i + 2] == 10 { i += 3; continue }
|
|
if i + 1 < bytes.count, bytes[i + 1] == 10 { i += 2; continue }
|
|
if i + 2 < bytes.count, let value = UInt8(String(decoding: bytes[(i + 1)...(i + 2)], as: UTF8.self), radix: 16) {
|
|
output.append(value); i += 3; continue
|
|
}
|
|
}
|
|
output.append(header && bytes[i] == 95 ? 32 : bytes[i])
|
|
i += 1
|
|
}
|
|
return output
|
|
}
|
|
|
|
static func text(_ data: Data, charset: String?) -> String {
|
|
let encoding: String.Encoding = switch charset?.lowercased() {
|
|
case "iso-8859-1", "latin1": .isoLatin1
|
|
case "windows-1252": .windowsCP1252
|
|
case "us-ascii": .ascii
|
|
default: .utf8
|
|
}
|
|
return String(data: data, encoding: encoding) ?? String(data: data, encoding: .isoLatin1) ?? ""
|
|
}
|
|
|
|
static func decodeHeader(_ value: String) -> String {
|
|
guard let regex = try? NSRegularExpression(pattern: "=\\?([^?]+)\\?([bBqQ])\\?([^?]*)\\?=") else { return value }
|
|
// Whitespace between adjacent encoded words is folding, not displayed content.
|
|
let source = value.replacingOccurrences(of: "(?<=\\?=)\\s+(?==\\?)", with: "", options: .regularExpression)
|
|
var result = source
|
|
for match in regex.matches(in: source, range: NSRange(source.startIndex..., in: source)).reversed() {
|
|
guard let whole = Range(match.range, in: result),
|
|
let cs = Range(match.range(at: 1), in: source),
|
|
let enc = Range(match.range(at: 2), in: source),
|
|
let content = Range(match.range(at: 3), in: source) else { continue }
|
|
let raw = String(source[content])
|
|
let bytes = source[enc].lowercased() == "b" ? Data(base64Encoded: raw) : quotedPrintable(Data(raw.utf8), header: true)
|
|
if let bytes { result.replaceSubrange(whole, with: text(bytes, charset: String(source[cs]))) }
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func parse(_ data: Data, id: String) -> MailMessage {
|
|
let (fields, _) = split(data)
|
|
var bodies: [String] = []
|
|
var htmlBodies: [String] = []
|
|
var attachments: [MailAttachment] = []
|
|
parsePart(data, depth: 0, bodies: &bodies, htmlBodies: &htmlBodies, attachments: &attachments)
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
var date: Date?
|
|
for format in ["EEE, d MMM yyyy HH:mm:ss Z", "d MMM yyyy HH:mm:ss Z", "EEE, d MMM yyyy HH:mm Z"] {
|
|
dateFormatter.dateFormat = format
|
|
date = dateFormatter.date(from: fields["date"] ?? "")
|
|
if date != nil { break }
|
|
}
|
|
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
|
|
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
|
|
date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"),
|
|
attachments: attachments)
|
|
}
|
|
|
|
private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) {
|
|
guard depth < 30 else { return }
|
|
let (fields, body) = split(data)
|
|
let type = fields["content-type"] ?? "text/plain"
|
|
if type.lowercased().hasPrefix("multipart/"), let boundary = parameter("boundary", in: type) {
|
|
// Boundaries are ASCII; Latin-1 preserves every byte through splitting.
|
|
let raw = String(data: body, encoding: .isoLatin1) ?? ""
|
|
let delimiter = "--" + boundary
|
|
let parts = raw.components(separatedBy: delimiter)
|
|
for part in parts.dropFirst() {
|
|
if part.hasPrefix("--") { break }
|
|
var part = part
|
|
// CRLF is one Swift Character, though it contains two bytes.
|
|
if part.hasPrefix("\r\n") || part.hasPrefix("\n") { part.removeFirst() }
|
|
if part.hasSuffix("\r\n") || part.hasSuffix("\n") { part.removeLast() }
|
|
if let bytes = part.data(using: .isoLatin1) {
|
|
parsePart(bytes, depth: depth + 1, bodies: &bodies, htmlBodies: &htmlBodies, attachments: &attachments)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
let transfer = fields["content-transfer-encoding"]?.lowercased() ?? ""
|
|
let decoded: Data
|
|
if transfer == "base64" { decoded = Data(base64Encoded: body, options: .ignoreUnknownCharacters) ?? Data() }
|
|
else if transfer == "quoted-printable" { decoded = quotedPrintable(body) }
|
|
else { decoded = body }
|
|
let disposition = fields["content-disposition"] ?? ""
|
|
let extendedName = parameter("filename*", in: disposition).flatMap { $0.components(separatedBy: "''").last?.removingPercentEncoding }
|
|
let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type)
|
|
if name != nil || disposition.lowercased().hasPrefix("attachment") || !type.lowercased().hasPrefix("text/") {
|
|
attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded))
|
|
} else {
|
|
let value = text(decoded, charset: parameter("charset", in: type))
|
|
if type.lowercased().hasPrefix("text/html") {
|
|
// Render HTML as text: remote images and active content are never loaded.
|
|
let plain = value.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?</\\1>", with: "", options: .regularExpression)
|
|
.replacingOccurrences(of: "(?i)<br\\s*/?>|</p>|</div>", with: "\n", options: .regularExpression)
|
|
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
|
.replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "&", with: "&")
|
|
htmlBodies.append(plain)
|
|
} else { bodies.append(value) }
|
|
}
|
|
}
|
|
|
|
static func validAddress(_ value: String) -> Bool {
|
|
value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil
|
|
}
|
|
|
|
static func compose(from: String, to: [String], subject: String, body: String, attachments: [MailAttachment]) throws -> Data {
|
|
guard validAddress(from), !to.isEmpty, to.allSatisfy(validAddress),
|
|
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
|
|
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.")
|
|
}
|
|
let boundary = "MikeMail-" + UUID().uuidString
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
|
func base64(_ data: Data) -> String {
|
|
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed])
|
|
}
|
|
var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n"
|
|
result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n"
|
|
result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n"
|
|
result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
|
|
result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n"
|
|
for attachment in attachments {
|
|
let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang"
|
|
result += "--\(boundary)\r\nContent-Type: application/octet-stream\r\n"
|
|
result += "Content-Disposition: attachment; filename*=UTF-8''\(safeName)\r\nContent-Transfer-Encoding: base64\r\n\r\n"
|
|
result += base64(attachment.data) + "\r\n"
|
|
}
|
|
result += "--\(boundary)--\r\n"
|
|
return Data(result.utf8)
|
|
}
|
|
}
|