ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android 无服务端任意邮箱 SMTP 发信源码解析与避坑

Android 无服务端任意邮箱 SMTP 发信源码解析与避坑

简介:这份面向Android开发者的邮件发送源码项目,围绕“不依赖手机自带或第三方邮件客户端、直接向任意邮箱发信”的需求展开,适合想理解SMTP协议与JavaMail在移动端落地方式的初中级开发者参考。压缩包共60个文件、约3.81MB,包含java与class源码、xml布局与清单、png界面资源,以及mail、activation等jar依赖和可运行apk,项目结构覆盖界面层、业务实现与依赖库。已有2048人学习,说明该场景具有一定共性。读者可从中梳理SMTP服务器的连接、认证与发信流程,观察JavaMail相关jar的引入和配置方式,并对照APK验证实际效果;同时可借鉴在无法预装邮件客户端时完成邮件发送的排错思路与工程组织方式。

1. 一个没有服务端的 App,怎么把「任意邮箱发送邮件」这件事做完

上个月有个做门店巡检工具的同行找我,需求很朴素:巡检表提交之后,自动给三个店长邮箱发一封带照片附件的邮件。问题在于他们整套东西是纯客户端,没有后端,为发一封信去申请一台服务器、走一遍上线流程,比功能本身还重。他后来翻到一份 android 源码,标题就叫「支持任意邮箱发送邮件功能」,把 SMTP 会话、MimeMessage 组装、附件投递、异步回调、异常映射全收在一套实现里,换 host、端口、账号就能从个人邮箱切到企业邮箱。这篇就把这份源码拆开讲:它到底解决了什么、参数怎么填、哪些地方一定翻车。适合小团队、外包交付、内网工具和原型验证;高频群发、要送达率统计的场景别用它。

2. 拿到源码先别改业务:四个模块与一次发信的调用链

很多人拿到这类源码包的第一反应是直接打开 Activity 改账号密码,跑起来发现连不上,然后开始怀疑代码有问题。实际上这类包能不能跑通,八成取决于你对它的分层有没有概念——哪一层是配置、哪一层是协议、哪一层是业务,分清楚了,出问题时才知道该去哪儿看。

2.1 源码包的四个模块与调用链

这份 android 源码的目录结构大致是四块,命名可能各家不同,但职责划分基本一致:

模块典型类职责改动频率
配置层SmtpConfig、SslMode 枚举保存 host、端口、账号、超时、加密方式高,换邮箱就改这里
会话层SessionFactory把配置翻译成 JavaMail 的 Properties 并构建 Session低,一次写对就不用动
组装层Mail、MailBuilder收件人、抄送、主题、正文、附件、编码中,业务字段变化时改
发送层SmtpSender、SendResult建立 Transport、投递、异常归类、回调低

一次发信的调用链是:业务侧构造一个Mail对象 →MailBuilder.build()把它变成MimeMessage→SmtpSender.send()拿到 Session、开 Transport、connect、sendMessage → 结果封装成SendResult回抛。这条链上只有第一步和最后一步跟业务有关,中间全是协议细节,所以真正需要你理解的只有两个对象:Properties和MimeMessage。

我一般会先把源码包里的 Demo 入口跑一遍,用一个自己的邮箱给自己发一封纯文本,确认链路通,再去改业务字段。这一步看着笨,但能帮你把「环境问题」和「代码问题」隔离开——后者才是你要花时间的地方。

2.2 依赖:JavaMail 在 Android 上有两个必改点

这是最容易踩的第一道坎。桌面端的 JavaMail 直接搬到 Android 上,常见两种失败:一是编译期找不到javax.activation.DataSource,二是运行期和系统里的包打架,抛ClassNotFoundException或者更玄学的NoClassDefFoundError。

正确做法是用 Android 专供的构建产物,它把 activation 一起打进去了:

android { // JavaMail 的 jar 里带了一堆 META-INF 许可证文件, // 不排除会在打包阶段报 "More than one file was found with OS independent path" packaging { resources { excludes += [ "META-INF/LICENSE.txt", "META-INF/NOTICE.txt", "META-INF/DEPENDENCIES", "META-INF/mailcap" ] } } } dependencies { // android- 前缀的两个包必须成对出现,版本保持一致 implementation("com.sun.mail:android-mail:1.6.7") implementation("com.sun.mail:android-activation:1.6.7") }

两个包的版本号一定要一致,混用会出现MimeMessage能创建、一setContent就崩的怪现象。如果你的工程开了 core library desugaring 且 minSdk 比较高,也可以走 jakarta 命名空间的新版本,但那份源码包里的 import 是javax.mail.*,换包名意味着整包搜索替换,收益不大,除非你有明确的 TLS 1.3 需求。

提示:packaging块在旧版 AGP 里叫packagingOptions,且语法是exclude 'META-INF/LICENSE.txt'单条写。直接抄上面这段到老工程里会编译不过,按你工程的 AGP 版本改。

2.3 权限、混淆与一个常被误改的开关

权限这块其实很简单,只需要两条,而且都是普通权限,不需要运行时申请:

<uses-permission android:name="android.permission.INTERNET" /> <!-- 用于发送前判断网络是否可用,省掉一次必然超时的等待 --> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

真正让人踩坑的是混淆。Debug 包一切正常,Release 包一点发送就崩,日志里只有一行javax.mail.MessagingException: IOException while sending message,看不出所以然——这是 R8 把 JavaMail 里的反射入口和 ServiceLoader 配置裁掉了。加规则:

# 保留 JavaMail 与 activation 的全部实现,别做精细化裁剪 -keep class javax.mail.** { *; } -keep class javax.activation.** { *; } -keep class com.sun.mail.** { *; } -dontwarn javax.mail.** -dontwarn javax.activation.** -dontwarn com.sun.mail.** # 保留 META-INF 下的服务发现文件 -keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod

还有一个流传很广的误解:很多人连不上就去找android:usesCleartextTraffic="true",或者往 network security config 里加 domain。这个开关管的是 HTTP 栈,SMTP 走的是裸 Socket,改它一点用没有。你的 25 端口要是被运营商拦了,加一百条配置也连不上,只能换 465 或 587。

3. SMTP 会话参数:host、端口、加密方式怎么填才不发呆

会话层的输入是一张参数表,输出是一个Session。这张表填错任何一项,症状都不一样,但错误信息往往都长成Couldn't connect to host或者535 Authentication failed,非常不具体。所以与其背参数,不如记住「哪个参数错了会报什么」,排查时反着推。

3.1 一份能直接抄的参数表

参数典型值作用填错的症状
mail.smtp.hostsmtp.+ 邮箱域名服务器地址UnknownHostException,或连到错误的机器
mail.smtp.port465 / 587 / 25提交端口ConnectException、连接超时
mail.smtp.authtrue是否做身份认证服务端回 530/550 拒绝投递
mail.smtp.ssl.enable465 端口时true建连即 TLS(隐式加密)SSLHandshakeException
mail.smtp.starttls.enable587 端口时true明文连上后升级为 TLS服务端回Must issue a STARTTLS command first
mail.smtp.ssl.checkserveridentitytrue校验证书里的域名默认 false,等于裸奔
mail.smtp.connectiontimeout15000建连超时(毫秒)卡住不返回,用户以为死机
mail.smtp.timeout20000读超时(毫秒)SocketTimeoutException
mail.smtp.writetimeout20000写超时(毫秒)发大附件时长时间无响应
mail.smtp.ssl.protocolsTLSv1.2 TLSv1.3限制协商版本老设备握手失败,报Connection closed by peer

关于host有个省事的经验:大多数服务商的 SMTP 主机就是把邮箱地址@后面的域名前面加smtp.。这条规律覆盖了绝大多数个人邮箱和不少企业邮箱,剩下那些不符合的(比如部分企业邮用的是独立的提交域名),去服务商帮助页搜「SMTP 服务器地址」一般都能查到。别凭印象填,我见过把pop.当成smtp.填进去、排查了两小时的。

3.2 构建 Session 的代码与逐项说明

enum class SslMode { SSL, STARTTLS, NONE } data class SmtpConfig( val host: String, val port: Int, val user: String, // 完整邮箱地址 val password: String, // 多数服务商这里是「授权码」,不是登录密码 val sslMode: SslMode, val connectTimeoutMs: Int = 15_000, val readTimeoutMs: Int = 20_000, val writeTimeoutMs: Int = 20_000 ) fun buildSession(c: SmtpConfig): Session { val props = Properties().apply { put("mail.smtp.host", c.host) put("mail.smtp.port", c.port.toString()) put("mail.smtp.auth", "true") // 三个超时都要给,缺一个就可能出现「一直转圈」的界面 put("mail.smtp.connectiontimeout", c.connectTimeoutMs.toString()) put("mail.smtp.timeout", c.readTimeoutMs.toString()) put("mail.smtp.writetimeout", c.writeTimeoutMs.toString()) // 证书域名校验,默认是 false,务必显式打开 put("mail.smtp.ssl.checkserveridentity", "true") when (c.sslMode) { SslMode.SSL -> { put("mail.smtp.ssl.enable", "true") } SslMode.STARTTLS -> { put("mail.smtp.starttls.enable", "true") // required=true 时,服务端不支持 STARTTLS 会直接失败而不是降级明文 put("mail.smtp.starttls.required", "true") } SslMode.NONE -> { put("mail.smtp.ssl.enable", "false") } } } return Session.getInstance(props, object : Authenticator() { override fun getPasswordAuthentication() = PasswordAuthentication(c.user, c.password) }).apply { // Debug 包打开协议对话日志,Release 一定关掉,否则会把授权码打进日志 debug = BuildConfig.DEBUG } }

几点说明。第一,Authenticator里返回的必须是完整邮箱地址作为用户名,有些服务商允许只填@前面的部分,有些不允许,统一填完整的省事。第二,starttls.required这个参数很关键:不设它的时候,服务端如果不支持 STARTTLS,JavaMail 会悄悄降级成明文继续发,账号密码就直接裸奔在网络上;设成true之后是「要么加密要么失败」,这是安全边界,别省。第三,debug = true会把整个 SMTP 对话打到 logcat,包括AUTH LOGIN之后的 base64 凭据,调试期很方便,上线前一定确认它跟着BuildConfig.DEBUG走。

3.3 465、587、25 三条端口的连接差异

这三个端口不是随便选的,它们对应三种完全不同的握手方式,代码分支也不一样:

  • 465(隐式 TLS):TCP 连上之后立刻就是 TLS 握手,整个会话都在加密通道里。对应mail.smtp.ssl.enable = true。
  • 587(提交端口):先明文连上,服务端回250 STARTTLS,客户端发STARTTLS命令,升级成 TLS,然后再认证。对应mail.smtp.starttls.enable = true,并且ssl.enable必须是 false。
  • 25(传统端口):绝大多数情况下是明文,很多运营商和云主机会直接封禁出站 25 端口,表现是连接超时而不是拒绝。

最常见的翻车组合是:端口填 465,但只开了starttls.enable。这时候客户端会明文连上去等220问候,而对端在等 TLS ClientHello,双方互相等,最后超时。反过来,端口 587 只开ssl.enable = true,会立刻收到SSLHandshakeException。所以配置里我把SslMode做成枚举,就是强制让端口和加密方式绑定,避免有人手动改一半。

如果要在运行时做兜底,可以按 465 → 587 的顺序试两轮,两次都失败再报错。这种做法在产品里很实用,因为用户填的邮箱五花八门,你不知道他那边到底开的是哪个端口:

suspend fun connectWithFallback(base: SmtpConfig): Transport { val candidates = listOf( base.copy(port = 465, sslMode = SslMode.SSL), base.copy(port = 587, sslMode = SslMode.STARTTLS) ) var last: Exception? = null for (cfg in candidates) { try { val session = buildSession(cfg) return session.getTransport("smtp").apply { // 注意:connect(host, port, user, pwd) 会覆盖 Session 里配的 host/port connect(cfg.host, cfg.port, cfg.user, cfg.password) } } catch (e: MessagingException) { last = e // 认证类错误重试另一个端口也没用,但这里先都试一遍 } } throw last ?: MessagingException("connect failed") }

connect(host, port, user, password)这个重载有个容易忽略的行为:它会用传进去的 host 和 port 覆盖 Session 里的设置。所以如果你在 Session 里配了超时,这些超时仍然生效;但如果你在 Session 里配了ssl.enable,走的还是那个协议栈,端口和加密方式必须自己保证一致,光改 port 不改 props 是无效的。

4. 邮件组装与异步发送:正文、HTML、附件与回调

会话通了之后,剩下的活儿就是把内容塞进MimeMessage。这一步看着简单,实际上大部分「收到了但显示不对」的问题都出在 MIME 结构上,而不是发送环节。

4.1 MimeMessage 组装:纯文本、HTML、混合三种形态

先看数据模型和组装代码:

data class Mail( val from: String, val fromName: String = "", val to: List<String>, val cc: List<String> = emptyList(), val subject: String, val textBody: String = "", val htmlBody: String = "", val attachments: List<File> = emptyList() ) fun buildMessage(session: Session, mail: Mail): MimeMessage { val msg = MimeMessage(session) // 发件人显示名要单独编码,直接拼进地址串里遇到逗号会被截断 msg.setFrom(InternetAddress(mail.from, mail.fromName, "UTF-8")) msg.setRecipients(Message.RecipientType.TO, mail.to.joinToString(",")) if (mail.cc.isNotEmpty()) { msg.setRecipients(Message.RecipientType.CC, mail.cc.joinToString(",")) } // 第二个参数指定字符集,不写就是平台默认,中文必乱 msg.setSubject(mail.subject, "UTF-8") msg.setSentDate(Date()) if (mail.attachments.isEmpty()) { // 没有附件时,正文用 alternative 承载「纯文本 + HTML」两个版本 val alt = MimeMultipart("alternative") if (mail.textBody.isNotEmpty()) { alt.addBodyPart(MimeBodyPart().apply { setText(mail.textBody, "UTF-8") }) } if (mail.htmlBody.isNotEmpty()) { alt.addBodyPart(MimeBodyPart().apply { setContent(mail.htmlBody, "text/html; charset=UTF-8") }) } msg.setContent(alt) } else { // 有附件时,外层用 mixed 包住「正文 alternative」和「各个附件」 val mixed = MimeMultipart("mixed") val bodyPart = MimeBodyPart() val alt = MimeMultipart("alternative") if (mail.textBody.isNotEmpty()) { alt.addBodyPart(MimeBodyPart().apply { setText(mail.textBody, "UTF-8") }) } if (mail.htmlBody.isNotEmpty()) { alt.addBodyPart(MimeBodyPart().apply { setContent(mail.htmlBody, "text/html; charset=UTF-8") }) } bodyPart.setContent(alt) mixed.addBodyPart(bodyPart) mail.attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { // 用 DataSource 挂文件,避免整份读进内存 dataHandler = DataHandler(FileDataSource(file), "application/octet-stream") // 中文文件名必须编码,否则在部分客户端会变成一串问号或被截断 fileName = MimeUtility.encodeText(file.name, "UTF-8", "B") }) } msg.setContent(mixed) } msg.saveChanges() // 生成 Message-ID、Date 等头部,漏了会出现部分服务端拒信 return msg }

这里最容易写错的是嵌套层级。不少源码包图省事,把所有内容(文本、HTML、附件)平铺在同一个multipart里,结果就是客户端把附件当正文显示,或者正文变成一串附件名。正确的结构是固定的两层:mixed里第一个 part 是alternative(依次放纯文本和 HTML),后面每个 part 是一个附件。邮件客户端按这个约定渲染,顺序反过来也可能出问题。

还有saveChanges(),它对 JavaMail 来说不是可选项。它的作用是补齐Message-ID、Date、MIME-Version这些头部,某些服务端看到缺少Message-ID会直接判为垃圾邮件拒收,症状是发送成功但对方永远收不到——这种问题最难查,因为没有异常。

4.2 线程与回调:把发送从主线程挪走

SMTP 是一次真实的网络往返,包含 TCP 建连、TLS 握手、认证、传输,在移动网络下动辄三五秒,弱网能到二三十秒。放主线程就是NetworkOnMainThreadException,很多人为了绕过它加个StrictMode.setThreadPolicy允许主线程网络,这是在给自己埋 ANR 的雷。

标准做法是固定线程池或者Dispatchers.IO,把结果切回主线程:

object SmtpSender { // 并发不要开大,很多服务商对同一账号的并发连接数有限制 private val executor = Executors.newFixedThreadPool(2) private val mainHandler = Handler(Looper.getMainLooper()) fun send(config: SmtpConfig, mail: Mail, callback: (SendResult) -> Unit) { executor.execute { val result = try { val session = buildSession(config) val msg = buildMessage(session, mail) val transport = session.getTransport("smtp") try { transport.connect(config.host, config.port, config.user, config.password) transport.sendMessage(msg, msg.allRecipients) SendResult.Success(msg.messageID) } finally { runCatching { transport.close() } // close 失败不影响发送结论 } } catch (e: AuthenticationFailedException) { SendResult.AuthFailed(e.message ?: "") } catch (e: SendFailedException) { SendResult.Rejected(e.invalidAddresses?.mapNotNull { it.toString() } ?: emptyList()) } catch (e: MessagingException) { if (e.cause is SocketTimeoutException) SendResult.Timeout else SendResult.Unknown(e.message ?: "") } mainHandler.post { callback(result) } } } }

并发数定成 2 是有意的。我试过按需 new Thread 的写法,在批量发日报的场景下同一账号十几个连接同时发起,直接触发服务端的连接频率限制,表现是一半成功一半421 Too many connections。发信是个低频操作,串行 + 小并发反而是最稳的。

4.3 结果判定与重试边界

transport.sendMessage()没有返回值,不抛异常就算成功。但这只代表「服务端接受了这封信」,不代表「对方收到了」。你要接受这个语义边界:SMTP 直连只能保证投递到服务器,后面的路由、过滤、进垃圾箱,客户端一无所知。所以业务侧不要用发信接口做「用户邮箱是否真实存在」的校验,那本来就不是它能干的事。

重试也要分类型,不能无脑重试:

结果类型是否重试理由
连接超时 / 读超时可以,退避 3~5 秒弱网抖动,换网络后大概率成功
认证失败(535)不要授权码或配置错,重试一百次也一样
收件人被拒(550/551)不要地址无效或对方拒收
被限流(421/450)可以,退避 30 秒以上频率限制,立刻重试只会更糟

SendFailedException里带了validUnsentAddresses、invalidAddresses和validSentAddresses三个数组,这是它比笼统的MessagingException有价值的地方——你可以精确知道哪几个收件人成功了、哪几个被拒,多收件人场景下按人回报状态,比整单失败要好得多。

5. 避坑与排查:五条血泪记录

这一节按「现象 → 原因 → 解决」写,都是我在对接各种邮箱时反复遇到的,按发生频率排序。

第一条:535 Authentication failed,账号密码明明是对的。

现象是connect阶段直接抛AuthenticationFailedException,debug日志里能看到535 Error: authentication failed。原因通常是三种:一是用了邮箱的登录密码而不是「授权码」,现在多数服务商在第三方客户端登录时都要求单独生成的授权码;二是账号根本没开 SMTP 服务,需要在邮箱设置里手动开启;三是账号被风控锁了,比如异地登录触发保护。解决顺序是先登录网页版邮箱把 SMTP 服务和授权码开了,拿到授权码后重新填。这里的坑在于「授权码」经常是一串十几位的随机字符,用户复制时常带上空格,代码里一定要做trim(),我见过因为这个多花了半天的。

第二条:Debug 包正常,Release 包一发送就崩。

现象是混淆后的包在setContent或transport.connect处抛NoClassDefFoundError,堆栈指向javax.activation或com.sun.mail.util。原因就是 R8 把 JavaMail 基于反射和 ServiceLoader 的入口裁掉了,它不像普通业务代码那样有静态引用可循。解决办法是把第 2.3 节那段 keep 规则原样加上,别做精细化裁剪。判断是不是这个问题有个快招:临时关掉minifyEnabled打一个包,如果好了,就是混淆无疑。

第三条:主线程发送,界面卡住然后 ANR。

现象是点了发送按钮转圈,三五秒后系统弹「应用无响应」,或者直接NetworkOnMainThreadException。原因是在 UI 线程里做了 TCP 建连。这不是玄学,是 Android 的硬性限制。解决办法是把send整个包进线程池或Dispatchers.IO,回调通过 Handler 或withContext(Dispatchers.Main)切回来。有个细节要注意:Session和MimeMessage都不是线程安全的,别为了省事在多线程间共享同一个MimeMessage实例,每次发信重新 build 一份。

第四条:小附件没问题,几兆的附件就超时。

现象是纯文本秒发成功,带一张 3MB 的图就卡住,最后SocketTimeoutException或者MessagingException: Exception reading response。原因有两层:一是把整个文件读成byte[]再塞进ByteArrayDataSource,内存翻倍,大文件直接 OOM;二是没设mail.smtp.writetimeout,写阻塞的时候永远不返回。解决是用FileDataSource走流式读取,同时把写超时设成 20 秒以上。另外要记住附件编码后体积会膨胀约三分之一,服务商那边一般有单封邮件总大小限制,超了会被拒,这个限制只能靠业务侧事先校验文件大小,代码里绕不过去。

第五条:中文主题、中文附件名在部分客户端乱码。

现象是自己用某个客户端看正常,换个客户端主题变成=?UTF-8?B?...?=一串,或者附件名变成问号。原因是编码没指定或者指定了但没做传输编码。解决是三处都要显式写UTF-8:setSubject(subject, "UTF-8")、InternetAddress(address, name, "UTF-8")、附件名用MimeUtility.encodeText(name, "UTF-8", "B")。还有个小坑:发件人显示名里如果有英文逗号,不编码会被当成地址分隔符,收件人列表直接错乱。

6. 进阶:把它收成一个能自检的模块

源码包能跑通只是及格线,真正决定这套东西好不好用的是「换一个陌生邮箱,能不能五分钟内配好」。我现在接这类需求,一定会额外写一个只连接、不发信的探活方法,把它放在设置页的「测试连接」按钮上:

// 只验证 host/端口/加密/凭据是否可用,不产生任何邮件 suspend fun probe(config: SmtpConfig): SendResult = withContext(Dispatchers.IO) { try { val session = buildSession(config) val transport = session.getTransport("smtp") try { transport.connect(config.host, config.port, config.user, config.password) SendResult.Success("ok") } finally { runCatching { transport.close() } } } catch (e: AuthenticationFailedException) { SendResult.AuthFailed(e.message ?: "") } catch (e: MessagingException) { SendResult.Unknown(e.message ?: "") } }

这个方法的价值在于分离变量:发送失败时你分不清是连接问题还是内容问题,探活失败则一定是连接或凭据问题。配合debug = true打出来的协议对话,220、250-AUTH、235这几行能把问题定位到具体阶段。

上线前的自检我一般按这张表走一遍:

检查项合格标准
探活465 和 587 至少一条通
纯文本发信主流客户端都能正常显示中文主题和正文
HTML 发信图片不是附件、样式不塌
附件5MB 附件能发出去,文件名不乱码
弱网飞行模式切换后能给出明确错误而不是卡死
Release 包混淆后功能与 Debug 一致
日志Release 包里搜不到授权码

最后说个我自己的习惯。早些年我图快,直接把账号密码写进BuildConfig,觉得反正是内部工具,结果一个测试包流出去,那个邮箱被拿去当发信源用了两周才发现。从那以后我每次接发信需求,都强制走一遍「配置外置 + 探活 + 日志脱敏」这三步,哪怕需求再小也不省。这份 android 源码里那套SmtpConfig加SslMode的写法之所以值得抄,不是因为代码多精巧,而是它逼着你把凭据和协议参数从业务代码里拎出来,后面换邮箱、加账号、做灰度都只改一处。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进