日常工作中如何对接邮箱服务商的 SMTP 接口?

0 阅读21分钟

日常工作中如何对接邮箱服务商的 SMTP 接口?

作为摸爬滚打 8 年的 Java 后端开发,对接邮箱服务商 SMTP 接口是日常工作中最基础也最容易踩坑的需求 —— 小到用户注册验证码、找回密码邮件,大到企业批量通知、业务报表推送,几乎所有项目都绕不开。

我先后对接过阿里云、腾讯云、网易、SendCloud 等主流邮箱服务商的 SMTP 接口,踩过授权码失效、端口被封、邮件乱码、发信被进垃圾箱、批量发信触发限流的各种坑,也沉淀了一套标准化、可落地、高可用的对接方案。

今天就从日常开发的实际场景出发,不讲纯理论,只讲干货 —— 从对接前的准备、企业级技术选型、0 到 1 实战开发、性能优化,到日常工作中最常踩的坑和避坑方案,全程贴可直接复制运行的代码,让你一次搞定 SMTP 接口对接,再也不用反复查文档、排错。

一、对接前必做 4 件事:别上来就写代码!

初级开发对接 SMTP 的通病:拿到服务商文档就直接撸 Spring Mail 代码,结果要么连不上服务器,要么发信被拒,回头返工改到吐。

资深开发的做法是先准备再编码,针对 SMTP 接口,这 4 件事花 10 分钟做好,后续开发零返工,这也是我日常工作中对接第三方基础接口的通用原则。

1. 先梳理清楚业务发信场景

SMTP 对接的核心是匹配业务场景,不同场景决定了后续的开发方式和优化策略,日常工作中主要分这 4 类,提前定好:

发信场景核心要求示例
单发触发式高实时性、高成功率,无延迟用户注册验证码、找回密码、订单通知
批量通知式控频限流,避免触发服务商限制企业每日业务报表、会员批量通知
带附件 / HTML 邮件格式兼容,无乱码带 Excel 报表的邮件、带图文的营销邮件
系统告警邮件高可用,失败需重试 / 告警服务宕机、接口异常的告警通知

核心原则核心邮件(验证码、告警)优先保证成功率和实时性,非核心邮件(营销、普通通知)做异步 + 限流,避免占用主业务资源。

2. 吃透服务商 SMTP 核心文档(抠这 6 个细节)

所有邮箱服务商的 SMTP 文档都大同小异,重点盯紧6 个核心细节,别漏看,这是对接成功的关键:

核心项关注重点主流服务商示例(阿里云 / 腾讯云)
SMTP 服务器地址区分普通 / SSL/TLS地址,禁止用 HTTP阿里云:smtp.aliyun.com;腾讯云:smtp.qq.com
端口 & 加密方式这俩是绑定的,错一个就连不上465 端口 + SSL(主流推荐)、587 端口 + STARTTLS、25 端口(易被封,不推荐)
授权方式绝对不是邮箱登录密码,是专用授权码所有服务商均要求开启 SMTP 服务,生成专用授权码(登录邮箱后台设置)
发信限制单账号日发信量、单封邮件收件人数、附件大小限制阿里云普通账号:日发 500 封,单封最多 50 人,单附件≤50M
限流规则单 IP / 单账号的发信频率限制,避免超流被封腾讯云:单账号每分钟最多发 20 封,批量发需间隔
域名要求自定义发件人域名是否需要备案 / 解析企业自有域名发信,需做 SPF/DKIM/DMARC 解析(提升送达率,避免进垃圾箱)

小技巧:把服务商的 SMTP 配置整理成表格,存在项目 wiki 里,后续换服务商直接替换,日常开发不用反复查文档。

3. 申请服务商资源并完成基础配置

提前在邮箱服务商后台完成 3 件事,缺一不可:

  1. 开启 SMTP 服务:登录企业邮箱 / 个人邮箱后台(如阿里云邮箱 console、QQ 邮箱设置),找到 POP3/SMTP 服务,开启并生成专用授权码(保存好,后续配置用,丢了要重新生成);
  2. 域名配置(企业场景必做) :用企业自有域名作为发件人域名(如 no-reply@yolo.com),在域名解析后台做SPF、DKIM、DMARC 解析(服务商有现成的解析教程,复制解析记录即可)—— 这步不做,邮件大概率进垃圾箱;
  3. IP 白名单:部分企业级邮箱服务商(如阿里云企业邮)会限制发信 IP,把应用服务器的出口 IP 加入服务商白名单,避免被拒绝连接。

4. 先做通联测试:排除网络 / 配置问题

编码前用简单工具测通 SMTP 服务器,避免写了半天代码发现是网络问题,日常工作中我常用 2 种方式:

  1. telnet 命令快速测连通性(服务器端执行,无需安装工具):

    # 格式:telnet SMTP服务器 端口
    telnet smtp.aliyun.com 465
    # 连接成功会返回类似 "Connected to smtp.aliyun.com" 的提示
    
  2. 用服务商自带的测试工具:比如阿里云邮箱、QQ 邮箱后台都有「发测试邮件」功能,直接发一封,能收到说明基础配置、网络都没问题。

核心目的:提前排除服务器不可达、端口被封、授权码错误、IP 未白名单这些低级问题,让后续开发只关注代码逻辑。

二、企业级技术选型:日常工作中最主流的栈,轻量又好用

对接 SMTP 接口,Java 生态里有原生 JavaMailSpring Mail两种选择,日常工作中绝对不要用原生 JavaMail—— 繁琐的连接管理、邮件对象构建、异常处理能让你写吐,而 Spring Mail 是 Spring 对 JavaMail 的轻量封装,简化了 90% 的重复代码,是企业级开发的标配。

结合我 8 年的开发经验,给出日常工作中最通用的企业级技术栈,适配 99% 的 Spring Boot 项目,轻量、易集成、可扩展:

技术组件选型理由版本建议
核心框架Spring Boot 2.7.x/3.x项目主流版本,兼容性好
邮件核心Spring Mail封装 JavaMail,简化邮件发送、附件处理、HTML 邮件构建
配置管理本地配置 + Apollo/Nacos开发 / 测试用本地配置,生产用配置中心(加密存储授权码,动态修改发信配置)
异步处理Spring @Async / 自定义线程池批量发信、非核心邮件异步发送,避免阻塞主业务
日志监控SLF4J+Logback + Prometheus+Grafana日志脱敏收件人 / 发件人,监控发信成功率 / 失败率,异常告警
附件处理JavaMail MimeMessageSpring Mail 底层依赖,原生支持各种类型附件、多附件处理

核心原则不引入多余的中间件,基于项目现有技术栈即可,对接 SMTP 只是基础功能,没必要为了发邮件单独加框架。

三、实战开发:0 到 1 落地 SMTP 对接(附可直接运行代码)

Spring Boot 2.7.x为基础,用Spring Mail实现 SMTP 对接,覆盖日常工作中单发纯文本、单发 HTML、单发带附件、批量发信4 种核心场景,代码全程贴,可直接复制到项目中使用,改改配置就能跑。

步骤 1:引入核心依赖(pom.xml)

只需要引入spring-boot-starter-mail即可,Spring Boot 会自动装配相关 Bean,无需手动配置 JavaMail:

<!-- Spring Mail 核心依赖(自动依赖JavaMail) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 工具类:简化字符串、附件处理(可选,项目一般都有) -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.11</version>
</dependency>
<!-- 配置中心(生产环境用,开发可注释) -->
<dependency>
    <groupId>com.ctrip.framework.apollo</groupId>
    <artifactId>apollo-client</artifactId>
    <version>1.9.1</version>
</dependency>

步骤 2:配置 SMTP 参数(开发 / 生产分离)

开发环境:直接写在 application.yml 中;生产环境:所有参数(尤其是授权码)移到 Apollo/Nacos 配置中心,绝对禁止硬编码授权码 / 密码

这里给出完整的配置示例,包含核心的 SMTP 配置、连接池配置(性能优化关键),适配 SSL 加密(465 端口,主流推荐):

# 邮件SMTP核心配置
spring:
  mail:
    # 发件人邮箱
    username: no-reply@yolo.com
    # SMTP专用授权码(生产环境配置中心加密存储)
    password: xxxxxxxxxx
    # SMTP服务器地址(阿里云示例,腾讯云替换为smtp.qq.com)
    host: smtp.aliyun.com
    # 端口(465=SSL,587=STARTTLS,25不推荐)
    port: 465
    # 加密方式(SSL/STARTTLS,和端口绑定)
    protocol: smtp
    properties:
      mail:
        smtp:
          # 开启SSL加密
          ssl:
            enable: true
            required: true
          # 开启连接池(核心优化,避免频繁创建连接)
          pool:
            enable: true
            # 连接池最大连接数
            max-active: 10
            # 连接池最大空闲连接数
            max-idle: 5
            # 连接池最小空闲连接数
            min-idle: 2
            # 连接最大存活时间(秒)
            max-wait: 3000
          # 超时配置(日常工作中建议短一点,避免阻塞)
          connect-timeout: 5000
          timeout: 5000
          write-timeout: 5000
        # 发件人昵称编码(避免乱码,核心配置)
        mime:
          charset: UTF-8
          encode: true
    # 发件人昵称(显示在收件人邮箱的发件人名称)
    nick-name: 某某科技业务通知

步骤 3:封装邮件配置类(可选,生产环境推荐)

生产环境中,建议封装一个邮件配置类,统一读取配置中心的参数,方便后续扩展(比如对接多服务商):

@Data
@Configuration
@ConfigurationProperties(prefix = "spring.mail")
public class MailConfig {
    /**
     * 发件人邮箱
     */
    private String username;
    /**
     * SMTP授权码
     */
    private String password;
    /**
     * SMTP服务器地址
     */
    private String host;
    /**
     * 端口
     */
    private Integer port;
    /**
     * 发件人昵称
     */
    private String nickName;
    /**
     * 协议
     */
    private String protocol;
}

步骤 4:核心邮件工具类(封装所有发信场景)

这是日常工作中最核心的代码,封装了单发纯文本、单发 HTML、单发带附件、批量发信4 种场景,做了乱码处理、异常封装、发件人昵称编码,主业务层直接注入调用即可,解耦且易用:

@Component
@Slf4j
public class MailService {
    /**
     * Spring Mail 核心发送器
     */
    @Resource
    private JavaMailSender mailSender;
    /**
     * 邮件配置
     */
    @Resource
    private MailConfig mailConfig;

    /**
     * 1. 单发纯文本邮件(日常用最多,如验证码、简单通知)
     * @param to 收件人邮箱
     * @param subject 邮件主题
     * @param content 邮件内容
     */
    public void sendSimpleMail(String to, String subject, String content) {
        try {
            SimpleMailMessage message = new SimpleMailMessage();
            // 发件人(配置中心读取)
            message.setFrom(getEncodeSender());
            // 收件人
            message.setTo(to);
            // 邮件主题
            message.setSubject(subject);
            // 邮件内容
            message.setText(content);
            // 发送时间
            message.setSentDate(new Date());
            // 执行发送
            mailSender.send(message);
            log.info("纯文本邮件发送成功,收件人:{},主题:{}", desensitizeEmail(to), subject);
        } catch (Exception e) {
            log.error("纯文本邮件发送失败,收件人:{},主题:{}", desensitizeEmail(to), subject, e);
            throw new RuntimeException("邮件发送失败:" + e.getMessage());
        }
    }

    /**
     * 2. 单发HTML邮件(带图文、样式,如营销邮件、报表通知)
     * @param to 收件人邮箱
     * @param subject 邮件主题
     * @param htmlContent HTML内容(支持CSS、图片)
     */
    public void sendHtmlMail(String to, String subject, String htmlContent) {
        try {
            // 复杂邮件用MimeMessage
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
            // 发件人(编码避免昵称乱码)
            helper.setFrom(getEncodeSender());
            helper.setTo(to);
            helper.setSubject(subject);
            // 开启HTML模式
            helper.setText(htmlContent, true);
            helper.setSentDate(new Date());
            // 执行发送
            mailSender.send(message);
            log.info("HTML邮件发送成功,收件人:{},主题:{}", desensitizeEmail(to), subject);
        } catch (Exception e) {
            log.error("HTML邮件发送失败,收件人:{},主题:{}", desensitizeEmail(to), subject, e);
            throw new RuntimeException("HTML邮件发送失败:" + e.getMessage());
        }
    }

    /**
     * 3. 单发带附件邮件(日常工作中如报表、文件推送)
     * @param to 收件人邮箱
     * @param subject 邮件主题
     * @param content 邮件内容(纯文本/HTML)
     * @param isHtml 是否是HTML内容
     * @param filePaths 附件路径(支持多附件)
     */
    public void sendAttachMail(String to, String subject, String content, boolean isHtml, List<String> filePaths) {
        try {
            MimeMessage message = mailSender.createMimeMessage();
            // 第二个参数为true表示支持多附件
            MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
            helper.setFrom(getEncodeSender());
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, isHtml);
            helper.setSentDate(new Date());
            // 处理多附件
            if (CollUtil.isNotEmpty(filePaths)) {
                for (String filePath : filePaths) {
                    File file = new File(filePath);
                    if (file.exists() && file.isFile()) {
                        // 附件名编码,避免中文乱码
                        String fileName = MimeUtility.encodeText(file.getName(), StandardCharsets.UTF_8.name(), "B");
                        helper.addAttachment(fileName, file);
                    } else {
                        log.warn("邮件附件不存在,路径:{}", filePath);
                    }
                }
            }
            mailSender.send(message);
            log.info("带附件邮件发送成功,收件人:{},主题:{},附件数:{}", desensitizeEmail(to), subject, filePaths.size());
        } catch (Exception e) {
            log.error("带附件邮件发送失败,收件人:{},主题:{}", desensitizeEmail(to), subject, e);
            throw new RuntimeException("带附件邮件发送失败:" + e.getMessage());
        }
    }

    /**
     * 4. 批量发信(日常工作中如企业批量通知,单封最多50人,避免超服务商限制)
     * @param toList 收件人邮箱列表
     * @param subject 邮件主题
     * @param content 邮件内容
     * @param isHtml 是否是HTML内容
     */
    public void sendBatchMail(List<String> toList, String subject, String content, boolean isHtml) {
        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
            helper.setFrom(getEncodeSender());
            // 批量设置收件人
            helper.setTo(toList.toArray(new String[0]));
            helper.setSubject(subject);
            helper.setText(content, isHtml);
            helper.setSentDate(new Date());
            mailSender.send(message);
            log.info("批量邮件发送成功,收件人数:{},主题:{}", toList.size(), desensitizeEmails(toList));
        } catch (Exception e) {
            log.error("批量邮件发送失败,收件人数:{},主题:{}", toList.size(), subject, e);
            throw new RuntimeException("批量邮件发送失败:" + e.getMessage());
        }
    }

    /**
     * 发件人昵称编码(核心:避免发件人名称中文乱码)
     */
    private String getEncodeSender() throws UnsupportedEncodingException {
        // 格式:昵称 <发件人邮箱>
        return MimeUtility.encodeText(mailConfig.getNickName(), StandardCharsets.UTF_8.name(), "B") 
                + " <" + mailConfig.getUsername() + ">";
    }

    /**
     * 邮箱脱敏(日常工作中日志规范,避免泄露用户邮箱)
     * 示例:123456@qq.com → 123****@qq.com
     */
    private String desensitizeEmail(String email) {
        if (StrUtil.isBlank(email) || !email.contains("@")) {
            return email;
        }
        String[] parts = email.split("@");
        if (parts[0].length() <= 3) {
            return parts[0] + "****@" + parts[1];
        }
        return parts[0].substring(0, 3) + "****@" + parts[1];
    }

    /**
     * 批量邮箱脱敏
     */
    private String desensitizeEmails(List<String> emails) {
        return emails.stream().map(this::desensitizeEmail).collect(Collectors.joining(","));
    }
}

步骤 5:业务层调用(极简,解耦)

日常工作中,主业务层(如用户服务、订单服务)只需注入MailService,直接调用对应方法即可,无需关注 SMTP 底层实现,完全解耦:

@Service
@Slf4j
public class UserService {
    @Resource
    private MailService mailService;

    /**
     * 用户注册发送验证码(单发纯文本邮件,核心场景,高实时性)
     */
    public void sendRegisterCode(String userEmail) {
        // 生成6位验证码
        String code = RandomUtil.randomNumbers(6);
        // 调用邮件工具类发送
        mailService.sendSimpleMail(
                userEmail,
                "【某某科技】注册验证码",
                "你的注册验证码为:" + code + ",5分钟内有效,请勿泄露给他人!"
        );
        // 后续逻辑:将验证码存入Redis,设置过期时间
        // redisTemplate.opsForValue().set("user:register:code:" + userEmail, code, 5, TimeUnit.MINUTES);
    }

    /**
     * 批量发送会员通知(批量HTML邮件,非核心场景,后续可做异步)
     */
    public void sendBatchMemberNotice(List<String> memberEmails) {
        // 构建HTML内容(日常工作中可从模板引擎读取,如Freemarker)
        String htmlContent = "<h3>【会员专属通知】</h3><p>亲爱的会员,您好!本周有专属福利,点击<a href='https://www.yolo.com'>这里</a>查看</p>";
        // 调用批量发信方法
        mailService.sendBatchMail(memberEmails, "【某某科技】会员专属福利", htmlContent, true);
    }
}

四、日常工作中的企业级优化:从「能发」到「稳发、多发、不被封」

很多开发觉得对接 SMTP 只要能发邮件就完事了,但在日常工作中,这只是第一步—— 生产环境中,你会遇到发信阻塞主业务、批量发信触发限流、邮件进垃圾箱、发信成功率低等问题。

结合 8 年的开发经验,分享 6 个企业级优化方案,都是我在实际项目中落地过的,能让你的 SMTP 对接从「能发」升级为「稳发、多发、不被封」,适配生产环境的高可用要求。

1. 异步发送:避免阻塞主业务链路

核心问题:同步发邮件会占用主业务线程,若 SMTP 服务器响应慢,会导致主业务接口超时(比如用户注册接口因发邮件超时,用户体验极差)。优化方案:对非核心邮件(批量通知、营销邮件)使用Spring @Async自定义线程池做异步发送,核心邮件(验证码、告警)可同步(因 SMTP 对接正常时响应极快,一般 100ms 内)。实战代码:只需在邮件工具类的方法上加@Async注解,再开启异步支持即可:

// 1. 启动类加@EnableAsync开启异步
@SpringBootApplication
@EnableAsync
public class MailApplication {
    public static void main(String[] args) {
        SpringApplication.run(MailApplication.class, args);
    }
}

// 2. 邮件方法上加@Async(批量发信示例)
@Async("mailExecutor") // 指定自定义线程池,避免用默认线程池
public void sendBatchMail(List<String> toList, String subject, String content, boolean isHtml) {
    // 原有逻辑不变
}

// 3. 配置自定义异步线程池(避免和主业务线程池冲突)
@Bean("mailExecutor")
public Executor mailExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(20);
    executor.setQueueCapacity(100);
    executor.setKeepAliveSeconds(300);
    executor.setThreadNamePrefix("mail-send-");
    // 拒绝策略:核心线程满了直接丢弃,非核心邮件不影响主业务
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
    executor.initialize();
    return executor;
}

2. 连接池优化:提升发信性能,避免频繁创建连接

核心问题:原生 JavaMail 默认每次发信都创建新的 SMTP 连接,频繁发信时性能极差,且容易被服务商判定为异常请求。优化方案:开启 Spring Mail 的SMTP 连接池(步骤 2 的配置中已加),通过max-active/max-idle配置合理的连接数,复用连接,提升发信性能。日常工作经验:普通项目配置max-active=10、max-idle=5即可,高并发发信场景(如电商大促)可适当提高到max-active=30

3. 限流控频:避免触发服务商限流,防止账号被封

核心问题:批量发信时一次性发几百封,会触发服务商的频率限制,导致发信失败,甚至账号被临时封停。优化方案

  1. 批量发信分批处理:将几百人的收件人列表拆分为每 50 人一批(服务商单封邮件最多 50 人),每批间隔 1-2 分钟发送;
  2. 本地限流:用 Guava RateLimiter 做本地限流,控制单账号的发信频率(如每分钟最多发 20 封,和服务商限流规则一致);实战代码(本地限流):
// 配置限流器(每分钟20封)
@Bean
public RateLimiter mailRateLimiter() {
    return RateLimiter.create(20.0 / 60);
}

// 邮件工具类中注入并使用
@Resource
private RateLimiter mailRateLimiter;

public void sendSimpleMail(String to, String subject, String content) {
    // 尝试获取令牌,获取不到则直接抛异常
    if (!mailRateLimiter.tryAcquire()) {
        log.warn("邮件发送限流,暂时无法发送,收件人:{}", desensitizeEmail(to));
        throw new RuntimeException("邮件发送过于频繁,请稍后再试");
    }
    // 原有发信逻辑...
}

4. 失败重试:针对网络问题,有限重试(避免重复发信)

核心问题:日常工作中,网络波动、SMTP 服务器临时不可达会导致发信失败,核心邮件(验证码、告警)需要保证成功率。优化方案:用Resilience4j/Spring Retry有限重试,仅对网络异常、连接超时重试,对授权码错误、收件人不存在等业务异常不重试,且重试次数控制在 2-3 次,避免重复发邮件。实战代码(Spring Retry,轻量易集成):

<!-- 引入Spring Retry依赖 -->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
// 启动类加@EnableRetry开启重试
@SpringBootApplication
@EnableAsync
@EnableRetry
public class MailApplication { ... }

// 核心邮件方法上加@Retryable(重试2次,仅对网络异常重试)
@Retryable(value = {ConnectException.class, SocketTimeoutException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void sendSimpleMail(String to, String subject, String content) {
    // 原有发信逻辑...
}

// 重试失败后执行的方法
@Recover
public void recoverSimpleMail(Exception e, String to, String subject, String content) {
    log.error("邮件发送重试3次失败,收件人:{},主题:{}", desensitizeEmail(to), subject, e);
    // 核心邮件可触发告警(如钉钉/企业微信)
    alertService.sendDingDingAlert("核心邮件发送失败,收件人:" + desensitizeEmail(to) + ",原因:" + e.getMessage());
}

5. 邮件内容 & 域名规范:提升送达率,避免进垃圾箱

核心问题:邮件发送成功但被收件人邮箱判为垃圾邮件,进垃圾箱,等于没发。优化方案(日常工作中必做的 3 件事):

  1. 发信域名做 SPF/DKIM/DMARC 解析:这是提升送达率的核心,服务商有现成的解析教程,复制解析记录到域名后台即可;
  2. 避免垃圾邮件关键词:邮件标题 / 内容避免使用「免费、秒杀、返利、中奖」等敏感词,日常工作中可做关键词过滤;
  3. 规范发信格式:发件人用企业域名(如 no-reply@yolo.com),不要用 QQ/163 等免费域名;邮件内容避免纯文本堆砌,HTML 邮件做合理排版。

6. 熔断降级:非核心邮件熔断,保障核心业务

核心问题:SMTP 服务器宕机或服务商接口出问题,会导致邮件发送方法抛出异常,若主业务层未做异常处理,可能影响核心业务。优化方案:用Resilience4j/Sentinel非核心邮件做熔断降级,当 SMTP 调用失败率超过阈值时,直接熔断,不再调用 SMTP 接口,避免阻塞主业务;核心邮件不熔断,触发重试 + 告警。实战思路:和对接第三方风控接口的熔断逻辑一致,通过配置中心动态设置熔断阈值,非核心邮件熔断后可将邮件内容存入数据库 / Redis,后续人工补发。

五、八年开发的踩坑总结:日常工作中这些坑千万别踩!

对接 SMTP 接口的坑大多不是技术难题,而是细节问题,我把 8 年开发中踩过的、日常工作中同事最常问的7 个经典坑和避坑方案整理出来,帮你少走 90% 的弯路:

坑 1:用邮箱登录密码代替 SMTP 授权码

现象:报535 Authentication Failed错误,认证失败;原因:所有邮箱服务商都要求开启 SMTP 服务后生成专用授权码,登录密码无法用于 SMTP 认证;避坑方案:登录邮箱后台,开启 SMTP 服务,生成并保存专用授权码,生产环境加密存储。

坑 2:邮件发件人昵称、附件名、内容乱码

现象:收件人看到的发件人昵称是乱码,中文附件名是乱码,邮件内容中文显示为问号;原因:未做 UTF-8 编码,SMTP 默认编码不是 UTF-8;避坑方案

  1. 发件人昵称用MimeUtility.encodeText编码(代码中已实现);
  2. 附件名用MimeUtility.encodeText编码;
  3. 所有邮件配置统一设置charset: UTF-8

坑 3:SMTP 服务器连接失败,报Connection refused

现象:无法连接到 SMTP 服务器,报连接拒绝;常见原因

  1. 端口和加密方式不匹配(如 465 端口未开 SSL);
  2. 应用服务器出口 IP 未加入服务商白名单;
  3. 465/25 端口被服务器防火墙 / 运营商封掉;避坑方案
  4. 严格按服务商文档匹配端口和加密方式;
  5. 检查 IP 白名单配置;
  6. 465 端口被封换 587 端口,25 端口直接弃用。

坑 4:批量发信时部分收件人收不到邮件

现象:批量发信后,部分收件人能收到,部分收不到,无异常日志;原因:服务商单封邮件的收件人数限制(一般 50 人),超过部分被静默丢弃;避坑方案:批量发信时拆分收件人列表,每批最多 50 人,分批发送。

坑 5:附件过大导致发信失败

现象:带附件发信时报Attachment too large错误;原因:服务商对单附件 / 总附件大小有限制(一般单附件≤50M);避坑方案

  1. 拆分大附件为多个小附件;
  2. 大文件上传到云存储(如阿里云 OSS),邮件中放下载链接,不发附件。

坑 6:重试导致重复发邮件

现象:用户收到多封相同的验证码 / 通知邮件;原因:对所有异常都做了重试,包括邮件发送成功但响应超时的场景;避坑方案

  1. 仅对网络异常、连接超时重试,对业务异常不重试;
  2. 核心邮件做幂等控制(如根据用户 ID + 邮件类型做唯一标识,Redis 记录发送状态,已发送则不重试)。

坑 7:测试环境发信给真实用户,造成业务问题

现象:测试环境发验证码 / 通知邮件给真实用户,用户投诉;原因:开发 / 测试环境和生产环境用了相同的 SMTP 配置;避坑方案

  1. 开发 / 测试 / 生产环境完全隔离 SMTP 配置,用不同的发件人账号;
  2. 测试环境发信的标题 / 内容加 【测试环境】 前缀,避免用户误解。

六、写在最后

对接邮箱服务商的 SMTP 接口,在 Java 开发的日常工作中,属于基础但高频的需求,看似简单,实则细节决定成败—— 从前期的配置准备,到中期的代码开发,再到后期的性能优化、避坑,每一步都要结合生产环境的实际场景。

八年开发下来,我最深的体会是:对接这类基础第三方接口,不要追求炫技,要追求「稳定、可维护、易扩展」。Spring Mail 已经帮我们封装了绝大部分重复代码,日常开发中只需做好配置规范、异常处理、性能优化,就能满足 99% 的业务需求。

而真正能体现开发功底的,不是写出能发邮件的代码,而是在生产环境中保证99.9% 的发信成功率,让邮件这个基础的业务触达方式,不拖主业务的后腿。