Files
MikeMail/MikeMail/MIME.swift
T

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: "&nbsp;", with: " ").replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">").replacingOccurrences(of: "&amp;", 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)
}
}