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")
+ }
}