技术演进中的开发沉思-163 java-servlet:用 Servlet 发送电子邮件
本文分享了Servlet集成邮件功能的实战经验。作者回顾了早期使用Socket直连SMTP的困境,分析了Sun的SmtpClient类和JavaMail API的优缺点,指出后者是生产环境的优选方案。详细讲解了SmtpClient维护中的常见问题(端口、编码、邮件头格式等)和JavaMail的正确使用方法,包括工具类封装、附件处理、批量发送和重试机制。最后总结了生产环境中的关键注意事项:IP黑名单
当年我第一次在 Servlet 里集成邮件功能,光是 SMTP 认证就卡了三天 —— 那时候还没现在这么多教程,全靠啃 RFC 文档和抓包调试。今天就讲讲当年用servlet发邮件的事情。

一、 可能的方案
别光看方案多,选对才是关键。我当年踩过的第一个坑就是 “图省事用了 socket 直连”,结果上线后邮件服务器一升级,协议细节变了,整个功能直接挂了。给你们掰扯掰扯这三种方案的 “实战利弊”:
- Socket 直连 SMTP:
当年年轻气盛,觉得自己能搞定协议细节,结果光是处理 “EHLO 握手”“身份认证”“换行符必须是 CRLF” 这几个点,就改了十几次代码。更要命的是,不同邮件服务器(比如阿里云、腾讯云、企业自建)的容错性不一样,有的少个空格就拒收,有的超时时间设置不一样就断连。除非你要做邮件服务器开发,否则千万别碰这个方案—— 纯属给自己找罪受。
- Sun 的 SmtpClient 类:
JDK 里自带的这个类,当年我在小型项目里用过,优点是不用引额外 jar 包,简单场景(比如发送告警邮件)能凑活用。但缺点太致命:一是在 “sun” 包下,不属于标准 API,当年换了个 IBM 的 JVM 就直接报类找不到;二是不支持 SMTP 认证(现在主流邮件服务器都要求认证,不然直接当成垃圾邮件拦截);三是要自己拼邮件头,当年漏了 “Date” 头,导致部分邮箱把邮件归为 “无效邮件”。现在新项目绝对不推荐用,除非你维护的是十年前的老系统。
- JavaMail API:
这才是生产环境的 “标准答案”。当年第一次用的时候,觉得配置麻烦,但用熟了才发现,它把协议细节全封装了,不管是 SMTP 认证、SSL 加密,还是多附件发送,都有现成的接口。最关键的是,它是标准 API,各大厂商都支持,换 JVM、换邮件服务器都不用改核心代码。记住:只要项目需要长期维护,优先选 JavaMail,别为了省事儿用那些 “野路子” 方案。
1.1 使用 SmtpClient 发送电子邮件
虽然不推荐新项目用,但很多老系统还在用这个类,我就说说维护时要注意的坑。当年我接手一个老项目,用的就是 SmtpClient,上线后天天收到 “邮件发送失败” 的反馈,最后排查出三个问题,你们维护时一定要注意:
1.1.1 关键代码补坑(老系统必改点)
原书中的 SendMailServlet 代码有几个致命漏洞,当年我就是这么改的:
package javaservlets.mail;
import javax.servlet.*;
import javax.servlet.http.*;
import sun.net.smtp.SmtpClient;
import sun.net.smtp.SmtpProtocolException; // 原书漏了这个异常捕获
import java.io.IOException;
import java.io.PrintStream;
import java.util.Date; // 用于补全邮件头
public class SendMailServlet extends HttpServlet {
public static final String MAIL_FROM = "from";
public static final String MAIL_SUBJECT = "subject";
public static final String MAIL_BODY = "body";
// 多个收件人用逗号分隔,原书硬编码收件人,实际项目建议从配置文件读
public static final String MAIL_TO = "karl@servletguru.com";
// 邮件服务器地址,别写死!当年我就是写死了,换环境就挂
public static final String MAIL_HOST = "smtp.electronaut.com";
// 新增:SMTP端口,老系统默认25,但现在很多服务商禁用25,要改587(TLS)
public static final int MAIL_PORT = 25;
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html");
PrintWriter out = resp.getWriter();
// 原书HTML没加编码,当年中文乱码,这里补charset
resp.setCharacterEncoding("UTF-8");
out.println("<!DOCTYPE html>"); // 原书漏了DOCTYPE,浏览器兼容问题
out.println("<html lang='zh-CN'>");
out.println("<head>");
out.println("<meta charset='UTF-8'>"); // 补编码 meta 标签
out.println("<title>发送邮件</title>");
out.println("</head>");
out.println("<body>");
out.println("<center><h2>给Karl Moss发送邮件</h2>");
// 原书form没加method=POST,默认GET,参数会暴露在URL里,不安全
out.println("<form method='POST' action='" + req.getRequestURI() + "' accept-charset='UTF-8'>");
out.println("<table border='1' cellpadding='5'>"); // 加边框和内边距,界面友好
out.println("<tr><td>发件人邮箱:</td><td><input type='text' name='" + MAIL_FROM + "' size='30' required></td></tr>");
out.println("<tr><td>邮件主题:</td><td><input type='text' name='" + MAIL_SUBJECT + "' size='30' required></td></tr>");
out.println("<tr><td>邮件内容:</td><td><textarea name='" + MAIL_BODY + "' cols='40' rows='6' required></textarea></td></tr>");
out.println("</table><br>");
out.println("<input type='submit' value='发送'>");
out.println("<input type='reset' value='重置'>");
out.println("</form></center>");
out.println("</body></html>");
out.flush();
}
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=UTF-8");
PrintWriter out = resp.getWriter();
// 原书没处理请求编码,中文参数乱码,当年排查了半天
req.setCharacterEncoding("UTF-8");
String from = req.getParameter(MAIL_FROM);
String subject = req.getParameter(MAIL_SUBJECT);
String body = req.getParameter(MAIL_BODY);
// 原书没做参数校验,当年有人传空值导致空指针
if (from == null || from.trim().isEmpty() || subject == null || subject.trim().isEmpty() || body == null || body.trim().isEmpty()) {
out.println("<html><body><center><h3>错误:发件人、主题、内容不能为空!</h3></center></body></html>");
out.flush();
return;
}
SmtpClient mailer = null;
PrintStream ps = null;
try {
// 原书只传了主机,没传端口,新增端口参数
mailer = new SmtpClient(MAIL_HOST, MAIL_PORT);
// 关键:原书没加EHLO握手,部分邮件服务器会拒绝连接
mailer.ehlo(MAIL_HOST);
// 注意:SmtpClient不支持SMTP认证,如果邮件服务器要求认证,这里会失败
// 当年我遇到这个问题,最后只能换JavaMail,这里提前提醒
mailer.from(from);
// 处理多个收件人,原书只传了一个,这里拆分
String[] toArr = MAIL_TO.split(",");
for (String to : toArr) {
if (to != null && !to.trim().isEmpty()) {
mailer.to(to.trim());
}
}
ps = mailer.startMessage();
// 原书漏了关键邮件头,当年导致邮件被归为垃圾邮件
ps.println("From: " + from);
ps.println("To: " + MAIL_TO);
ps.println("Subject: " + subject);
ps.println("Date: " + new Date()); // 必须加日期头,RFC822要求
ps.println("MIME-Version: 1.0"); // 支持MIME,避免内容乱码
ps.println("Content-Type: text/plain; charset=UTF-8"); // 加编码,中文不乱码
ps.println("Content-Transfer-Encoding: 8bit");
ps.println(); // 邮件头和正文之间必须空一行,否则正文会被当成头处理
ps.println(body);
mailer.closeServer();
out.println("<html><body><center><h3>邮件发送成功!</h3></center></body></html>");
} catch (SmtpProtocolException e) {
// 原书只捕了Exception,这里细分协议异常,方便排查
out.println("<html><body><center><h3>邮件发送失败:SMTP协议错误</h3>");
out.println("错误信息:" + e.getMessage() + "</center></body></html>");
e.printStackTrace();
} catch (IOException e) {
out.println("<html><body><center><h3>邮件发送失败:网络错误</h3>");
out.println("错误信息:" + e.getMessage() + "</center></body></html>");
e.printStackTrace();
} finally {
// 原书没关流,当年导致资源泄漏,这里必须关闭
if (ps != null) {
ps.close();
}
if (mailer != null) {
try {
mailer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
out.flush();
}
}
1.1.2 老系统维护避坑指南(当年的血泪经验)
端口问题:
现在主流邮件服务商(阿里云、腾讯云)都禁用 25 端口了,要用 587(TLS)或 465(SSL),但 SmtpClient 不支持 SSL/TLS,所以如果服务器要求加密,只能换 JavaMail。当年我就是因为这个,把老系统的 SmtpClient 全换成了 JavaMail。
编码问题:
一定要在邮件头加charset=UTF-8,并且请求 / 响应都设 UTF-8,不然中文肯定乱码。当年我没加,用户收到的邮件全是 “???”,排查了一天才发现是编码问题。
邮件头格式:
邮件头和正文之间必须空一行,这是 RFC822 的硬性要求,当年我漏了这行,导致正文被当成邮件头处理,用户收到的邮件只有头没有内容。
资源释放:
SmtpClient 和 PrintStream 一定要在 finally 里关闭,当年没关,导致服务器连接池被耗尽,最后重启才解决。
1.2 JavaMail API
这部分是重点,也是我现在新项目必用的方案。当年从 SmtpClient 转到 JavaMail,一开始觉得配置麻烦,但用熟了才发现有多香。下面我会把当年踩过的坑、优化的点全写进去,比原书的代码更实用。
1.2.1 前置准备
依赖引入:
现在 JavaMail 已经整合到 Jakarta EE 了,新项目用 Maven 引入更方便,注意版本别太老,当年我用 1.4.7 版本,不支持 TLS1.2,被邮件服务器拒绝:
<!-- Jakarta Mail(JavaMail的新版本,替代老的javax.mail) -->
<dependency>
<groupId>jakarta.mail</groupId>
<artifactId>jakarta.mail-api</artifactId>
<version>2.1.2</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.1.2</version>
</dependency>
配置文件:
别把邮件服务器地址、账号、密码写死在代码里!当年我写死了,换环境就要改代码、重新部署,后来改成读配置文件,舒服多了。新建src/main/resources/mail.properties:
# 邮件服务器配置
mail.transport.protocol=smtp
mail.smtp.host=smtp.qq.com # 示例:QQ邮箱SMTP服务器
mail.smtp.port=587 # TLS端口,465用SSL
mail.smtp.auth=true # 必须开启认证
mail.smtp.starttls.enable=true # 开启TLS加密(587端口用)
mail.smtp.ssl.enable=false # 465端口开启SSL,这里设true
# 邮件服务器账号密码(QQ邮箱用授权码,不是登录密码!当年坑了我)
mail.username=your_qq@qq.com
mail.password=your_auth_code
# 超时设置,避免卡住
mail.smtp.connectiontimeout=5000
mail.smtp.timeout=5000
mail.smtp.writetimeout=5000
1.2.2 封装工具类
当年我把邮件发送逻辑抽成工具类,整个项目都能复用,还方便维护:
package javaservlets.mail.util;
import jakarta.mail.*;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public class JavaMailUtil {
private static final Properties props = new Properties();
private static final String USERNAME;
private static final String PASSWORD;
// 静态代码块加载配置文件,只加载一次,避免重复IO
static {
try (InputStream in = JavaMailUtil.class.getClassLoader().getResourceAsStream("mail.properties")) {
if (in == null) {
throw new RuntimeException("找不到mail.properties配置文件!");
}
props.load(in);
USERNAME = props.getProperty("mail.username");
PASSWORD = props.getProperty("mail.password");
// 校验关键配置,避免空值
if (USERNAME == null || USERNAME.trim().isEmpty() || PASSWORD == null || PASSWORD.trim().isEmpty()) {
throw new RuntimeException("mail.username或mail.password未配置!");
}
} catch (IOException e) {
throw new RuntimeException("加载mail.properties失败:" + e.getMessage(), e);
}
}
/**
* 发送简单文本邮件
* @param to 收件人邮箱(多个用逗号分隔)
* @param subject 邮件主题
* @param content 邮件内容
* @throws MessagingException 邮件发送异常
*/
public static void sendTextMail(String to, String subject, String content) throws MessagingException {
// 创建会话,设置认证信息
Session session = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
// 这里用配置的账号密码,当年我错用了发件人邮箱,导致认证失败
return new PasswordAuthentication(USERNAME, PASSWORD);
}
});
// 开启调试模式,方便排查问题(生产环境关闭)
session.setDebug(false);
// 创建邮件消息
MimeMessage message = new MimeMessage(session);
// 设置发件人,必须和认证账号一致!当年不一致,被当成垃圾邮件
message.setFrom(new InternetAddress(USERNAME));
// 设置收件人
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
// 设置主题(处理中文,当年没处理导致主题乱码)
message.setSubject(subject, "UTF-8");
// 设置内容(文本+编码)
message.setText(content, "UTF-8");
// 设置发送时间
message.setSentDate(new Date());
// 发送邮件
Transport.send(message);
}
/**
* 发送带附件的邮件(当年项目常用功能,原书没提)
* @param to 收件人邮箱
* @param subject 主题
* @param content 正文
* @param attachPaths 附件路径(多个)
* @throws MessagingException 发送异常
* @throws IOException 附件读取异常
*/
public static void sendMailWithAttachment(String to, String subject, String content, String... attachPaths) throws MessagingException, IOException {
Session session = Session.getInstance</doubaocanvas>
Session session = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(USERNAME, PASSWORD);
}
});
session.setDebug(false);
// 创建邮件消息
MimeMessage message = new MimeMessage (session);
message.setFrom (new InternetAddress (USERNAME));
message.setRecipients (Message.RecipientType.TO, InternetAddress.parse (to));
message.setSubject (subject, "UTF-8");
message.setSentDate (new Date ());
// 构建多部分消息(正文 + 附件)
MimeMultipart multipart = new MimeMultipart ();
// 1. 添加正文部分
MimeBodyPart contentPart = new MimeBodyPart ();
// 这里用 text/html 类型,支持 HTML 格式正文(当年做激活邮件时必须用这个)
contentPart.setContent (content, "text/html;charset=UTF-8");
multipart.addBodyPart (contentPart);
// 2. 添加附件部分(当年漏了文件存在性校验,导致空指针)
for (String attachPath : attachPaths) {
File attachFile = new File (attachPath);
// 关键:校验文件是否存在、是否是文件(不是文件夹)
if (!attachFile.exists () || !attachFile.isFile ()) {
throw new IOException ("附件不存在或不是有效文件:" + attachPath);
}
// 校验文件大小,当年没限制,有人传 1G 附件导致服务器卡死
if (attachFile.length () > 1024 * 1024 * 20) { // 限制 20MB
throw new IOException ("附件超过 20MB 限制:" + attachPath);
}
MimeBodyPart attachPart = new MimeBodyPart ();
// 用 FileDataSource 读取文件,避免流处理不当导致文件损坏
DataSource dataSource = new FileDataSource (attachFile);
attachPart.setDataHandler (new DataHandler (dataSource));
// 处理附件文件名中文乱码(当年坑了我一周,必须用 MimeUtility.encodeText)
attachPart.setFileName (MimeUtility.encodeText (attachFile.getName (), "UTF-8", "B"));
multipart.addBodyPart (attachPart);
}
// 将多部分消息设置为邮件内容
message.setContent (multipart);
// 发送邮件
Transport.send (message);
}
/**
批量发送邮件(带重试机制,生产环境必备)
当年做用户注册激活邮件,并发高时经常发送失败,加了重试才稳定
@param toList 收件人列表
@param subject 主题
@param content 正文
@param retryCount 重试次数(建议 3 次以内,避免死循环)
*/
public static void batchSendMail (List toList, String subject, String content, int retryCount) {
if (toList == null || toList.isEmpty ()) {
return;
}
// 遍历收件人,逐个发送(别用群发,避免一个地址无效导致全部失败)
for (String to : toList) {
if (to == null || to.trim ().isEmpty ()) {
continue;
}
int currentRetry = 0;
while (currentRetry < retryCount) {
try {
sendTextMail (to, subject, content);
System.out.println ("邮件发送成功:" + to);
break; // 成功则跳出重试循环
} catch (MessagingException e) {
currentRetry++;
System.err.println ("邮件发送失败(第" + currentRetry + "次重试):" + to + ",错误:" + e.getMessage ());
// 重试间隔(避免频繁重试被邮件服务器拉黑,当年没加间隔被封过 IP)
try {
Thread.sleep (1000 * (currentRetry + 1)); // 重试间隔递增:1s、2s、3s...
} catch (InterruptedException ie) {
Thread.currentThread ().interrupt ();
break;
}
// 重试次数耗尽,记录日志(当年没记录,失败了找不到原因)
if (currentRetry == retryCount) {
System.err.println ("邮件发送失败(已达最大重试次数):" + to);
// 这里建议加日志框架(如 Log4j),别用 System.err
}
}
}
}
}
}
二、生产环境避坑
邮件服务器 IP 黑名单问题:
当年用阿里云 ECS 发邮件,没备案的服务器 IP 直接被腾讯、网易拉黑,发的邮件全进垃圾邮件。解决办法:要么用云服务商的邮件推送服务(如阿里云邮件推送、腾讯云邮件推送),要么给服务器 IP 备案,并且在邮件头加DKIM签名(JavaMail 可以通过message.setHeader("DKIM-Signature", "...")设置)。
并发发送问题:
当年做活动一次发 1000 封邮件,直接用循环同步发送,导致服务器卡死。正确做法:用线程池异步发送,比如用ThreadPoolExecutor,但要控制线程数(建议 5-10 个线程,太多会被邮件服务器当成攻击)。
密码安全问题:
当年把邮件服务器密码明文写在配置文件里,被安全审计查出问题。解决办法:用加密工具(如 Jasypt)加密密码,读取配置时解密,或者把密码存在环境变量里,别写死在文件里。
异常监控问题:
当年邮件发送失败没加监控,用户投诉了才知道。现在都会集成监控告警(如 Prometheus+Grafana),把发送失败率作为指标,超过阈值就发短信 / 钉钉告警。
协议版本问题
当年用 JavaMail 1.4.7 连接 Office 365 邮箱,因为不支持 TLS1.2 被拒绝。解决办法:升级 JavaMail 到 2.x 版本,并且在 JVM 参数里加-Dmail.smtp.ssl.protocols=TLSv1.2强制指定协议版本。
小结
发送邮件这事儿,技术不难,但要在生产环境跑稳,全靠细节。当年我从 SmtpClient 踩坑到 JavaMail,再到现在用云服务商的邮件 API,最大的体会是:别重复造轮子,优先用成熟的方案;别忽视细节,比如编码、附件清理、重试机制,这些都是生产环境稳定的关键。
如果是维护老系统,遇到 SmtpClient 的问题,能换 JavaMail 就赶紧换;如果是新建项目,直接用云服务商的邮件推送 API(比自己搭 JavaMail 更稳定,还不用关心 IP 黑名单问题)。记住:程序员的核心是解决问题,不是跟底层协议死磕 —— 当年我要是早明白这点,能少熬不少夜。未完待续...........
更多推荐


所有评论(0)