PAYATHON 2026

iPhone 应用如何按每封邮件收取 .50 USD

支付小周

结论

可以把”发送一封邮件”设计成一个消耗型 App 内购买项目(Consumable In-App Purchase)。用户每购买一次,就会获得 1 个发送额度;邮件成功提交后,再消耗这个额度。

需要注意两点:

  • StoreKit 不能直接从银行卡任意扣取 $0.50。你需要在 App Store Connect 中创建消耗型商品,并选择 Apple 提供的价格点。是否有正好 0.50 USD 的价格点,以当前 App Store Connect 中显示的选项为准。
  • 如果邮件通过 iPhone 的系统邮件编辑器发送,应用很难可靠地确认邮件是否真正送达。更稳妥的做法是由自己的服务器发送邮件,并在服务端核销发送额度。

如果收费对应数字功能,通常应优先使用 App 内购买。审核要求和可用价格点可能发生变化,因此上线前要核对最新的 App Review Guidelines 和 App Store Connect 配置说明。

推荐的业务流程

业务流程可以这样设计:

  1. 在 App Store Connect 中创建消耗型商品,例如:

    Product ID: com.example.app.email_credit
    Type: Consumable
    Display Name: One Email Credit
    
  2. 用户点击”发送邮件”。

  3. App 检查用户是否还有可用额度。

  4. 如果没有额度,通过 StoreKit 购买一个消耗型商品。

  5. 交易验证成功后,为用户增加 1 个额度。

  6. 将邮件交给服务器发送。

  7. 服务器接受发送请求后,通过原子操作扣除 1 个额度。

  8. 客户端调用 transaction.finish() 完成 StoreKit 交易。

不要只用 UserDefaults 在本地保存余额。卸载应用、修改本地数据、并发请求或网络中断,都可能导致额度丢失或被重复使用。

StoreKit 2 购买示例

下面是一个简化的 Swift 示例,用于演示商品加载和购买流程。生产环境还需要加入服务端交易验证和防重复发放机制。

import StoreKit

@MainActor
final class EmailCreditStore: ObservableObject {
    private let productID = "com.example.app.email_credit"

    @Published private(set) var product: Product?
    @Published private(set) var isPurchasing = false

    func loadProduct() async throws {
        let products = try await Product.products(for: [productID])

        guard let product = products.first else {
            throw PurchaseError.productNotFound
        }

        self.product = product
    }

    func purchaseOneCredit() async throws {
        guard let product else {
            throw PurchaseError.productNotLoaded
        }

        isPurchasing = true
        defer { isPurchasing = false }

        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)

            /*
             将 transaction.id、transaction.originalID、
             transaction.productID 等信息发送到服务器验证。

             服务器确认该 transaction.id 尚未处理后,
             再为用户增加一个邮件额度。
             */

            try await grantCreditOnServer(for: transaction)

            // 只有在服务器成功记录交易后再结束交易
            await transaction.finish()

        case .pending:
            throw PurchaseError.pending

        case .userCancelled:
            throw PurchaseError.cancelled

        @unknown default:
            throw PurchaseError.unknownResult
        }
    }

    private func checkVerified<T>(
        _ result: VerificationResult<T>
    ) throws -> T {
        switch result {
        case .verified(let value):
            return value

        case .unverified:
            throw PurchaseError.failedVerification
        }
    }

    private func grantCreditOnServer(
        for transaction: StoreKit.Transaction
    ) async throws {
        // 示例占位:
        // 通过 HTTPS 将交易标识发送给你的服务器。
        // 服务器应验证交易并使用 transaction.id 做幂等处理。
    }
}

enum PurchaseError: Error {
    case productNotFound
    case productNotLoaded
    case failedVerification
    case pending
    case cancelled
    case unknownResult
}

界面应显示 StoreKit 返回的本地化价格,不要写死 $0.50:

if let product = store.product {
    Text("购买 1 次发送额度:\(product.displayPrice)")
}

不同国家或地区使用的货币、税费和价格可能不同,因此应向用户显示 displayPrice。

监听未完成的交易

应用启动后还应监听交易更新。这样即使支付延期、应用被终止或网络中断,也能继续处理已经付款但尚未发放额度的交易。

import StoreKit

func observeTransactions() -> Task<Void, Never> {
    Task.detached {
        for await result in Transaction.updates {
            do {
                let transaction: Transaction

                switch result {
                case .verified(let verifiedTransaction):
                    transaction = verifiedTransaction

                case .unverified:
                    continue
                }

                // 将交易提交到服务器。
                // 服务器通过 transaction.id 保证同一笔交易只发放一次。
                try await submitTransactionToServer(transaction)

                await transaction.finish()
            } catch {
                // 暂时不要 finish,让系统之后继续提供该交易。
            }
        }
    }
}

func submitTransactionToServer(
    _ transaction: Transaction
) async throws {
    // 实现 HTTPS 请求和服务端验证
}

邮件发送方案

方案一:由服务器发送,推荐

客户端把收件人、主题和正文提交给服务器,服务器负责:

  1. 验证登录用户。
  2. 检查可用额度。
  3. 校验收件人、正文长度和发送频率。
  4. 在数据库事务中扣除一个额度,同时创建发送任务。
  5. 调用邮件服务商发送邮件。
  6. 保存发送状态和审计记录。

扣除额度和创建发送任务都必须具备幂等性。例如,每次请求都携带唯一的 requestId,避免用户重复点击或客户端重试时被扣费两次。

{
  "requestId": "4BB7D869-1E30-41A2-97EC-A17EF93405B8",
  "to": "recipient@example.com",
  "subject": "Hello",
  "body": "Message body"
}

服务端要保证同一个 requestId 最多只能创建一次发送任务。

产品还需要提前定义”发送成功”的含义。通常,这只能表示邮件服务商已经接受投递请求,并不代表收件人一定收到或阅读了邮件。如果发生永久退信,是否返还额度也要在产品规则中写清楚。

方案二:使用系统邮件编辑器

如果必须让用户通过系统邮件账户发送,可以使用 MFMailComposeViewController:

import MessageUI
import UIKit

final class MailViewController: UIViewController,
                                MFMailComposeViewControllerDelegate {
    func presentMailComposer() {
        guard MFMailComposeViewController.canSendMail() else {
            // 当前设备没有配置可用的邮件账户
            return
        }

        let composer = MFMailComposeViewController()
        composer.mailComposeDelegate = self
        composer.setToRecipients(["recipient@example.com"])
        composer.setSubject("Subject")
        composer.setMessageBody("Message body", isHTML: false)

        present(composer, animated: true)
    }

    func mailComposeController(
        _ controller: MFMailComposeViewController,
        didFinishWith result: MFMailComposeResult,
        error: Error?
    ) {
        controller.dismiss(animated: true)

        switch result {
        case .sent:
            // 可以认为用户已将邮件提交给系统邮件组件,
            // 但这不能证明邮件最终送达。
            break

        case .saved:
            break

        case .cancelled:
            break

        case .failed:
            break

        @unknown default:
            break
        }
    }
}

这种方式有几个明显的限制:

  • 用户可能取消发送,因此不能在打开编辑器时直接扣除额度。
  • .sent 只表示邮件已提交给系统邮件组件,不代表邮件已经送达。
  • 应用无法控制用户之后通过其他邮件客户端发送邮件的行为。
  • 如果用户付款后取消邮件,需要决定是保留额度还是退款。通常,保留额度供下次使用更合理。

因此,更合适的处理方式是先购买额度,在实际提交发送时再消耗额度,不要把一次购买与一次邮件编辑器展示直接绑定。

价格和收入并不相同

即使商店中有 0.50 USD 的价格点,开发者实际获得的收入通常也不是 $0.50,因为其中可能涉及:

  • Apple 的佣金;
  • 当地税费;
  • 不同地区的价格和汇率;
  • 退款、撤销和家庭成员付款审批;
  • App Store 的结算规则。

产品描述应写成”用户支付一个商店价格点,购买一次发送额度”,不要把 $0.50 当成每次发送的固定净收入。

上线前需要处理的问题

  • 使用服务端交易验证,并通过 transaction.id 防止重复发放额度。
  • 在服务器确认交易前,不要调用 transaction.finish()。
  • 邮件发送请求必须使用登录鉴权、HTTPS、限流和幂等键。
  • 明确邮件发送失败、退信或取消时是否返还额度。
  • 在购买按钮附近说明商品内容,并显示 StoreKit 提供的实际价格。
  • 提供隐私政策,说明如何处理收件人地址、邮件正文和发送记录。
  • 加入反垃圾邮件机制,防止应用被用于批量发送未经同意的邮件。
  • 在沙盒环境和 TestFlight 中测试购买取消、付款待处理、断网、重复回调和应用被强制终止等情况。
  • 提交审核前检查最新的 App Review Guidelines、StoreKit 文档 和 App Store Server API。

备注:内容仅供参考。