| 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: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
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` class | Defines the `EmlParser` class. |