当年我第一次在 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 黑名单问题)。记住:程序员的核心是解决问题,不是跟底层协议死磕 —— 当年我要是早明白这点,能少熬不少夜。未完待续...........

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐