diff --git a/README.md b/README.md index 587e9b7..071a34b 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,142 @@ This is only needed on iOS — Android handles file access differently by copyin If you only need to display the file (e.g., pass it to a `DocumentPreview`) without reading its contents, re-acquiring access may not be necessary. +## Mail Composer + +The `View.withMailComposer()` modifier presents a system email composition interface, allowing users to compose and send emails from within your app. + +On iOS, this uses `MFMailComposeViewController` for in-app email composition with full support for recipients, subject, body (plain text or HTML), and file attachments. On Android, this launches an `ACTION_SENDTO` intent (or `ACTION_SEND`/`ACTION_SEND_MULTIPLE` when attachments are present), which opens the user's preferred email app. + +### Checking Availability + +Before presenting the composer, check if the device can send email: + +```swift +import SkipKit + +if MailComposer.canSendMail() { + // Show compose button +} else { + // Email not available +} +``` + +### Basic Usage + +```swift +struct EmailView: View { + @State var showComposer = false + + var body: some View { + Button("Send Feedback") { + showComposer = true + } + .withMailComposer( + isPresented: $showComposer, + options: MailComposerOptions( + recipients: ["support@example.com"], + subject: "App Feedback", + body: "I'd like to share the following feedback..." + ), + onComplete: { result in + switch result { + case .sent: print("Email sent!") + case .saved: print("Draft saved") + case .cancelled: print("Cancelled") + case .failed: print("Failed to send") + case .unknown: print("Unknown result") + } + } + ) + } +} +``` + +### HTML Body + +```swift +MailComposerOptions( + recipients: ["user@example.com"], + subject: "Welcome!", + body: "

Welcome

Thank you for signing up.

", + isHTML: true +) +``` + +### Attachments + +```swift +let pdfURL = Bundle.main.url(forResource: "report", withExtension: "pdf")! + +MailComposerOptions( + recipients: ["team@example.com"], + subject: "Monthly Report", + body: "Please find the report attached.", + attachments: [ + MailAttachment(url: pdfURL, mimeType: "application/pdf", filename: "report.pdf") + ] +) +``` + +Multiple attachments are supported: + +```swift +attachments: [ + MailAttachment(url: pdfURL, mimeType: "application/pdf", filename: "report.pdf"), + MailAttachment(url: imageURL, mimeType: "image/png", filename: "chart.png") +] +``` + +### CC and BCC + +```swift +MailComposerOptions( + recipients: ["primary@example.com"], + ccRecipients: ["manager@example.com", "team@example.com"], + bccRecipients: ["archive@example.com"], + subject: "Project Update" +) +``` + +### API Reference + +**MailComposer** (static methods): + +| Method | Description | +|---|---| +| `canSendMail() -> Bool` | Whether the device can compose email | + +**MailComposerOptions**: + +| Property | Type | Default | Description | +|---|---|---|---| +| `recipients` | `[String]` | `[]` | Primary recipients | +| `ccRecipients` | `[String]` | `[]` | CC recipients | +| `bccRecipients` | `[String]` | `[]` | BCC recipients | +| `subject` | `String?` | `nil` | Subject line | +| `body` | `String?` | `nil` | Body text | +| `isHTML` | `Bool` | `false` | Whether body is HTML | +| `attachments` | `[MailAttachment]` | `[]` | File attachments | + +**MailAttachment**: + +| Property | Type | Description | +|---|---|---| +| `url` | `URL` | File URL of the attachment | +| `mimeType` | `String` | MIME type (e.g. `"application/pdf"`) | +| `filename` | `String` | Display filename | + +**MailComposerResult** (enum): +`sent`, `saved`, `cancelled`, `failed`, `unknown` + +### Platform Notes + +> [!NOTE] +> **iOS**: The `MFMailComposeViewController` requires a configured Mail account on the device. On the simulator, `canSendMail()` typically returns `false`. The `onComplete` callback receives a specific result (`.sent`, `.saved`, `.cancelled`, `.failed`). + +> [!NOTE] +> **Android**: The intent-based approach opens the user's default email app. The `onComplete` callback always receives `.unknown` because Android intents do not report back the send status. When there are no attachments, a `mailto:` URI is used with `ACTION_SENDTO` to target only email apps. When attachments are present, `ACTION_SEND` or `ACTION_SEND_MULTIPLE` is used, and the `FLAG_GRANT_READ_URI_PERMISSION` flag is set for file access. You may need to declare the `android.intent.action.SENDTO` intent filter in your `AndroidManifest.xml` for Android 11+ package visibility. + ## Document Preview The `View.withDocumentPreview(isPresented: Binding, documentURL: URL?, filename: String?, type: String?)` extension function can be used to preview a document available to the app (either selected with the provided `Document Picker` or downloaded locally by the App). diff --git a/Sources/SkipKit/MailComposer.swift b/Sources/SkipKit/MailComposer.swift new file mode 100644 index 0000000..1f587c9 --- /dev/null +++ b/Sources/SkipKit/MailComposer.swift @@ -0,0 +1,304 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation +import SwiftUI + +#if SKIP +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +#else +#if os(iOS) +import MessageUI +#endif +#endif + +// MARK: - MailComposerOptions + +/// Options for composing an email message. +public struct MailComposerOptions { + /// The primary recipient email addresses. + public var recipients: [String] + /// Carbon copy recipients. + public var ccRecipients: [String] + /// Blind carbon copy recipients. + public var bccRecipients: [String] + /// The email subject line. + public var subject: String? + /// The email body text. + public var body: String? + /// Whether the body is HTML formatted. + public var isHTML: Bool + /// File attachments. Each attachment specifies a URL, MIME type, and filename. + public var attachments: [MailAttachment] + + public init( + recipients: [String] = [], + ccRecipients: [String] = [], + bccRecipients: [String] = [], + subject: String? = nil, + body: String? = nil, + isHTML: Bool = false, + attachments: [MailAttachment] = [] + ) { + self.recipients = recipients + self.ccRecipients = ccRecipients + self.bccRecipients = bccRecipients + self.subject = subject + self.body = body + self.isHTML = isHTML + self.attachments = attachments + } +} + +// MARK: - MailAttachment + +/// A file attachment for an email. +public struct MailAttachment: Sendable { + /// The file URL of the attachment. + public var url: URL + /// The MIME type (e.g. `"image/png"`, `"application/pdf"`). + public var mimeType: String + /// The filename to display to the recipient. + public var filename: String + + public init(url: URL, mimeType: String, filename: String) { + self.url = url + self.mimeType = mimeType + self.filename = filename + } +} + +// MARK: - MailComposerResult + +/// The result of a mail composition attempt. +public enum MailComposerResult: String, Sendable { + /// The email was sent successfully. + case sent + /// The email was saved as a draft. + case saved + /// The user cancelled the composition. + case cancelled + /// The composition failed. + case failed + /// The result is unknown (Android always returns this since the intent doesn't report back). + case unknown +} + +// MARK: - MailComposer Availability + +/// Utility for checking mail composition availability. +public enum MailComposer { + /// Whether the device can send email. + /// + /// On iOS, this checks `MFMailComposeViewController.canSendMail()`. + /// On Android, this checks whether an app can handle `ACTION_SENDTO` with a `mailto:` URI. + public static func canSendMail() -> Bool { + #if SKIP + let context = ProcessInfo.processInfo.androidContext + let intent = Intent(Intent.ACTION_SENDTO) + intent.setData(Uri.parse("mailto:")) + return intent.resolveActivity(context.getPackageManager()) != nil + #elseif os(iOS) + return MFMailComposeViewController.canSendMail() + #else + return false + #endif + } +} + +// MARK: - View Extension + +extension View { + /// Present an email composition interface. + /// + /// On iOS, this presents an `MFMailComposeViewController` in a sheet. + /// On Android, this launches an `ACTION_SENDTO` intent to the user's email app. + /// + /// - Parameters: + /// - isPresented: A binding that controls whether the composer is shown. + /// - options: The email composition options (recipients, subject, body, attachments). + /// - onComplete: Called when the composition finishes, with the result status. + @ViewBuilder public func withMailComposer( + isPresented: Binding, + options: MailComposerOptions = MailComposerOptions(), + onComplete: ((MailComposerResult) -> Void)? = nil + ) -> some View { + #if SKIP + let context = LocalContext.current + + return onChange(of: isPresented.wrappedValue) { oldValue, presented in + if presented == true { + isPresented.wrappedValue = false + launchMailIntent(context: context, options: options) + onComplete?(.unknown) + } + } + #else // !SKIP + #if os(iOS) + sheet(isPresented: isPresented) { + MailComposerRepresentable( + options: options, + isPresented: isPresented, + onComplete: onComplete + ) + } + #else + self + #endif + #endif + } +} + +// MARK: - Android Intent + +#if SKIP +private func launchMailIntent(context: Context, options: MailComposerOptions) { + if options.attachments.isEmpty { + // Simple mailto: intent for text-only emails + var uriString = "mailto:" + if !options.recipients.isEmpty { + uriString += options.recipients.joined(separator: ",") + } + var params: [String] = [] + if let subject = options.subject { + params.append("subject=" + Uri.encode(subject)) + } + if let body = options.body { + params.append("body=" + Uri.encode(body)) + } + if !options.ccRecipients.isEmpty { + params.append("cc=" + options.ccRecipients.joined(separator: ",")) + } + if !options.bccRecipients.isEmpty { + params.append("bcc=" + options.bccRecipients.joined(separator: ",")) + } + if !params.isEmpty { + uriString += "?" + params.joined(separator: "&") + } + + let intent = Intent(Intent.ACTION_SENDTO) + intent.setData(Uri.parse(uriString)) + context.startActivity(intent) + } else { + // ACTION_SEND or ACTION_SEND_MULTIPLE for attachments + let intent: Intent + if options.attachments.count == 1 { + intent = Intent(Intent.ACTION_SEND) + intent.setType(options.attachments[0].mimeType) + let fileUri = Uri.parse(options.attachments[0].url.absoluteString) + intent.putExtra(Intent.EXTRA_STREAM, fileUri) + } else { + intent = Intent(Intent.ACTION_SEND_MULTIPLE) + intent.setType("message/rfc822") + // SKIP INSERT: val uris = ArrayList() + for attachment in options.attachments { + let fileUri = Uri.parse(attachment.url.absoluteString) + uris.add(fileUri) + } + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + } + + if !options.recipients.isEmpty { + intent.putExtra(Intent.EXTRA_EMAIL, options.recipients.toList().toTypedArray()) + } + if !options.ccRecipients.isEmpty { + intent.putExtra(Intent.EXTRA_CC, options.ccRecipients.toList().toTypedArray()) + } + if !options.bccRecipients.isEmpty { + intent.putExtra(Intent.EXTRA_BCC, options.bccRecipients.toList().toTypedArray()) + } + if let subject = options.subject { + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + } + if let body = options.body { + if options.isHTML { + intent.putExtra(Intent.EXTRA_TEXT, android.text.Html.fromHtml(body, android.text.Html.FROM_HTML_MODE_COMPACT)) + } else { + intent.putExtra(Intent.EXTRA_TEXT, body) + } + } + + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(Intent.createChooser(intent, "Send Email")) + } +} +#endif + +// MARK: - iOS MFMailComposeViewController + +#if !SKIP +#if os(iOS) + +struct MailComposerRepresentable: UIViewControllerRepresentable { + let options: MailComposerOptions + @Binding var isPresented: Bool + let onComplete: ((MailComposerResult) -> Void)? + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let vc = MFMailComposeViewController() + vc.mailComposeDelegate = context.coordinator + + if !options.recipients.isEmpty { + vc.setToRecipients(options.recipients) + } + if !options.ccRecipients.isEmpty { + vc.setCcRecipients(options.ccRecipients) + } + if !options.bccRecipients.isEmpty { + vc.setBccRecipients(options.bccRecipients) + } + if let subject = options.subject { + vc.setSubject(subject) + } + if let body = options.body { + vc.setMessageBody(body, isHTML: options.isHTML) + } + + for attachment in options.attachments { + if let data = try? Data(contentsOf: attachment.url) { + vc.addAttachmentData(data, mimeType: attachment.mimeType, fileName: attachment.filename) + } + } + + return vc + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) { + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, @preconcurrency MFMailComposeViewControllerDelegate { + let parent: MailComposerRepresentable + + init(parent: MailComposerRepresentable) { + self.parent = parent + } + + @MainActor func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + let mapped: MailComposerResult + switch result { + case .sent: mapped = .sent + case .saved: mapped = .saved + case .cancelled: mapped = .cancelled + case .failed: mapped = .failed + @unknown default: mapped = .unknown + } + parent.onComplete?(mapped) + parent.isPresented = false + } + } +} + +#endif +#endif + +#endif diff --git a/Tests/SkipKitTests/SkipKitTests.swift b/Tests/SkipKitTests/SkipKitTests.swift index dee66c0..7fb85b3 100644 --- a/Tests/SkipKitTests/SkipKitTests.swift +++ b/Tests/SkipKitTests/SkipKitTests.swift @@ -51,4 +51,45 @@ final class SkipKitTests: XCTestCase { XCTAssertTrue(cache.getValue(for: key1) == nil || cache.getValue(for: key2) == nil || cache.getValue(for: key3) == nil, "either key1 or key2 or key3 should have been evicted from the cache") // XCTAssertNotNil(cache.getValue(for: key4), "newly added key overflowing cache should have been retained") // not necessarily true } + + func testMailComposerOptions() throws { + let opts = MailComposerOptions( + recipients: ["alice@example.com", "bob@example.com"], + ccRecipients: ["cc@example.com"], + bccRecipients: ["bcc@example.com"], + subject: "Test Subject", + body: "

Hello

", + isHTML: true + ) + XCTAssertEqual(opts.recipients.count, 2) + XCTAssertEqual(opts.ccRecipients.count, 1) + XCTAssertEqual(opts.bccRecipients.count, 1) + XCTAssertEqual(opts.subject, "Test Subject") + XCTAssertEqual(opts.body, "

Hello

") + XCTAssertTrue(opts.isHTML) + XCTAssertEqual(opts.attachments.count, 0) + + // Default options + let empty = MailComposerOptions() + XCTAssertTrue(empty.recipients.isEmpty) + XCTAssertNil(empty.subject) + XCTAssertFalse(empty.isHTML) + } + + func testMailAttachment() throws { + let attachment = MailAttachment( + url: URL(string: "file:///tmp/test.pdf")!, + mimeType: "application/pdf", + filename: "test.pdf" + ) + XCTAssertEqual(attachment.mimeType, "application/pdf") + XCTAssertEqual(attachment.filename, "test.pdf") + } + + func testMailComposerResult() throws { + let results: [MailComposerResult] = [.sent, .saved, .cancelled, .failed, .unknown] + XCTAssertEqual(results.count, 5) + XCTAssertEqual(MailComposerResult.sent.rawValue, "sent") + XCTAssertEqual(MailComposerResult.cancelled.rawValue, "cancelled") + } }