← Back to index

EmlParser

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import FoundationFramework importsImports Foundation.
class EmlParser { /// Produces a fully self-contained HTML string suitable for WKWebView. /// - Walks the entire MIME tree to collect the HTML body and every inline /// image part that carries a Content-ID header. /// - Replaces every `cid:` reference in the HTML with a `data:` URI so /// images render without any external network access. /// - Injects a mobile viewport and CSS that forces fixed-width email /// tables to reflow to fit the device screen. static func renderSelfContainedHTML(from emlPath: String) -> String? { let resolvedPath = resolveActualPath(for: emlPath) ?? emlPath let fileURL = URL(fileURLWithPath: resolvedPath) let rawData: Data do { rawData = try Data(contentsOf: fileURL) } catch { print("⚠️ EmlParser: read error: \(error)") return nil } guard let content = String(data: rawData, encoding: .utf8) ?? String(data: rawData, encoding: .isoLatin1) else { print("⚠️ EmlParser: could not decode file contents") return nil } var htmlBody: String? = nil var inlineImages: [String: String] = [:] // cid → "data:image/xxx;base64,..." collectPartsForRendering(from: content, htmlBody: &htmlBody, inlineImages: &inlineImages) print("ℹ️ EmlParser: found \(inlineImages.count) inline image(s)") // Fallback to plain text if no HTML part was found if htmlBody == nil { let (headers, body) = splitHeadersAndBody(content) let ct = (headerValue(headers, name: "content-type") ?? "").lowercased() if ct.hasPrefix("text/plain") || ct.isEmpty { let text = decodeTextBody(body, headers: headers) if !text.isEmpty { let escaped = text .replacingOccurrences(of: "&", with: "&amp;") .replacingOccurrences(of: "<", with: "&lt;") .replacingOccurrences(of: ">", with: "&gt;") htmlBody = "<pre style='font-family:system-ui;font-size:15px;white-space:pre-wrap'>\(escaped)</pre>" } } } guard var html = htmlBody, !html.isEmpty else { return nil } // Replace every cid: reference with its base64 data URI for (cid, dataURI) in inlineImages { html = html.replacingOccurrences(of: "cid:\(cid)", with: dataURI, options: .caseInsensitive) } return wrapWithResponsiveCSS(html) } // MARK: - MIME tree walker (HTML + inline images) /// Recursively walks MIME parts, populating `htmlBody` (first text/html found) /// and `inlineImages` (cid → data URI for every image/* part with a Content-ID). private static func collectPartsForRendering( from part: String, htmlBody: inout String?, inlineImages: inout [String: String] ) { let (headers, body) = splitHeadersAndBody(part) let contentType = headerValue(headers, name: "content-type") ?? "" let lower = contentType.lowercased() if lower.hasPrefix("multipart/") { guard let boundary = boundaryValue(from: contentType) else { return } let delimiter = "--" + boundary let parts = body.components(separatedBy: delimiter) for sub in parts.dropFirst() { let trimmed = sub.trimmingCharacters(in: .newlines) guard !trimmed.hasPrefix("--"), !trimmed.isEmpty else { continue } collectPartsForRendering(from: trimmed, htmlBody: &htmlBody, inlineImages: &inlineImages) } } else if lower.hasPrefix("text/html") { if htmlBody == nil { htmlBody = decodeTextBody(body, headers: headers) } } else if lower.hasPrefix("image/") { // Strip angle brackets from Content-ID: <abc@def> if let rawCID = headerValue(headers, name: "content-id") { let cid = rawCID .trimmingCharacters(in: .whitespaces) .trimmingCharacters(in: CharacterSet(charactersIn: "<>")) if !cid.isEmpty, let data = decodeBody(body, headers: headers) { let mimeType = contentType .components(separatedBy: ";")[0] .trimmingCharacters(in: .whitespaces) inlineImages[cid] = "data:\(mimeType);base64,\(data.base64EncodedString())" } } } } // MARK: - Responsive HTML wrapper private static func wrapWithResponsiveCSS(_ html: String) -> String { let injected = """ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <style> /* Force everything to fit device width */ * { box-sizing: border-box !important; max-width: 100% !important; } body { margin: 0 !important; padding: 8px !important; font-family: -apple-system, Helvetica, sans-serif; -webkit-text-size-adjust: 100%; word-break: break-word; } /* Email tables almost always carry hard-coded pixel widths — override them */ table { width: 100% !important; table-layout: fixed !important; } td, th { word-break: break-word !important; } img { max-width: 100% !important; height: auto !important; } /* Override HTML width="" attributes */ [width] { width: auto !important; max-width: 100% !important; } </style> """ // Prefer injecting just after an existing <head> tag if let range = html.range(of: "<head>", options: .caseInsensitive) { var result = html result.insert(contentsOf: injected, at: range.upperBound) return result } // Some emails omit <head> entirely — wrap the whole thing return """ <!DOCTYPE html> <html> <head>\(injected)</head> <body>\(html)</body> </html> """ } // MARK: - Legacy helper kept for extractFirstImage (image thumbnail) static func extractHTMLBody(from emlPath: String) -> String? { renderSelfContainedHTML(from: emlPath) } /// Attempts to extract the first usable image from an .eml file. /// Tries embedded MIME image parts first, then falls back to /// downloading the first real <img src> URL found in the HTML body. static func extractFirstImage(from emlPath: String) async -> Data? { let resolvedPath = resolveActualPath(for: emlPath) ?? emlPath let fileURL = URL(fileURLWithPath: resolvedPath) guard let rawData = try? Data(contentsOf: fileURL), let content = String(data: rawData, encoding: .utf8) ?? String(data: rawData, encoding: .isoLatin1) else { return nil } // 1. Try embedded base64 image MIME parts if let embedded = parseEmbeddedImage(from: content) { return embedded } // 2. Fall back to <img src="..."> URLs in the HTML body let imgURLs = extractImgURLs(from: content) for url in imgURLs { if let data = try? await URLSession.shared.data(from: url).0, data.count > 10_000 { // skip tiny tracking pixels return data } } return nil } // MARK: - Embedded MIME Image Parts private static func parseEmbeddedImage(from content: String) -> Data? { return parseImageFromMimePart(content) } private static func parseImageFromMimePart(_ part: String) -> Data? { let (headers, body) = splitHeadersAndBody(part) let contentType = headerValue(headers, name: "content-type") ?? "" let lowerContentType = contentType.lowercased() if lowerContentType.hasPrefix("multipart/") { if let boundary = boundaryValue(from: contentType) { return parseMultipart(body: body, boundary: boundary) } } else if lowerContentType.hasPrefix("image/") { return decodeBody(body, headers: headers) } return nil } private static func parseMultipart(body: String, boundary: String) -> Data? { let delimiter = "--" + boundary let parts = body.components(separatedBy: delimiter) for part in parts.dropFirst() { let trimmed = part.trimmingCharacters(in: .newlines) guard !trimmed.hasPrefix("--"), !trimmed.isEmpty else { continue } if let data = parseImageFromMimePart(trimmed) { return data } } return nil } // MARK: - HTML <img src> Extraction /// Walks the MIME tree, collects all text/html parts, and returns /// a filtered, ordered list of image URLs found in <img src="..."> tags. private static func extractImgURLs(from content: String) -> [URL] { let htmlBodies = collectHTMLBodies(from: content) var urls: [URL] = [] for html in htmlBodies { urls += imgSrcURLs(from: html) } return urls } /// Recursively walks MIME parts and collects text/html body strings. private static func collectHTMLBodies(from part: String) -> [String] { let (headers, body) = splitHeadersAndBody(part) let contentType = headerValue(headers, name: "content-type") ?? "" let lower = contentType.lowercased() if lower.hasPrefix("multipart/") { guard let boundary = boundaryValue(from: contentType) else { return [] } let delimiter = "--" + boundary let parts = body.components(separatedBy: delimiter) return parts.dropFirst().flatMap { sub -> [String] in let trimmed = sub.trimmingCharacters(in: .newlines) guard !trimmed.hasPrefix("--"), !trimmed.isEmpty else { return [] } return collectHTMLBodies(from: trimmed) } } else if lower.hasPrefix("text/html") { let decoded = decodeTextBody(body, headers: headers) return [decoded] } return [] } /// Extracts and filters <img src="..."> URLs from an HTML string. /// Skips likely tracking pixels (tiny GIFs, known tracker patterns). private static func imgSrcURLs(from html: String) -> [URL] { var results: [URL] = [] // Match src="..." or src='...' inside <img tags let pattern = #"<img[^>]+src=["']([^"']+)["']"# guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return results } let nsHTML = html as NSString let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length)) for match in matches { guard match.numberOfRanges > 1 else { continue } let srcRange = match.range(at: 1) let src = nsHTML.substring(with: srcRange) guard let url = URL(string: src) else { continue } // Skip obvious tracking pixels and non-image content let lower = src.lowercased() if lower.hasSuffix(".gif") { continue } if lower.contains("track") || lower.contains("pixel") || lower.contains("beacon") || lower.contains("open.php") || lower.contains("spacer") { continue } results.append(url) } return results } // MARK: - Path Resolution /// If the stored path doesn't exist (e.g. due to filename encoding mismatch), /// searches the same directory for a file whose unique hex suffix matches. /// Filenames are saved as "ShowName_XXXXXXXX.ext" — the hex suffix is encoding-safe. static func resolveActualPath(for path: String) -> String? { // Derive just the filename regardless of whether path is filename-only or absolute let filename = path.hasPrefix("/") ? URL(fileURLWithPath: path).lastPathComponent : path let ext = (filename as NSString).pathExtension let stem = (filename as NSString).deletingPathExtension // 1. iCloud Documents — primary (syncs across devices) if let icloudDocsURL = FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") { let icloudPath = icloudDocsURL.appendingPathComponent(filename).path if FileManager.default.fileExists(atPath: icloudPath) { print("ℹ️ EmlParser: resolved via iCloud '\(filename)'") return icloudPath } // If a .icloud placeholder exists the file is in iCloud but not yet downloaded — // request the download and fall through to local fallback for this access. let placeholder = icloudDocsURL.appendingPathComponent(".\(filename).icloud").path if FileManager.default.fileExists(atPath: placeholder) { print("ℹ️ EmlParser: iCloud placeholder found — triggering download for '\(filename)'") try? FileManager.default.startDownloadingUbiquitousItem( at: icloudDocsURL.appendingPathComponent(filename)) } // Hex-suffix search in iCloud (handles name encoding mismatches) if let suffix = stem.components(separatedBy: "_").last, suffix.count == 8 { let needle = "_\(suffix).\(ext)" let files = (try? FileManager.default.contentsOfDirectory(atPath: icloudDocsURL.path)) ?? [] if let match = files.first(where: { $0.hasSuffix(needle) && !$0.hasPrefix(".") }) { let resolved = icloudDocsURL.appendingPathComponent(match).path print("ℹ️ EmlParser: resolved via iCloud suffix '\(filename)' → '\(match)'") return resolved } } // SpotsDocs subfolder — iCloud (MindTheShow migrated ticket documents) let icloudSpotsDocs = icloudDocsURL.appendingPathComponent("SpotsDocs") let icloudSpotsDocsPath = icloudSpotsDocs.appendingPathComponent(filename).path if FileManager.default.fileExists(atPath: icloudSpotsDocsPath) { print("ℹ️ EmlParser: resolved via iCloud SpotsDocs '\(filename)'") return icloudSpotsDocsPath } // Trigger iCloud download for SpotsDocs placeholder if present let spotsDocsPlaceholder = icloudSpotsDocs.appendingPathComponent(".\(filename).icloud").path if FileManager.default.fileExists(atPath: spotsDocsPlaceholder) { print("ℹ️ EmlParser: iCloud SpotsDocs placeholder found — triggering download for '\(filename)'") try? FileManager.default.startDownloadingUbiquitousItem( at: icloudSpotsDocs.appendingPathComponent(filename)) } // Hex-suffix search in iCloud SpotsDocs if let suffix = stem.components(separatedBy: "_").last, suffix.count == 8 { let needle = "_\(suffix).\(ext)" let files = (try? FileManager.default.contentsOfDirectory(atPath: icloudSpotsDocs.path)) ?? [] if let match = files.first(where: { $0.hasSuffix(needle) && !$0.hasPrefix(".") }) { let resolved = icloudSpotsDocs.appendingPathComponent(match).path print("ℹ️ EmlParser: resolved via iCloud SpotsDocs suffix '\(filename)' → '\(match)'") return resolved } } } // 2. Filename-only format — local Documents directory if !path.hasPrefix("/"), let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let resolved = docsDir.appendingPathComponent(path).path if FileManager.default.fileExists(atPath: resolved) { return resolved } } // 3. Exact match (full absolute path — legacy entries) if FileManager.default.fileExists(atPath: path) { return path } // 4. Local Documents directory by filename — handles UUID changes after reinstall if let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let byName = docsDir.appendingPathComponent(filename).path if FileManager.default.fileExists(atPath: byName) { print("ℹ️ EmlParser: resolved via docs dir '\(filename)'") return byName } // Hex-suffix search (handles show-name encoding mismatches) if let suffix = stem.components(separatedBy: "_").last, suffix.count == 8 { let needle = "_\(suffix).\(ext)" let files = (try? FileManager.default.contentsOfDirectory(atPath: docsDir.path)) ?? [] if let match = files.first(where: { $0.hasSuffix(needle) }) { let resolved = docsDir.appendingPathComponent(match).path print("ℹ️ EmlParser: resolved by suffix '\(filename)' → '\(match)'") return resolved } } // SpotsDocs subfolder — local (MindTheShow migrated ticket documents) let localSpotsDocs = docsDir.appendingPathComponent("SpotsDocs") let localSpotsDocsPath = localSpotsDocs.appendingPathComponent(filename).path if FileManager.default.fileExists(atPath: localSpotsDocsPath) { print("ℹ️ EmlParser: resolved via local SpotsDocs '\(filename)'") return localSpotsDocsPath } // Hex-suffix search in local SpotsDocs if let suffix = stem.components(separatedBy: "_").last, suffix.count == 8 { let needle = "_\(suffix).\(ext)" let files = (try? FileManager.default.contentsOfDirectory(atPath: localSpotsDocs.path)) ?? [] if let match = files.first(where: { $0.hasSuffix(needle) }) { let resolved = localSpotsDocs.appendingPathComponent(match).path print("ℹ️ EmlParser: resolved via local SpotsDocs suffix '\(filename)' → '\(match)'") return resolved } } } return nil } // MARK: - Header Utilities private static func splitHeadersAndBody(_ content: String) -> (headers: String, body: String) { if let range = content.range(of: "\r\n\r\n") { return (String(content[..<range.lowerBound]), String(content[range.upperBound...])) } else if let range = content.range(of: "\n\n") { return (String(content[..<range.lowerBound]), String(content[range.upperBound...])) } return (content, "") } private static func headerValue(_ headers: String, name: String) -> String? { let target = name.lowercased() + ":" let lines = headers.components(separatedBy: "\n") var result: String? = nil for (i, line) in lines.enumerated() { if line.lowercased().hasPrefix(target) { let after = line.dropFirst(target.count) result = String(after).trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\r", with: "") var j = i + 1 while j < lines.count { let next = lines[j] if next.hasPrefix(" ") || next.hasPrefix("\t") { result! += " " + next.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\r", with: "") j += 1 } else { break } } break } } return result } private static func boundaryValue(from contentType: String) -> String? { for param in contentType.components(separatedBy: ";") { let trimmed = param.trimmingCharacters(in: .whitespaces) if trimmed.lowercased().hasPrefix("boundary=") { var value = String(trimmed.dropFirst("boundary=".count)).trimmingCharacters(in: .whitespaces) if value.hasPrefix("\"") { value = String(value.dropFirst()) } if value.hasSuffix("\"") { value = String(value.dropLast()) } return value } } return nil } // MARK: - Body Decoding private static func decodeBody(_ body: String, headers: String) -> Data? { let encoding = (headerValue(headers, name: "content-transfer-encoding") ?? "7bit") .lowercased().trimmingCharacters(in: .whitespaces) let clean = body.trimmingCharacters(in: .whitespacesAndNewlines) switch encoding { case "base64": let b64 = clean.components(separatedBy: .whitespacesAndNewlines).joined() return Data(base64Encoded: b64, options: .ignoreUnknownCharacters) default: return clean.data(using: .utf8) } } private static func decodeTextBody(_ body: String, headers: String) -> String { let encoding = (headerValue(headers, name: "content-transfer-encoding") ?? "7bit") .lowercased().trimmingCharacters(in: .whitespaces) let clean = body.trimmingCharacters(in: .whitespacesAndNewlines) if encoding == "base64" { let b64 = clean.components(separatedBy: .whitespacesAndNewlines).joined() if let data = Data(base64Encoded: b64, options: .ignoreUnknownCharacters), let str = String(data: data, encoding: .utf8) { return str } } else if encoding == "quoted-printable" { return decodeQuotedPrintable(clean) } return clean } private static func decodeQuotedPrintable(_ input: String) -> String { var result = "" var i = input.startIndex while i < input.endIndex { let ch = input[i] if ch == "=" { let next = input.index(after: i) if next < input.endIndex { let afterNext = input.index(after: next) // Soft line break: =\r\n or =\n if input[next] == "\r" || input[next] == "\n" { i = afterNext if i < input.endIndex && (input[i] == "\n") { i = input.index(after: i) } continue } if afterNext < input.endIndex { let hex = String(input[next...afterNext]) if let byte = UInt8(hex, radix: 16) { result.append(Character(UnicodeScalar(byte))) i = input.index(after: afterNext) continue } } } } result.append(ch) i = input.index(after: i) } return result } }`EmlParser` classDefines the `EmlParser` class.