Skip to content

Instantly share code, notes, and snippets.

@stephanecopin
Last active November 7, 2023 16:32
Show Gist options
  • Save stephanecopin/9b5da27e06f1f59e07630502d555ed46 to your computer and use it in GitHub Desktop.
Save stephanecopin/9b5da27e06f1f59e07630502d555ed46 to your computer and use it in GitHub Desktop.
A small set of struct/extensions to easily handle `mailto:` links in Swift 5.1.
import Foundation
import MessageUI
struct EmailParameters {
/// Guaranteed to be non-empty
let toEmails: [String]
let ccEmails: [String]
let bccEmails: [String]
let subject: String?
let body: String?
/// Defaults validation is just verifying that the email is not empty.
static func defaultValidateEmail(_ email: String) -> Bool {
return !email.isEmpty
}
/// Returns `nil` if `toEmails` contains at least one email address validated by `validateEmail`
/// A "blank" email address is defined as an address that doesn't only contain whitespace and new lines characters, as defined by CharacterSet.whitespacesAndNewlines
/// `validateEmail`'s default implementation is `defaultValidateEmail`.
init?(
toEmails: [String],
ccEmails: [String],
bccEmails: [String],
subject: String?,
body: String?,
validateEmail: (String) -> Bool = defaultValidateEmail
) {
func parseEmails(_ emails: [String]) -> [String] {
return emails.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter(validateEmail)
}
let toEmails = parseEmails(toEmails)
let ccEmails = parseEmails(ccEmails)
let bccEmails = parseEmails(bccEmails)
if toEmails.isEmpty {
return nil
}
self.toEmails = toEmails
self.ccEmails = ccEmails
self.bccEmails = bccEmails
self.subject = subject
self.body = body
}
/// Returns `nil` if `scheme` is not `mailto`, or if it couldn't find any `to` email addresses
/// `validateEmail`'s default implementation is `defaultValidateEmail`.
/// Reference: https://tools.ietf.org/html/rfc2368
init?(url: URL, validateEmail: (String) -> Bool = defaultValidateEmail) {
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
let queryItems = urlComponents.queryItems ?? []
func splitEmail(_ email: String) -> [String] {
return email.split(separator: ",").map(String.init)
}
let initialParameters = (toEmails: urlComponents.path.isEmpty ? [] : splitEmail(urlComponents.path), subject: String?(nil), body: String?(nil), ccEmails: [String](), bccEmails: [String]())
let emailParameters = queryItems.reduce(into: initialParameters) { emailParameters, queryItem in
guard let value = queryItem.value else {
return
}
switch queryItem.name {
case "to":
emailParameters.toEmails += splitEmail(value)
case "cc":
emailParameters.ccEmails += splitEmail(value)
case "bcc":
emailParameters.bccEmails += splitEmail(value)
case "subject" where emailParameters.subject == nil:
emailParameters.subject = value
case "body" where emailParameters.body == nil:
emailParameters.body = value
default:
break
}
}
self.init(
toEmails: emailParameters.toEmails,
ccEmails: emailParameters.ccEmails,
bccEmails: emailParameters.bccEmails,
subject: emailParameters.subject,
body: emailParameters.body,
validateEmail: validateEmail
)
}
private final class MailComposeDelegate: NSObject, MFMailComposeViewControllerDelegate {
static var mailDelegateKey: UInt8 = 0
let finishAction: MailComposeFinishAction
init(_ finishAction: @escaping MailComposeFinishAction = { viewController, _ in viewController.dismiss(animated: true) }) {
self.finishAction = finishAction
}
func mailComposeController(_ viewController: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
if let result = MailComposeResult(result) {
self.finishAction(viewController, .success(result))
} else {
self.finishAction(viewController, .failure(error ?? NSError(domain: MFMailComposeError.errorDomain, code: MFMailComposeError.sendFailed.rawValue)))
}
}
}
enum MailComposeResult {
case cancelled
case saved
case sent
var toMFMailComposeResult: MFMailComposeResult {
switch self {
case .cancelled:
return .cancelled
case .saved:
return .saved
case .sent:
return .sent
}
}
init?(_ result: MFMailComposeResult) {
switch result {
case .cancelled:
self = .cancelled
case .saved:
self = .saved
case .sent:
self = .sent
case .failed:
return nil
@unknown default:
return nil
}
}
}
typealias MailComposeFinishAction = (MFMailComposeViewController, Result<MailComposeResult, Error>) -> Void
func showMailCompose<ViewController: UIViewController>(
from viewController: ViewController,
showAction: (ViewController, MFMailComposeViewController) -> Void = { $0.present($1, animated: true) },
finishAction: @escaping MailComposeFinishAction = { viewController, _ in viewController.dismiss(animated: true) })
{
guard let mailComposeViewController = MFMailComposeViewController.mailComposeViewController(emailParameters: self) else {
UIApplication.shared.open(self.mailToURL, options: [:], completionHandler: nil)
return
}
let delegate = MailComposeDelegate(finishAction)
mailComposeViewController.mailComposeDelegate = delegate
objc_setAssociatedObject(mailComposeViewController, &MailComposeDelegate.mailDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
showAction(viewController, mailComposeViewController)
}
var mailToURL: URL {
var urlComponents = URLComponents()
urlComponents.scheme = "mailto"
if toEmails.count == 1 {
urlComponents.path = toEmails[0]
}
var queryItems: [URLQueryItem] = []
if toEmails.count > 1 {
queryItems.append(URLQueryItem(name: "to", value: toEmails[1...].joined(separator: ", ")))
}
if !ccEmails.isEmpty {
queryItems.append(URLQueryItem(name: "cc", value: ccEmails[1...].joined(separator: ", ")))
}
if !bccEmails.isEmpty {
queryItems.append(URLQueryItem(name: "bcc", value: bccEmails[1...].joined(separator: ", ")))
}
if let subject = self.subject {
queryItems.append(URLQueryItem(name: "subject", value: subject))
}
if let body = self.body {
queryItems.append(URLQueryItem(name: "body", value: body))
}
if !queryItems.isEmpty {
urlComponents.queryItems = queryItems
}
return urlComponents.url!
}
}
extension URL {
/// `validateEmail`'s default implementation is `EmailParameters.defaultValidateEmail`.
func parseRFC2368MailTo(validateEmail: (String) -> Bool = EmailParameters.defaultValidateEmail) -> EmailParameters? {
return EmailParameters(url: self, validateEmail: validateEmail)
}
}
extension MFMailComposeViewController {
/// Returns `nil` if `mailToURL` is not a valid `mailto` URL, or if `MFMailComposeViewController.canSendMail()` returns `false`
/// `validateEmail`'s default implementation is `EmailParameters.defaultValidateEmail`.
static func mailComposeViewController(mailToURL: URL, validateEmail: (String) -> Bool = EmailParameters.defaultValidateEmail) -> MFMailComposeViewController? {
guard let emailParameters = EmailParameters(url: mailToURL, validateEmail: validateEmail) else {
return nil
}
return self.mailComposeViewController(emailParameters: emailParameters)
}
/// Returns `nil` if `MFMailComposeViewController.canSendMail()` returns `false`
static func mailComposeViewController(emailParameters: EmailParameters) -> MFMailComposeViewController? {
guard MFMailComposeViewController.canSendMail() else {
return nil
}
let mailComposeViewController = MFMailComposeViewController()
mailComposeViewController.setToRecipients(emailParameters.toEmails)
mailComposeViewController.setCcRecipients(emailParameters.ccEmails.isEmpty ? nil : emailParameters.ccEmails)
mailComposeViewController.setBccRecipients(emailParameters.bccEmails.isEmpty ? nil : emailParameters.bccEmails)
if let subject = emailParameters.subject {
mailComposeViewController.setSubject(subject)
}
if let body = emailParameters.body {
mailComposeViewController.setMessageBody(body, isHTML: false)
}
return mailComposeViewController
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment