Programatically sending a HTML email in macOS


One of the neat things about SwiftUI is that you are able to use some of the built-in wrappers for functionalities we would have previously had to manually write ourselves.

However, the other side of that sentiment is that SwiftUI for macOS compared to iOS or iPadOS feels to be running at a different speed.

macOS still needs to leverage AppKit or Objective-C, which is completely understandable since there is a lot more freedom in what apps exist, how they were installed (or from where), among many other things.

In my localisation app, Get Local, one of the features I wanted to add was a support email function.

The issue I encountered was embedding HTML into the mailto: string was not rendering correctly, or at least it wasn't converting the HTML into rendered HTML. This occurred for both Mail.app and for Outlook.app. Testing this in iOS did render correctly.

What I was starting with

This is the code which I was working with. My two current versions were using the Link() from SwiftUI and NSSharingService() from AppKit.

Neither of these did render the HTML

let body = "<strong>Please describe your issue below:</strong><br><br><hr><strong>Operating system:</strong> \(ProcessInfo.processInfo.operatingSystemVersionString)"

// option 1
let mailto = "mailto:me@domain.ltd?subject=Test%20Email&body=\(body)"
Link("Send an email", destination: mailto)

// option 2
func sendEmail(to: [String], subject: String, message: String) {
  let service = NSSharingService(named: .composeEmail)!
  service.recipients = [to]
  service.subject = subject
  service.perform(withItems: [message])
}

sendEmail(to: ["me@domain.ltd], subject: "Test Email", message: body)

First attempt

My first intention was to dig into setting a Content-Type or Mime-Type when composing the email.

This would let me force the HTML email body type more than letting the systems decide themselves.

Unfortunately, I couldn't find anything that was helpful for SwiftUI.

Research

I did some deep diving, opening every search result into new tabs, and reading everything.

Sometimes it takes going into page 4 or 5 in the search results to find an answer.

I came across this blog post from 2013 about sending rich text emails.

Unfortunately, for us Swift users the post was written in Objective-C. However, fortunately there are many resources for converting Objective-C to Swift - particularly now with ChatGPT, Bard, or other models.

When I originally found this solution though it was 27/10/2022 and ChatGPT came out one month later

Original Objective-C

// create an attributed string
NSString* htmlText = @"<html><body>Hello, <b>World</b>!</body></html>"; 
NSData* textData = [NSData dataWithBytes:[htmlText UTF8String] length:[htmlText lengthOfBytesUsingEncoding:NSUTF8StringEncoding]];
NSAttributedString* textAttributedString = [[NSAttributedString alloc] initWithHTML:textData options:options documentAttributes:nil];

// create a file to attach 
NSUUID* uuid = [NSUUID new]; 
NSString* tempDir = [NSTemporaryDirectory() stringByAppendingPathComponent:[uuid UUIDString]]; 
NSFileManager* fm = [NSFileManager new]; 
[fm createDirectoryAtPath:tempDir withIntermediateDirectories:YES attributes:nil error:nil];
NSString* tempFile = [tempDir stringByAppendingPathComponent:@"report.csv"]; 
NSURL* tempFileURL = [NSURL fileURLWithPath:tempFile]; 
NSData* csv = ...; // generate the data here 
[csv writeToURL:tempFileURL atomically:NO];

// share it 
NSSharingService* mailShare = [NSSharingService sharingServiceNamed:NSSharingServiceNameComposeEmail]; 
NSArray* shareItems = @[textAttributedString,tempFileURL];
[mailShare performWithItems:shareItems];

Converted to Swift

I manually converted it into Swift which resulted into this:

// Create an attributed string
let htmlText = "<html><body>Hello, <b>World</b>!</body></html>"
guard let textData = htmlText.data(using: .utf8) else { 
    fatalError("Failed to convert HTML to data")
}
let textAttributedString = try? NSAttributedString(data: textData, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)

// Create a file to attach
let uuid = UUID()
let tempDir = NSTemporaryDirectory().appending(uuid.uuidString)
let fm = FileManager()
try? fm.createDirectory(atPath: tempDir, withIntermediateDirectories: true, attributes: nil)
let tempFile = tempDir.appendingPathComponent("report.csv")
let tempFileURL = URL(fileURLWithPath: tempFile)
let csv = Data() // Generate the data here
try? csv.write(to: tempFileURL)

// Share it
guard let mailShare = NSSharingService(named: .composeEmail) else { fatalError("Email sharing service is not available") }
let shareItems: [Any] = [textAttributedString, tempFileURL]
mailShare.perform(withItems: shareItems)

Basic HTML email

This was the code I ended up using for the original example:

let body = "<strong>Please describe your issue below:</strong><br><br><hr><strong>Operating system:</strong> \(ProcessInfo.processInfo.operatingSystemVersionString)"

func sendEmail(to: [String], subject: String, message: String) {
  let service = NSSharingService(named: .composeEmail)!
  service.recipients = [to] 
  service.subject = subject

  // convert String to NSAttributedString 
  let data = message.data(using: .utf8) 
  let attributedString = NSAttributedString(html: data!, documentAttributes: nil)

  service.perform(withItems: [attributedString!]) 
}

sendEmail(to: ["me@domain.ltd], subject: "Test Email", message: body)

Help me write and make more!

You can help me continue to provide valuable content like this. If you found this article helpful, please consider supporting me.

Coffee Pizza Dinner