Highlight
macOS Monterey 引入 MailKit,让开发者用 App Extension 架构为 Mail 添加撰写辅助、收件处理、内容拦截和邮件安全能力,解决旧 Mail 插件随系统更新失效的问题。
核心内容
很多团队的邮件规则不在 Mail 里。
企业邮件常见的需求很具体:机密项目邮件要检查收件人,项目邮件要自动打标,营销邮件里的远程图片要被拦截,企业证书体系要接管签名和加密。过去如果要深度改造 Mail,开发者会写 Mail 插件。插件依赖 Mail 内部实现,系统更新后容易失效。
Apple 在 macOS Monterey 提供了 MailKit(Mail 扩展框架)。它建立在 App Extension(应用扩展)架构上,和 Safari app extensions、Share Sheet extensions 使用同一类基础设施。扩展放在签名过的 Mac app 里,也能随现有 app 分发到 App Store。
这场 session 给出四类入口。Compose extension 处理写信过程,Action extension 处理新收到的邮件,Content Blocker extension 复用 WebKit 内容拦截规则,Message Security extension 处理签名、加密和解密。
演讲里的例子是一个企业保密扩展。写信时,它根据项目检查收件人;收信时,它把 Mars 项目邮件标成红色;打开邮件时,它显示邮件已经被扩展解密。所有操作都发生在 Mail 的原生界面里。
详细内容
MailKit 扩展入口
(00:27)MailKit 从 MEExtension 开始。扩展的 principal class 实现这个协议,然后按能力返回对应 handler。演讲提到的四种能力,都通过这个入口交给 Mail 调用。
import MailKit
class MailExtension: NSObject, MEExtension {
func handlerForContentBlocker() -> MEContentBlocker {
return ContentBlocker.shared
}
func handlerForMessageActions() -> MEMessageActionHandler {
return MessageActionHandler.shared
}
func handler(for session: MEComposeSession) -> MEComposeSessionHandler {
return ComposeSessionHandler()
}
func handlerForMessageSecurity() -> MEMessageSecurityHandler {
return MessageSecurityHandler.shared
}
}
关键点:
import MailKit引入 Mail 扩展相关协议和类型。MailExtension继承NSObject,并实现MEExtension。handlerForContentBlocker()返回内容拦截 handler,Mail 显示邮件内容时会读取规则。handlerForMessageActions()返回收件处理 handler,Mail 下载新邮件时会调用它。handler(for:)接收一个MEComposeSession,每个撰写窗口都有独立 session,所以示例返回新的ComposeSessionHandler。handlerForMessageSecurity()返回邮件安全 handler,负责签名、加密、解密和证书视图。
Compose extension:写信时检查收件人
(04:31)Compose extension 可以在撰写窗口做四件事:验证收件人地址、提供附加视图、设置额外邮件头、发送前报错。演讲里的保密扩展用它检查收件人是否属于当前项目。
import MailKit
class ComposeSessionHandler: NSObject, MEComposeSessionHandler {
func mailComposeSessionDidBegin(_ session: MEComposeSession) {
// Perform any setup necessary for handling the compose session.
}
func mailComposeSessionDidEnd(_ session: MEComposeSession) {
SpecialProjectHandler.sharedHandler.selectedSpecialProject = nil
}
func annotateAddressesForSession(_ session: MEComposeSession) async -> [MEEmailAddress: MEAddressAnnotation] {
var annotations: [MEEmailAddress: MEAddressAnnotation] = [:]
for address in session.mailMessage.allRecipientAddresses {
if !SpecialProjectHandler.verifiedEmails.contains(address) {
let message = "\(SpecialProjectHandler.bannedDomain) is not a valid domain"
let annotation = MEAddressAnnotation.error(withLocalizedDescription: message)
annotations[address] = annotation
}
}
return annotations
}
}
关键点:
MEComposeSessionHandler让扩展参与撰写窗口生命周期。mailComposeSessionDidBegin(_:)在新撰写窗口打开时调用,适合初始化当前窗口状态。mailComposeSessionDidEnd(_:)在撰写结束时调用,示例清空当前选择的项目。annotateAddressesForSession(_:)在收件人地址被编辑时调用。session.mailMessage.allRecipientAddresses提供当前邮件的所有收件人地址。MEAddressAnnotation.error(withLocalizedDescription:)创建错误标注,Mail 会把它显示在地址 token 上。- 返回值是
[MEEmailAddress: MEAddressAnnotation],键是需要标注的地址,值是 Mail 要展示的标注。
(07:53)Compose extension 还能在撰写窗口显示自定义 UI。这个 UI 必须来自 MEExtensionViewController 子类。
func viewController(for session: MEComposeSession) -> MEExtensionViewController {
return ComposeSessionViewController(
nibName: "ComposeSessionViewController",
bundle: Bundle.main
)
}
func additionalHeaders(for session: MEComposeSession) -> [String: [String]] {
guard let project = SpecialProjectHandler.sharedHandler.selectedSpecialProject else {
return [:]
}
return [SpecialProjectHandler.specialProjectsHeader: [project.rawValue]]
}
关键点:
viewController(for:)返回 Mail 要嵌入撰写窗口的控制器。ComposeSessionViewController是自定义 UI,示例用它选择特殊项目。additionalHeaders(for:)在发送邮件时返回额外 header。- 返回空字典表示当前邮件不需要额外 header。
- header 的值是字符串数组,示例把项目名写进
SpecialProjectHandler.specialProjectsHeader。
Action extension:新邮件到达时自动处理
(08:38)Action extension 在 Mail 下载新邮件时运行。它可以改已读状态和 flag,移动到 Junk、Trash、Archive 等系统邮箱,也可以给邮件列表里的邮件设置颜色。
import MailKit
class MessageActionHandler: NSObject, MEMessageActionHandler {
static let shared = MessageActionHandler()
func decideAction(for message: MEMessage) async -> MEMessageActionDecision? {
if SpecialProjectHandler.SpecialProject.allCases.first(where: { message.subject.contains($0.rawValue) }) != nil {
return MEMessageActionDecision.action(.setBackgroundColor(.yellow))
}
if message.rawData == nil {
return MEMessageActionDecision.invokeAgainWithBody
}
if let projects = message.headers?[SpecialProjectHandler.specialProjectsHeader] {
if projects.contains(SpecialProjectHandler.SpecialProject.marsRemoteOffice.rawValue) {
return MEMessageActionDecision.action(.flag(.purple))
} else if projects.contains(SpecialProjectHandler.SpecialProject.apSpaceShuttle.rawValue) {
return MEMessageActionDecision.action(.flag(.green))
}
}
if let rawData = message.rawData,
let text = String(data: rawData, encoding: .utf8),
SpecialProjectHandler.SpecialProject.allCases.first(where: { text.contains($0.rawValue) }) != nil {
return MEMessageActionDecision.action(.flag(.red))
}
return nil
}
}
关键点:
MEMessageActionHandler接收 Mail 下载的新邮件。decideAction(for:)返回MEMessageActionDecision?,告诉 Mail 要执行什么动作。- 第一段用
message.subject检查主题,命中项目名就设置黄色背景。 message.rawData == nil表示当前只有部分 header,没有完整正文。MEMessageActionDecision.invokeAgainWithBody要求 Mail 拉取完整正文和 header 后再调用一次 handler。message.headers读取 Compose extension 写入的自定义 header。.flag(.purple)、.flag(.green)、.flag(.red)给邮件设置不同 flag。- 返回
nil表示不处理这封邮件。
Content Blocker:复用 Safari 内容拦截规则
(11:23)Mail 的邮件内容视图基于 WebKit。Content Blocker extension 可以把内容规则接入 Mail 的 WebKit 配置,按 HTML 里的 URL 等条件阻止资源加载。演讲明确说明,规则语法和 Safari content blockers 相同。
import MailKit
class ContentBlocker: NSObject, MEContentBlocker {
static let shared = ContentBlocker()
func contentRulesJSON() -> Data {
guard let url = Bundle.main.url(
forResource: "ContentBlockerRules",
withExtension: "json"
) else { return Data() }
guard let data = try? Data(contentsOf: url) else { return Data() }
return data
}
}
关键点:
MEContentBlocker提供 Mail 内容拦截规则。contentRulesJSON()返回 JSON 的Data编码。- 示例从 app bundle 读取
ContentBlockerRules.json。 - 找不到文件或读取失败时返回空
Data,Mail 就没有规则可用。 - 规则格式沿用 Safari content blockers,已有 Safari 规则可以迁移到 Mail extension。
规则文件本身是 JSON。
[
{
"action": {
"type": "block"
},
"trigger": {
"url-filter": "webkit.svg",
"resource-type": ["image", "style-sheet"],
"if-domain": "example.com"
}
}
]
关键点:
action.type设为block,表示命中后阻止加载。trigger.url-filter用 URL 规则匹配资源。resource-type限定资源类型,这里匹配图片和样式表。if-domain限定页面域名,这里只对example.com生效。
Message Security:接管签名、加密和解密
(12:57)Message Security extension 处理邮件安全。发送时,它告诉 Mail 当前邮件是否能签名和加密;点击发送后,它接收 RFC822 message data,返回签名或加密后的 RFC822 data。接收时,Mail 把编码后的 RFC822 data 交给扩展,扩展返回解码后的邮件。
import MailKit
class MessageSecurityHandler: NSObject, MEMessageSecurityHandler {
static let shared = MessageSecurityHandler()
func encodingStatus(
for message: MEMessage,
composeContext: MEComposeContext
) async -> MEOutgoingMessageEncodingStatus {
let invalidRecipients = message.allRecipientAddresses.filter({ address in
return !SpecialProjectHandler.verifiedEmails.contains(address)
})
if !invalidRecipients.isEmpty {
return MEOutgoingMessageEncodingStatus(
canSign: false,
canEncrypt: false,
securityError: MessageSecurityError.unverifiedEmails(emailAdresses: invalidRecipients),
addressesFailingEncryption: invalidRecipients
)
} else {
let encoder = MockEncoder.sharedInstance
let encodingStatus = encoder.securityStatus(for: message)
return encodingStatus
}
}
func encode(_ message: MEMessage, composeContext: MEComposeContext) async -> MEMessageEncodingResult {
return MockEncoder.sharedInstance.encodedMessage(for: message, context: composeContext)
}
}
关键点:
MEMessageSecurityHandler是邮件安全 handler 协议。encodingStatus(for:composeContext:)在发件人或收件人变化时调用,用于驱动锁和证书图标状态。message.allRecipientAddresses提供当前收件人列表。MEOutgoingMessageEncodingStatus返回是否能签名、是否能加密、错误信息和加密失败的地址。encode(_:composeContext:)在邮件发送时调用。- 示例把真正编码工作交给
MockEncoder.sharedInstance.encodedMessage(for:context:)。
(16:05)解码流程发生在查看邮件时。扩展如果能处理这封邮件,就返回 MEDecodedMessage;如果不需要处理,要快速返回 nil。
func decodedMessage(forMessageData data: Data) -> MEDecodedMessage? {
let decoder = ExampleDecoder.sharedInstance
if decoder.shouldDecodeMessage(withData: data) {
return decoder.decodedMessage(from: data)
}
return nil
}
func extensionViewController(signers messageSigners: [MEMessageSigner]) -> MEExtensionViewController? {
let controller = ExampleSigningViewController.sharedInstance
controller.signers = messageSigners
return controller
}
关键点:
decodedMessage(forMessageData:)接收编码后的 RFC822 数据。shouldDecodeMessage(withData:)先判断扩展是否需要处理,避免无关邮件被拖慢。decodedMessage(from:)返回解密或去签名后的邮件数据,以及签名和加密状态。extensionViewController(signers:)返回证书信息 UI。messageSigners来自扩展解码时返回的 signer 信息,Mail 会把它传回给证书视图。
核心启发
-
做什么:为企业邮件加项目级收件人校验。 为什么值得做:Compose extension 能在用户编辑地址时返回
MEAddressAnnotation,错误直接显示在 Mail 地址 token 上。 怎么开始:创建 Mail Extension target,勾选 Compose Session Handler,在annotateAddressesForSession(_:)里读取session.mailMessage.allRecipientAddresses,用企业权限表生成标注。 -
做什么:把项目邮件自动标成不同颜色或 flag。 为什么值得做:Action extension 在新邮件进入收件箱前运行,用户打开 Mail 时已经看到分类结果。 怎么开始:实现
MEMessageActionHandler.decideAction(for:),先检查message.subject和message.headers,需要正文时返回MEMessageActionDecision.invokeAgainWithBody。 -
做什么:拦截邮件里的远程追踪图片。 为什么值得做:Content Blocker extension 使用 Safari content blockers 的规则格式,已有 WebKit 规则可以复用。 怎么开始:实现
MEContentBlocker.contentRulesJSON(),把阻止远程图片域名的 JSON 规则放进 app bundle。 -
做什么:把企业证书体系接入 Mail 发送流程。 为什么值得做:Message Security extension 能根据当前收件人返回签名和加密状态,Mail 会用锁和证书图标反馈给用户。 怎么开始:实现
encodingStatus(for:composeContext:)检查证书可用性,再在encode(_:composeContext:)返回编码后的 RFC822 数据。 -
做什么:为加密邮件提供自定义证书详情页。 为什么值得做:Mail 允许扩展返回
MEExtensionViewController显示 signer 或 message context,企业可以展示内部证书字段。 怎么开始:解码邮件时返回 signer 信息,实现extensionViewController(signers:)并把 signer 数据填入自定义视图。
关联 Session
- Meet Safari Web Extensions on iOS — 了解 Safari Web Extensions 的跨设备扩展模型,便于对比 WebKit 内容拦截规则在 Safari 和 Mail 中的复用方式。
- Sync files to the cloud with FileProvider on macOS — FileProvider 同样使用 extension 架构把第三方服务接入系统级体验。
- Meet TestFlight on Mac — Mail extension 随 Mac app 分发,TestFlight on Mac 可用于扩展的外部测试。
- Distribute apps in Xcode with cloud signing — Mail extension 需要放进签名过的 Mac app,云签名流程能减少分发前的证书配置成本。
评论
GitHub Issues · utterances