chenkq 3 недель назад
Родитель
Сommit
5729f74966
24 измененных файлов с 1417 добавлено и 193 удалено
  1. 10 7
      storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java
  2. 112 0
      storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailBlacklistApiController.java
  3. 1 1
      storlead-mail-api/src/main/resources/application-dev.yml
  4. 12 2
      storlead-mail/README.md
  5. 47 6
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailMessageParser.java
  6. 111 35
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailReceiveEngine.java
  7. 18 1
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailReceivePersistSpi.java
  8. 54 7
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/util/MailReceiveQueueThreadPool.java
  9. 20 0
      storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_blacklist.sql
  10. 31 13
      storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/MktEmailPullTaskJob.java
  11. 48 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailBlacklistEntity.java
  12. 14 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/enums/MktBlacklistSource.java
  13. 7 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/mapper/MktMailBlacklistMapper.java
  14. 22 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktBlacklistQueryDTO.java
  15. 22 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktSendMailResult.java
  16. 185 32
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/receive/MktMailReceivePersist.java
  17. 41 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailBlacklistService.java
  18. 3 1
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java
  19. 188 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailBlacklistServiceImpl.java
  20. 148 83
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java
  21. 1 1
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailReceiveServiceImpl.java
  22. 129 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/support/MktBounceDetector.java
  23. 129 4
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/support/MktMailHeaders.java
  24. 64 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/util/MktMailSender.java

+ 10 - 7
storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java

@@ -19,6 +19,7 @@ import com.storlead.sales.mail.mkt.pojo.MktEditMailContentDTO;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
+import com.storlead.sales.mail.mkt.pojo.MktSendMailResult;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import com.storlead.sales.mail.mkt.service.MktMailReceiveService;
@@ -199,16 +200,18 @@ public class MktMailApiController {
     }
 
     @PostMapping("/send")
-    @ApiOperation("发送营销邮件(单封;默认同步等待投递结果;async=true 异步投递+回调)")
+    @ApiOperation("发送营销邮件(单封;默认同步等待投递结果;async=true 异步投递+回调;黑名单收件人直接拒绝)")
     public Result<?> send(@RequestBody MktSendMailDTO dto) {
         Long userId = LoginUserUtil.getCurrentUserId();
         if (Objects.isNull(userId)) {
             return Result.error("未登录");
         }
-
-
-        MktMailMessageEntity entity = mktMailMessageService.send(dto, userId);
-        return Result.result(entity);
+        try {
+            MktSendMailResult result = mktMailMessageService.send(dto, userId);
+            return Result.result(result);
+        } catch (com.storlead.framework.web.exception.WebException e) {
+            return Result.error(e.getMessage());
+        }
     }
 
     @PostMapping("/dispatch_sent")
@@ -219,8 +222,8 @@ public class MktMailApiController {
             return Result.error("未配置账号");
         }
         dto.setAccountId(ds.get(0).getId());
-        MktMailMessageEntity entity = mktMailMessageService.send(dto,1L);
-        return Result.result(entity);
+        MktSendMailResult result = mktMailMessageService.send(dto,1L);
+        return Result.result(result);
     }
 
     @PostMapping("/edit_content")

+ 112 - 0
storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailBlacklistApiController.java

@@ -0,0 +1,112 @@
+package com.storlead.mail.mkt;
+
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.common.result.Result;
+import com.storlead.framework.util.LoginUserUtil;
+import com.storlead.sales.mail.mkt.entity.MktMailBlacklistEntity;
+import com.storlead.sales.mail.mkt.enums.MktBlacklistSource;
+import com.storlead.sales.mail.mkt.pojo.MktBlacklistQueryDTO;
+import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * 营销邮件黑名单管理。
+ */
+@RestController
+@RequestMapping("/mkt/blacklist")
+@Api(tags = "021.营销邮件黑名单")
+@Log4j2
+public class MktMailBlacklistApiController {
+
+    @Resource
+    private MktMailBlacklistService mktMailBlacklistService;
+
+    @PostMapping("/page_list")
+    @ApiOperation("黑名单分页")
+    public Result<?> pageList(@RequestBody MktBlacklistQueryDTO query) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        IPage<MktMailBlacklistEntity> page = mktMailBlacklistService.pageList(query);
+        return Result.result(page);
+    }
+
+    @PostMapping("/save")
+    @ApiOperation("新增/更新黑名单(需填写邮箱与理由)")
+    public Result<?> save(@RequestBody MktMailBlacklistEntity entity) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        if (!StringUtils.hasText(entity.getEmailAddress())) {
+            return Result.error("邮箱不能为空");
+        }
+        if (!StringUtils.hasText(entity.getReason())) {
+            return Result.error("黑名单理由不能为空");
+        }
+        MktMailBlacklistEntity saved = mktMailBlacklistService.addOrRefresh(
+                entity.getEmailAddress(),
+                entity.getReason(),
+                StringUtils.hasText(entity.getSource()) ? entity.getSource() : MktBlacklistSource.MANUAL,
+                userId,
+                entity.getAccountId(),
+                entity.getMailId());
+        return Result.result(saved);
+    }
+
+    @PostMapping("/deleteIds")
+    @ApiOperation("删除黑名单(逻辑删除)")
+    public Result<?> delete(@RequestParam Long[] ids) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        if (ids == null || ids.length == 0) {
+            return Result.error("参数错误");
+        }
+        LambdaUpdateWrapper<MktMailBlacklistEntity> update = new LambdaUpdateWrapper<>();
+        update.in(MktMailBlacklistEntity::getId, Arrays.asList(ids));
+        update.eq(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        update.set(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_1);
+        update.set(MktMailBlacklistEntity::getUpdateBy, userId);
+        update.set(MktMailBlacklistEntity::getUpdateTime, new Date());
+        mktMailBlacklistService.update(update);
+        return Result.ok();
+    }
+
+    @PostMapping("/enabled")
+    @ApiOperation("启用/禁用黑名单")
+    public Result<?> enabled(@RequestParam Long id, @RequestParam Boolean enable) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        if (id == null || enable == null) {
+            return Result.error("参数错误");
+        }
+        LambdaUpdateWrapper<MktMailBlacklistEntity> update = new LambdaUpdateWrapper<>();
+        update.eq(MktMailBlacklistEntity::getId, id);
+        update.eq(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        update.set(MktMailBlacklistEntity::getEnabled, enable);
+        update.set(MktMailBlacklistEntity::getUpdateBy, userId);
+        update.set(MktMailBlacklistEntity::getUpdateTime, new Date());
+        mktMailBlacklistService.update(update);
+        return Result.ok();
+    }
+}

+ 1 - 1
storlead-mail-api/src/main/resources/application-dev.yml

@@ -147,7 +147,7 @@ logging:
 environment: test
 
 # 站内消息、邮件模板中的前端站点根地址(覆盖 application.yml 默认值)
-domainname: http://localhost:10010/router/rest
+domainname: https://email.test.storlead.com/router/rest
 
 file:
   mode: 1

+ 12 - 2
storlead-mail/README.md

@@ -66,12 +66,22 @@ API:
 ## 库表变更
 
 执行:`storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql`  
-状态表:`storlead-mail-common/src/main/resources/sql/mkt_mail_status.sql`
+状态表:`storlead-mail-common/src/main/resources/sql/mkt_mail_status.sql`  
+黑名单:`storlead-mail-common/src/main/resources/sql/mkt_mail_blacklist.sql`
 
 - `smtp_pop_settings` 增加 `scene` / `account_scope` / `org_id` 等
-- 新建 `mkt_mail_message`、`mkt_mail_attachment`、`mkt_mail_status`
+- 新建 `mkt_mail_message`、`mkt_mail_attachment`、`mkt_mail_status`、`mkt_mail_blacklist`
 - `mail_temp_attachment` 增加 `scene`(默认 `CRM`)
 
+### 营销黑名单
+
+| 能力 | 说明 |
+|------|------|
+| 发信拦截 | `/mkt/send` 校验 TO/CC/BCC,命中则返回「无法发送 + 黑名单理由」 |
+| 退信入黑 | INBOX 识别 mailer-daemon/undeliverable 等,提取失败地址写入黑名单 |
+| SMTP 失败入黑 | `SendFailedException`/550 将收件人写入黑名单 |
+| 管理接口 | `POST /mkt/blacklist/page_list`、`/save`、`/deleteIds`、`/enabled` |
+
 ### mkt_mail_status 埋点
 
 | 事件 | 更新字段 |

+ 47 - 6
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailMessageParser.java

@@ -10,7 +10,10 @@ import javax.mail.*;
 import javax.mail.internet.InternetAddress;
 import javax.mail.internet.MimeUtility;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
@@ -114,15 +117,53 @@ public final class MailMessageParser {
     }
 
     public static String resolveInReplyTo(Message message) {
+        List<String> ids = collectReplyMessageIds(message);
+        if (ids.isEmpty()) {
+            return null;
+        }
+        return normalizeMessageId(ids.get(0));
+    }
+
+    /**
+     * 收集回复线程中的 Message-ID:先 In-Reply-To,再 References(从新到旧)。
+     * 用于匹配本地发出信,不要只取一个 ID。
+     */
+    public static List<String> collectReplyMessageIds(Message message) {
+        LinkedHashSet<String> ids = new LinkedHashSet<>();
         String inReplyTo = firstHeader(message, "In-Reply-To");
-        if (!StringUtils.hasText(inReplyTo)) {
-            String references = firstHeader(message, "References");
-            if (StringUtils.hasText(references)) {
-                String[] parts = references.trim().split("\\s+");
-                inReplyTo = parts[parts.length - 1];
+        if (StringUtils.hasText(inReplyTo)) {
+            for (String part : splitMessageIdHeader(inReplyTo)) {
+                ids.add(normalizeMessageId(part));
+            }
+        }
+        String references = firstHeader(message, "References");
+        if (StringUtils.hasText(references)) {
+            List<String> refParts = splitMessageIdHeader(references);
+            // References 通常从旧到新;匹配时优先从新到旧
+            for (int i = refParts.size() - 1; i >= 0; i--) {
+                ids.add(normalizeMessageId(refParts.get(i)));
+            }
+        }
+        return new ArrayList<>(ids);
+    }
+
+    public static List<String> splitMessageIdHeader(String headerValue) {
+        LinkedHashSet<String> ids = new LinkedHashSet<>();
+        if (!StringUtils.hasText(headerValue)) {
+            return new ArrayList<>();
+        }
+        java.util.regex.Matcher m = java.util.regex.Pattern.compile("<[^>]+>").matcher(headerValue);
+        while (m.find()) {
+            ids.add(m.group().trim());
+        }
+        if (ids.isEmpty()) {
+            for (String part : headerValue.trim().split("\\s+")) {
+                if (StringUtils.hasText(part)) {
+                    ids.add(part.trim());
+                }
             }
         }
-        return normalizeMessageId(inReplyTo);
+        return new ArrayList<>(ids);
     }
 
     public static String normalizeMessageId(String messageId) {

+ 111 - 35
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailReceiveEngine.java

@@ -6,24 +6,28 @@ import com.storlead.sales.mail.common.connection.config.MailProperties;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.enums.MailSceneEnum;
 import com.storlead.sales.mail.common.util.MailReceiveQueueThreadPool;
+import com.sun.mail.imap.IMAPFolder;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
-import javax.mail.Folder;
-import javax.mail.Message;
+import javax.mail.*;
 import javax.mail.search.ComparisonTerm;
 import javax.mail.search.ReceivedDateTerm;
 import javax.mail.search.SearchTerm;
-import java.util.Arrays;
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.Enumeration;
 import java.util.List;
 
 /**
- * 公共收信引擎:对齐 CRM receiveEmails 三阶段(拉头 → 正文 → 附件),
- * 落库由 {@link MailReceivePersistSpi} 实现。
- * <p>同账户串行:拉头完成后再补正文/附件,避免并发乱序。
+ * 公共收信引擎。
+ * <ul>
+ *   <li>CRM:拉头 → 补正文 → 附件(三阶段)</li>
+ *   <li>MKT:头+正文一次落库;附件由 Persist 异步处理</li>
+ * </ul>
+ * 增量:优先按 IMAP UID &gt; 本地最大 uid 只拉新信;否则按收信时间游标。
  */
 @Log4j2
 @Component
@@ -34,9 +38,6 @@ public class MailReceiveEngine {
     private static final String SERVER_INBOX = "INBOX";
     private static final String SERVER_SENT = "Sent Items";
 
-    /**
-     * 异步收信入口(与 CRM receiveEmails 形态一致)。
-     */
     public void receive(SmtpPopSettingsEntity account,
                         MailSceneEnum scene,
                         MailReceivePersistSpi persist,
@@ -46,31 +47,36 @@ public class MailReceiveEngine {
         }
         String taskKey = buildTaskKey(scene, account);
         MailReceiveQueueThreadPool pool = MailReceiveQueueThreadPool.getInstance();
-        if (Boolean.TRUE.equals(pool.getTaskInProgressByMail(taskKey))) {
-            log.warn("receive already running: {}", taskKey);
+        // 上一轮未跑完则直接跳过,不排队堆积
+        if (pool.isBusy(taskKey)) {
+            log.warn("receive skip, previous task still running: {}", taskKey);
             return;
         }
-        pool.addTask(taskKey, () -> {
+        boolean accepted = pool.addTaskIfAbsent(taskKey, () -> {
             try {
-                receiveHead(account, persist, isBackTask);
-                loadBodies(account, persist);
-                persist.loadPendingAttachmentFiles(account);
+                if (persist.receiveHeadAndBodyTogether()) {
+                    pullOneFolder(account, persist, SERVER_INBOX, FOLDER_INBOX, isBackTask);
+                    pullOneFolder(account, persist, SERVER_SENT, FOLDER_SENT, isBackTask);
+                } else {
+                    pullOneFolder(account, persist, SERVER_INBOX, FOLDER_INBOX, isBackTask);
+                    pullOneFolder(account, persist, SERVER_SENT, FOLDER_SENT, isBackTask);
+                    loadBodies(account, persist);
+                    persist.loadPendingAttachmentFiles(account);
+                }
             } catch (Exception e) {
                 log.error("MailReceiveEngine receive error, scene={}, accountId={}",
                         scene.getCode(), account.getId(), e);
             }
         });
+        if (!accepted) {
+            log.warn("receive skip, task already queued or running: {}", taskKey);
+        }
     }
 
     public static String buildTaskKey(MailSceneEnum scene, SmtpPopSettingsEntity account) {
         return scene.getCode() + ":" + account.getId() + ":" + account.getEmailAddress();
     }
 
-    private void receiveHead(SmtpPopSettingsEntity account, MailReceivePersistSpi persist, boolean isBackTask) {
-        pullOneFolder(account, persist, SERVER_INBOX, FOLDER_INBOX, isBackTask);
-        pullOneFolder(account, persist, SERVER_SENT, FOLDER_SENT, isBackTask);
-    }
-
     private void pullOneFolder(SmtpPopSettingsEntity account,
                                MailReceivePersistSpi persist,
                                String serverFolder,
@@ -81,15 +87,18 @@ public class MailReceiveEngine {
             if (CollectionUtils.isEmpty(messages)) {
                 return;
             }
-            log.info("==========邮件入库开始=========={} accountId={}", folderCode, account.getId());
-            List<Long> savedIds = new java.util.ArrayList<>();
+            log.info("==========邮件入库开始=========={} accountId={} count={}",
+                    folderCode, account.getId(), messages.size());
+            List<Long> savedIds = new ArrayList<>();
             for (Message message : messages) {
                 try {
-                    String[] headers = message.getHeader("Message-ID");
-                    if (headers == null || headers.length == 0 || !StringUtils.hasText(headers[0])) {
-                        continue;
-                    }
-                    // 是否新建/合并由 Persist 决定(MKT:同 message_id 的本地发信记录保留 id 并更新服务器字段)
+//                    String headers = getHeader(message,"Message-ID");
+//                    // In-Reply-To
+//                    String inReplyTo = getHeader(message, "In-Reply-To");
+//
+//                    // References
+//                    String references = getHeader(message, "References");
+
                     Long mailId = persist.saveHead(account, message, folderCode, isBackTask);
                     if (mailId != null) {
                         savedIds.add(mailId);
@@ -98,7 +107,7 @@ public class MailReceiveEngine {
                     log.error("pull one mail error, accountId={}, folder={}", account.getId(), folderCode, ex);
                 }
             }
-            log.info("==========邮件入库结束=========={} size={}", folderCode, savedIds.size());
+            log.info("==========邮件入库结束=========={} saved={}", folderCode, savedIds.size());
             if (!CollectionUtils.isEmpty(savedIds)) {
                 persist.afterHeadsSaved(account, folderCode, savedIds, isBackTask);
             }
@@ -107,11 +116,26 @@ public class MailReceiveEngine {
         }
     }
 
+    private String getHeader(Message message, String name) throws MessagingException {
+
+        String[] headers = message.getHeader(name);
+
+        if (headers == null || headers.length == 0) {
+            return null;
+        }
+
+        return String.join(" ", headers).trim();
+    }
+
+    /**
+     * 只拉比本地游标更新的邮件:IMAP 有 uid 则 uid &gt; maxUid;否则 receivedDate &gt; maxDate。
+     */
     private List<Message> searchMessages(SmtpPopSettingsEntity account,
                                          MailReceivePersistSpi persist,
                                          String serverFolder,
                                          String folderCode) {
         try {
+            String maxUid = persist.maxMsgUid(account.getId(), folderCode);
             Date beginDate = MailMessageParser.resolveBeginDate(
                     persist.maxRecipientDate(account.getId(), folderCode), account.getPullDay());
             MailProperties properties = new MailProperties(account);
@@ -119,23 +143,75 @@ public class MailReceiveEngine {
             if (connection == null) {
                 return null;
             }
-            Folder inbox = connection.getFolder(serverFolder);
-            if (inbox == null) {
+            Folder folder = connection.getFolder(serverFolder);
+            if (folder == null) {
                 return null;
             }
-            inbox.open(Folder.READ_ONLY);
+            folder.open(Folder.READ_ONLY);
+
+            // IMAP:从本地最大 uid 之后继续拉,避免整夹搜索再比对
+            if (folder instanceof IMAPFolder && isNumericUid(maxUid)) {
+                long startUid = Long.parseLong(maxUid) + 1;
+                Message[] byUid = ((IMAPFolder) folder).getMessagesByUID(startUid, UIDFolder.LASTUID);
+                List<Message> list = compactMessages(byUid);
+                log.info("search by UID>{} folder={} accountId={} hit={}",
+                        maxUid, folderCode, account.getId(), list.size());
+                return list;
+            }
+
             SearchTerm searchTerm = new ReceivedDateTerm(ComparisonTerm.GT, beginDate);
-            Message[] messages = inbox.search(searchTerm);
-            if (messages == null || messages.length == 0) {
-                return null;
+            Message[] messages = folder.search(searchTerm);
+            List<Message> list = compactMessages(messages);
+            // 日期游标粒度粗:再按 uid 过滤已入库的
+            if (isNumericUid(maxUid) && !list.isEmpty()) {
+                long max = Long.parseLong(maxUid);
+                List<Message> newer = new ArrayList<>();
+                for (Message message : list) {
+                    try {
+                        String uid = MailMessageParser.resolveMsgUid(message, account.getProtocolType());
+                        if (!isNumericUid(uid) || Long.parseLong(uid) > max) {
+                            newer.add(message);
+                        }
+                    } catch (Exception e) {
+                        newer.add(message);
+                    }
+                }
+                list = newer;
             }
-            return Arrays.asList(messages);
+            log.info("search by date>{} folder={} accountId={} hit={}",
+                    beginDate, folderCode, account.getId(), list.size());
+            return list;
         } catch (Exception e) {
             log.error("searchMessages error, folder={}", serverFolder, e);
             return null;
         }
     }
 
+    private static List<Message> compactMessages(Message[] messages) {
+        List<Message> list = new ArrayList<>();
+        if (messages == null || messages.length == 0) {
+            return list;
+        }
+        for (Message message : messages) {
+            if (message != null) {
+                list.add(message);
+            }
+        }
+        return list;
+    }
+
+    private static boolean isNumericUid(String uid) {
+        if (!StringUtils.hasText(uid)) {
+            return false;
+        }
+        for (int i = 0; i < uid.length(); i++) {
+            if (!Character.isDigit(uid.charAt(i))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private void loadBodies(SmtpPopSettingsEntity account, MailReceivePersistSpi persist) {
         List<MailReceiveBodyTask> tasks = persist.listNeedBody(account.getId());
         if (CollectionUtils.isEmpty(tasks)) {

+ 18 - 1
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailReceivePersistSpi.java

@@ -16,6 +16,13 @@ public interface MailReceivePersistSpi {
      */
     Date maxRecipientDate(Long accountId, String folder);
 
+    /**
+     * 增量游标:该账户该 folder 下最大 IMAP msg_uid(数字字符串);无则返回 null,走日期拉取。
+     */
+    default String maxMsgUid(Long accountId, String folder) {
+        return null;
+    }
+
     /**
      * 是否已存在同 Message-ID(可选;引擎不再据此短路,由 {@link #saveHead} 自行去重/合并)。
      */
@@ -23,11 +30,21 @@ public interface MailReceivePersistSpi {
 
     /**
      * 保存拉头结果:新建返回主键;合并更新本地已有记录可返回 null(不触发 afterHeadsSaved)或返回原 id。
+     * <p>若 {@link #receiveHeadAndBodyTogether()} 为 true,实现里应一次写入头+正文(不含附件)。
      */
     Long saveHead(SmtpPopSettingsEntity account, Message message, String folder, boolean isBackTask);
 
     /**
-     * 一批拉头完成后的业务钩子(CRM:集成任务/自动回复;MKT:可空)。
+     * MKT:头与正文一次获取落库,不走补正文阶段;附件由 afterHeadsSaved 异步处理。
+     * CRM:false,保持拉头 / 补正文 / 附件三阶段。
+     */
+    default boolean receiveHeadAndBodyTogether() {
+        return false;
+    }
+
+    /**
+     * 一批拉头(或头+正文)完成后的业务钩子。
+     * CRM:集成任务/自动回复;MKT:异步拉附件。
      */
     void afterHeadsSaved(SmtpPopSettingsEntity account, String folder, List<Long> mailIds, boolean isBackTask);
 

+ 54 - 7
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/util/MailReceiveQueueThreadPool.java

@@ -2,16 +2,16 @@ package com.storlead.sales.mail.common.util;
 
 import com.storlead.framework.common.thread.ThreadPoolUtil;
 import lombok.extern.log4j.Log4j2;
+import org.springframework.util.StringUtils;
 
 import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Queue;
 import java.util.concurrent.ThreadPoolExecutor;
 
 /**
- * 收信任务队列(CRM/MKT 共用):同一邮箱串行,避免并发拉信。
+ * 收信任务队列(CRM/MKT 共用):同一邮箱串行,未跑完不重复入队。
  */
 @Log4j2
 public class MailReceiveQueueThreadPool {
@@ -37,18 +37,62 @@ public class MailReceiveQueueThreadPool {
         return taskInProgress.getOrDefault(key, false);
     }
 
-    public synchronized void addTask(String key, Runnable task) {
-        if (Objects.nonNull(taskMap.get(key))) {
-            return;
+    /**
+     * 该 key 是否已在跑或已排队。
+     */
+    public synchronized boolean isBusy(String key) {
+        if (!StringUtils.hasText(key)) {
+            return false;
+        }
+        if (Boolean.TRUE.equals(taskInProgress.get(key))) {
+            return true;
+        }
+        Queue<Runnable> queue = taskMap.get(key);
+        return queue != null && !queue.isEmpty();
+    }
+
+    /**
+     * 是否存在指定前缀的任务在跑或排队(如 {@code MKT:})。
+     */
+    public synchronized boolean hasBusyPrefix(String prefix) {
+        if (!StringUtils.hasText(prefix)) {
+            return false;
+        }
+        for (Map.Entry<String, Boolean> e : taskInProgress.entrySet()) {
+            if (e.getKey() != null && e.getKey().startsWith(prefix) && Boolean.TRUE.equals(e.getValue())) {
+                return true;
+            }
+        }
+        for (Map.Entry<String, Queue<Runnable>> e : taskMap.entrySet()) {
+            if (e.getKey() != null && e.getKey().startsWith(prefix)
+                    && e.getValue() != null && !e.getValue().isEmpty()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 仅当 key 空闲时入队;已忙则返回 false(不堆积)。
+     */
+    public synchronized boolean addTaskIfAbsent(String key, Runnable task) {
+        if (isBusy(key)) {
+            return false;
         }
         taskMap.putIfAbsent(key, new LinkedList<>());
         taskMap.get(key).offer(task);
         taskInProgress.putIfAbsent(key, false);
         processNextTask(key);
+        return true;
+    }
+
+    /** @deprecated 使用 {@link #addTaskIfAbsent} */
+    public synchronized void addTask(String key, Runnable task) {
+        addTaskIfAbsent(key, task);
     }
 
     private synchronized void processNextTask(String key) {
-        if (taskInProgress.getOrDefault(key, false)) {
+        if (Boolean.TRUE.equals(taskInProgress.getOrDefault(key, false))) {
             return;
         }
         Queue<Runnable> queue = taskMap.get(key);
@@ -68,7 +112,10 @@ public class MailReceiveQueueThreadPool {
 
     public synchronized void taskCompleted(String key) {
         taskInProgress.put(key, false);
-        taskMap.remove(key);
+        Queue<Runnable> queue = taskMap.get(key);
+        if (queue == null || queue.isEmpty()) {
+            taskMap.remove(key);
+        }
         processNextTask(key);
     }
 }

+ 20 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_blacklist.sql

@@ -0,0 +1,20 @@
+-- 营销邮件黑名单(退信/550 自动入黑,发信前拦截)
+CREATE TABLE IF NOT EXISTS mkt_mail_blacklist (
+    id              bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    email_address   varchar(255) NOT NULL COMMENT '黑名单邮箱(建议存小写)',
+    reason          varchar(512) NOT NULL COMMENT '黑名单理由',
+    source          varchar(32)  NOT NULL DEFAULT 'MANUAL' COMMENT '来源:MANUAL手工 BOUNCE退信 SMTP_FAIL投递失败',
+    account_id      bigint       NULL COMMENT '关联发信账户(退信/失败时记录)',
+    mail_id         bigint       NULL COMMENT '触发入黑的邮件ID(可选)',
+    owner_by        bigint       NULL COMMENT '数据归属人/操作人',
+    create_by       bigint       NULL COMMENT '创建人',
+    update_by       bigint       NULL COMMENT '最后更新人',
+    create_time     datetime     NULL COMMENT '创建时间',
+    update_time     datetime     NULL COMMENT '更新时间',
+    is_delete       tinyint      NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除;1已删除',
+    enabled         tinyint      NOT NULL DEFAULT 1 COMMENT '是否启用:1启用;0禁用',
+    sort            int          NULL COMMENT '排序号',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_mkt_blacklist_email (email_address),
+    KEY idx_mkt_blacklist_enabled (enabled, is_delete)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营销邮件黑名单';

+ 31 - 13
storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/MktEmailPullTaskJob.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.enums.MailSceneEnum;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.common.util.MailReceiveQueueThreadPool;
 import com.storlead.sales.mail.mkt.service.MktMailReceiveService;
 import lombok.NoArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -13,38 +14,55 @@ import org.springframework.util.CollectionUtils;
 
 import javax.annotation.Resource;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * 营销收信调度:仅 scene=MKT 且 enable_receive=1 的账户。
+ * 上一轮未结束(含账户异步拉信未完成)则跳过本轮,避免并发堆积。
  */
 @Slf4j
 @Component
 @NoArgsConstructor
 public class MktEmailPullTaskJob {
 
+    private final AtomicBoolean jobRunning = new AtomicBoolean(false);
+
     @Resource
     private SmtpPopSettingsService smtpPopSettingsService;
 
     @Resource
     private MktMailReceiveService mktMailReceiveService;
 
-    // @Scheduled(cron = "0 */1 * * * ?")
+    @Scheduled(cron = "0 */1 * * * ?")
     public void pullMktMailTask() {
-        LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(SmtpPopSettingsEntity::getIsDelete, 0);
-        wrapper.eq(SmtpPopSettingsEntity::getEnabled, 1);
-        wrapper.eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.MKT.getCode());
-        wrapper.eq(SmtpPopSettingsEntity::getEnableReceive, 1);
-        List<SmtpPopSettingsEntity> accounts = smtpPopSettingsService.list(wrapper);
-        if (CollectionUtils.isEmpty(accounts)) {
+        if (!jobRunning.compareAndSet(false, true)) {
+            log.warn("mkt pull job still running, skip this schedule");
             return;
         }
-        for (SmtpPopSettingsEntity account : accounts) {
-            try {
-                mktMailReceiveService.receiveEmails(account);
-            } catch (Exception e) {
-                log.error("mkt pull task error, accountId={}", account.getId(), e);
+        try {
+            // 账户异步收信尚未跑完,本轮整批跳过
+            if (MailReceiveQueueThreadPool.getInstance().hasBusyPrefix(MailSceneEnum.MKT.getCode() + ":")) {
+                log.warn("mkt receive tasks still busy, skip this schedule");
+                return;
+            }
+            LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(SmtpPopSettingsEntity::getIsDelete, 0);
+            wrapper.eq(SmtpPopSettingsEntity::getEnabled, 1);
+            wrapper.eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.MKT.getCode());
+            wrapper.eq(SmtpPopSettingsEntity::getEnableReceive, 1);
+            List<SmtpPopSettingsEntity> accounts = smtpPopSettingsService.list(wrapper);
+            if (CollectionUtils.isEmpty(accounts)) {
+                return;
+            }
+            for (SmtpPopSettingsEntity account : accounts) {
+                try {
+                    mktMailReceiveService.receiveEmails(account);
+                } catch (Exception e) {
+                    log.error("mkt pull task error, accountId={}", account.getId(), e);
+                }
             }
+        } finally {
+            jobRunning.set(false);
         }
     }
 }

+ 48 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailBlacklistEntity.java

@@ -0,0 +1,48 @@
+package com.storlead.sales.mail.mkt.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.experimental.Accessors;
+
+/**
+ * 营销邮件黑名单。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Accessors(chain = true)
+@TableName("mkt_mail_blacklist")
+@ApiModel(value = "MktMailBlacklistEntity", description = "营销邮件黑名单")
+public class MktMailBlacklistEntity extends SysBaseField {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("黑名单邮箱")
+    @TableField("email_address")
+    private String emailAddress;
+
+    @ApiModelProperty("黑名单理由")
+    @TableField("reason")
+    private String reason;
+
+    @ApiModelProperty("来源:MANUAL/BOUNCE/SMTP_FAIL")
+    @TableField("source")
+    private String source;
+
+    @ApiModelProperty("关联发信账户ID")
+    @TableField("account_id")
+    private Long accountId;
+
+    @ApiModelProperty("触发入黑的邮件ID")
+    @TableField("mail_id")
+    private Long mailId;
+}

+ 14 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/enums/MktBlacklistSource.java

@@ -0,0 +1,14 @@
+package com.storlead.sales.mail.mkt.enums;
+
+/**
+ * 营销黑名单来源。
+ */
+public final class MktBlacklistSource {
+
+    public static final String MANUAL = "MANUAL";
+    public static final String BOUNCE = "BOUNCE";
+    public static final String SMTP_FAIL = "SMTP_FAIL";
+
+    private MktBlacklistSource() {
+    }
+}

+ 7 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/mapper/MktMailBlacklistMapper.java

@@ -0,0 +1,7 @@
+package com.storlead.sales.mail.mkt.mapper;
+
+import com.storlead.framework.mybatis.mapper.MyBaseMapper;
+import com.storlead.sales.mail.mkt.entity.MktMailBlacklistEntity;
+
+public interface MktMailBlacklistMapper extends MyBaseMapper<MktMailBlacklistEntity> {
+}

+ 22 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktBlacklistQueryDTO.java

@@ -0,0 +1,22 @@
+package com.storlead.sales.mail.mkt.pojo;
+
+import com.storlead.framework.mybatis.query.page.PageQuery;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("营销黑名单查询")
+public class MktBlacklistQueryDTO extends PageQuery {
+
+    @ApiModelProperty("邮箱(模糊)")
+    private String emailAddress;
+
+    @ApiModelProperty("来源:MANUAL/BOUNCE/SMTP_FAIL")
+    private String source;
+
+    @ApiModelProperty("是否启用")
+    private Boolean enabled;
+}

+ 22 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktSendMailResult.java

@@ -0,0 +1,22 @@
+package com.storlead.sales.mail.mkt.pojo;
+
+import com.storlead.sales.mail.common.enums.MailSendStatus;
+import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/**
+ * 营销发信结果:投递状态 + 邮件实体。
+ * 正文仅在投递成功后写入;失败时 content 为空。
+ */
+@Data
+@ApiModel("营销邮件发送结果")
+public class MktSendMailResult {
+
+    @ApiModelProperty("SMTP 投递状态;延时未投递、异步已受理时为空")
+    private MailSendStatus sendStatus;
+
+    @ApiModelProperty("邮件实体")
+    private MktMailMessageEntity mail;
+}

+ 185 - 32
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/receive/MktMailReceivePersist.java

@@ -4,8 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.common.thread.ThreadPoolUtil;
 import com.storlead.framework.common.util.DateUtils;
 import com.storlead.framework.common.util.RandomGenerateHelper;
+import com.storlead.sales.mail.common.connection.MailConnection;
+import com.storlead.sales.mail.common.connection.client.MailConnectionUtil;
+import com.storlead.sales.mail.common.connection.config.MailProperties;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.properties.MailFileProperties;
 import com.storlead.sales.mail.common.receive.MailMessageParser;
@@ -14,10 +18,13 @@ import com.storlead.sales.mail.common.receive.MailReceiveHeadDraft;
 import com.storlead.sales.mail.common.receive.MailReceivePersistSpi;
 import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+import com.storlead.sales.mail.mkt.enums.MktBlacklistSource;
 import com.storlead.sales.mail.mkt.enums.MktMailFolderEnum;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
+import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import com.storlead.sales.mail.mkt.service.MktMailStatusService;
+import com.storlead.sales.mail.mkt.support.MktBounceDetector;
 import com.storlead.sales.mail.mkt.support.MktMailHeaders;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.io.FilenameUtils;
@@ -36,10 +43,9 @@ import java.io.FileOutputStream;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
-import java.util.stream.Collectors;
 
 /**
- * MKT 收信落库:mkt_mail_message + reply_msg_id 绑定 + mkt_mail_attachment。
+ * MKT 收信落库:头+正文一次写入;附件异步;退信入黑名单。
  */
 @Log4j2
 @Component
@@ -56,6 +62,9 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
     @Resource
     private MktMailStatusService mktMailStatusService;
 
+    @Resource
+    private MktMailBlacklistService mktMailBlacklistService;
+
     @Resource
     private MailFileProperties mailFileProperties;
 
@@ -69,11 +78,36 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
         return latest == null ? null : latest.getRecipientDate();
     }
 
+    @Override
+    public String maxMsgUid(Long accountId, String folder) {
+        if (accountId == null || !StringUtils.hasText(folder)) {
+            return null;
+        }
+        // 取数值最大的 IMAP uid,作为增量拉取起点
+        MktMailMessageEntity latest = mktMailMessageService.getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getMsgUid)
+                .eq(MktMailMessageEntity::getAccountId, accountId)
+                .eq(MktMailMessageEntity::getFolder, folder)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .isNotNull(MktMailMessageEntity::getMsgUid)
+                .ne(MktMailMessageEntity::getMsgUid, "")
+                .last("order by cast(msg_uid as unsigned) desc limit 1"));
+        if (latest == null || !StringUtils.hasText(latest.getMsgUid())) {
+            return null;
+        }
+        return latest.getMsgUid().trim();
+    }
+
     @Override
     public boolean existsMessageId(Long accountId, String messageId) {
         return findByMessageId(accountId, messageId) != null;
     }
 
+    @Override
+    public boolean receiveHeadAndBodyTogether() {
+        return true;
+    }
+
     @Override
     public Long saveHead(SmtpPopSettingsEntity account, Message message, String folder, boolean isBackTask) {
         try {
@@ -82,10 +116,12 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
                 return null;
             }
             String messageId = MailMessageParser.normalizeMessageId(headers[0]);
-            MailReceiveHeadDraft draft = MailMessageParser.parseHead(message, folder, true);
+            // 营销邮件体量小:头+正文一次解析落库(附件另异步)
+            MailReceiveHeadDraft draft = MailMessageParser.parseHead(message, folder, false);
             if (draft == null) {
                 return null;
             }
+            String body = MailMessageParser.extractBody(message);
 
             // 优先按发信时写入的主键头对齐;兼容旧邮件再按 RFC Message-ID 对齐
             MktMailMessageEntity existing = findLocalOutboundForMerge(account.getId(), message, messageId);
@@ -117,8 +153,8 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
             }
             entity.setEmailSize(draft.getEmailSize());
             entity.setIsRead(draft.getIsRead());
-            entity.setIsOnlyHead(draft.getIsOnlyHead());
-            entity.setContent(draft.getContent());
+            entity.setIsOnlyHead(0);
+            entity.setContent(body);
             entity.setMsgUid(MailMessageParser.resolveMsgUid(message, account.getProtocolType()));
             entity.setStatus(STATUS_RECEIVED);
             entity.setIsDelete(CommonConstant.DEL_FLAG_0);
@@ -127,9 +163,12 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
             entity.setOpened(0);
             entity.setOpenCount(0);
 
-            bindReply(account.getId(), draft.getInReplyTo(), entity);
+            bindReply(account.getId(), message, draft, entity);
 
             if (mktMailMessageService.save(entity)) {
+                if (MktMailFolderEnum.INBOX.code.equals(folder)) {
+                    handleBounceIfNeeded(entity, body);
+                }
                 return entity.getId();
             }
         } catch (Exception e) {
@@ -224,30 +263,59 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
 
     @Override
     public void afterHeadsSaved(SmtpPopSettingsEntity account, String folder, List<Long> mailIds, boolean isBackTask) {
-        // MKT 无 CRM 集成/自动回复
+        if (account == null || CollectionUtils.isEmpty(mailIds)) {
+            return;
+        }
+        // 附件单独异步,不阻塞头+正文入库
+        List<Long> ids = new ArrayList<>(mailIds);
+        ThreadPoolUtil.getThreadExecutor().submit(() -> pullAttachmentsAsync(account, folder, ids));
+    }
+
+    private void pullAttachmentsAsync(SmtpPopSettingsEntity account, String folder, List<Long> mailIds) {
+        try {
+            List<MktMailMessageEntity> mails = mktMailMessageService.list(new LambdaQueryWrapper<MktMailMessageEntity>()
+                    .select(MktMailMessageEntity::getId, MktMailMessageEntity::getMsgUid,
+                            MktMailMessageEntity::getFolder, MktMailMessageEntity::getRecipientDate)
+                    .in(MktMailMessageEntity::getId, mailIds)
+                    .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0));
+            if (CollectionUtils.isEmpty(mails)) {
+                return;
+            }
+            MailProperties properties = new MailProperties(account);
+            MailConnection connection = MailConnectionUtil.receiveEmailsConnection(properties);
+            if (connection == null) {
+                return;
+            }
+            String serverFolder = MktMailFolderEnum.INBOX.code.equals(folder)
+                    ? "INBOX" : "Sent Items";
+            for (MktMailMessageEntity mail : mails) {
+                try {
+                    if (!StringUtils.hasText(mail.getMsgUid())) {
+                        continue;
+                    }
+                    Message message = connection.getMessageByUid(mail.getMsgUid(), serverFolder);
+                    if (message == null) {
+                        continue;
+                    }
+                    MailReceiveBodyTask task = new MailReceiveBodyTask();
+                    task.setMailId(mail.getId());
+                    task.setMsgUid(mail.getMsgUid());
+                    task.setFolder(mail.getFolder());
+                    task.setRecipientDate(mail.getRecipientDate());
+                    saveAttachmentsFromMessage(account, task, message);
+                } catch (Exception ex) {
+                    log.error("mkt async attachment error, mailId={}", mail.getId(), ex);
+                }
+            }
+        } catch (Exception e) {
+            log.error("mkt pullAttachmentsAsync error, accountId={}", account.getId(), e);
+        }
     }
 
     @Override
     public List<MailReceiveBodyTask> listNeedBody(Long accountId) {
-        List<MktMailMessageEntity> list = mktMailMessageService.list(new LambdaQueryWrapper<MktMailMessageEntity>()
-                .select(MktMailMessageEntity::getId, MktMailMessageEntity::getMsgUid,
-                        MktMailMessageEntity::getFolder, MktMailMessageEntity::getRecipientDate)
-                .eq(MktMailMessageEntity::getAccountId, accountId)
-                .eq(MktMailMessageEntity::getIsOnlyHead, 1)
-                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
-                .in(MktMailMessageEntity::getFolder,
-                        MktMailFolderEnum.INBOX.code, MktMailFolderEnum.SENT.code));
-        if (CollectionUtils.isEmpty(list)) {
-            return new ArrayList<>();
-        }
-        return list.stream().map(e -> {
-            MailReceiveBodyTask t = new MailReceiveBodyTask();
-            t.setMailId(e.getId());
-            t.setMsgUid(e.getMsgUid());
-            t.setFolder(e.getFolder());
-            t.setRecipientDate(e.getRecipientDate());
-            return t;
-        }).collect(Collectors.toList());
+        // 头+正文已一次落库,无需补正文阶段
+        return new ArrayList<>();
     }
 
     @Override
@@ -289,13 +357,87 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
         }
     }
 
-    private void bindReply(Long accountId, String inReplyTo, MktMailMessageEntity entity) {
-        if (!StringUtils.hasText(inReplyTo)) {
-            return;
+    /**
+     * 用已有正文做退信入黑(不读 Message;由 updateBody / 拉头已带正文 触发)。
+     */
+    private void handleBounceIfNeeded(MktMailMessageEntity mail, String body) {
+        try {
+            String content = joinText(mail == null ? null : mail.getSubject(), body);
+            if (!MktBounceDetector.looksLikeBounceContent(content)) {
+                return;
+            }
+            List<String> failed = MktBounceDetector.extractFailedRecipients(content, null);
+            if (CollectionUtils.isEmpty(failed)) {
+                log.info("mkt bounce content detected but no recipient extracted, mailId={}",
+                        mail == null ? null : mail.getId());
+                return;
+            }
+            String reason = MktBounceDetector.buildBounceReason(content);
+            for (String email : failed) {
+                mktMailBlacklistService.addOrRefresh(
+                        email,
+                        reason,
+                        MktBlacklistSource.BOUNCE,
+                        mail == null ? null : mail.getOwnerBy(),
+                        mail == null ? null : mail.getAccountId(),
+                        mail == null ? null : mail.getId());
+                log.info("mkt bounce auto blacklist, email={}, mailId={}",
+                        email, mail == null ? null : mail.getId());
+            }
+        } catch (Exception e) {
+            log.error("mkt handleBounceIfNeeded error, mailId={}",
+                    mail == null ? null : mail.getId(), e);
+        }
+    }
+
+    private static String joinText(String subject, String body) {
+        StringBuilder sb = new StringBuilder();
+        if (StringUtils.hasText(subject)) {
+            sb.append(subject);
+        }
+        if (StringUtils.hasText(body)) {
+            if (sb.length() > 0) {
+                sb.append('\n');
+            }
+            sb.append(body);
+        }
+        return sb.toString();
+    }
+
+    private void bindReply(Long accountId, Message message, MailReceiveHeadDraft draft, MktMailMessageEntity entity) {
+        List<String> replyIds = MailMessageParser.collectReplyMessageIds(message);
+        if (!CollectionUtils.isEmpty(replyIds)) {
+            entity.setInReplyTo(replyIds.get(0));
+        } else if (draft != null && StringUtils.hasText(draft.getInReplyTo())) {
+            entity.setInReplyTo(MailMessageParser.normalizeMessageId(draft.getInReplyTo()));
+        }
+
+        MktMailMessageEntity parent = null;
+
+        // 1) 从 In-Reply-To/References 解析 track_token,按 token 查原信
+        String trackToken = MktMailHeaders.parseTrackTokenFromCandidates(replyIds);
+        if (StringUtils.hasText(trackToken)) {
+            parent = findByTrackToken(accountId, trackToken);
         }
-        String normalized = MailMessageParser.normalizeMessageId(inReplyTo);
-        entity.setInReplyTo(normalized);
-        MktMailMessageEntity parent = findByMessageId(accountId, normalized);
+
+        // 2) 正文里 class=token 的标记 div(测回复是否保留)
+        if (parent == null) {
+            String bodyToken = MktMailHeaders.parseTrackTokenFromBody(entity.getContent());
+            if (StringUtils.hasText(bodyToken)) {
+                parent = findByTrackToken(accountId, bodyToken);
+            }
+        }
+
+        // 3) 兼容:完整 Message-ID 精确匹配
+        if (parent == null && !CollectionUtils.isEmpty(replyIds)) {
+            for (String mid : replyIds) {
+                parent = findByMessageId(accountId, mid);
+                if (parent != null) {
+                    break;
+                }
+            }
+        }
+
         if (parent == null) {
             return;
         }
@@ -313,6 +455,17 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
         }
     }
 
+    private MktMailMessageEntity findByTrackToken(Long accountId, String trackToken) {
+        if (accountId == null || !StringUtils.hasText(trackToken)) {
+            return null;
+        }
+        return mktMailMessageService.getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .eq(MktMailMessageEntity::getAccountId, accountId)
+                .eq(MktMailMessageEntity::getTrackToken, trackToken.trim())
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .last("limit 1"));
+    }
+
     private MktMailMessageEntity findByMessageId(Long accountId, String messageId) {
         if (!StringUtils.hasText(messageId)) {
             return null;

+ 41 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailBlacklistService.java

@@ -0,0 +1,41 @@
+package com.storlead.sales.mail.mkt.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.mybatis.service.MyBaseService;
+import com.storlead.sales.mail.mkt.entity.MktMailBlacklistEntity;
+import com.storlead.sales.mail.mkt.pojo.MktBlacklistQueryDTO;
+
+import java.util.Collection;
+
+/**
+ * 营销邮件黑名单。
+ */
+public interface MktMailBlacklistService extends MyBaseService<MktMailBlacklistEntity> {
+
+    /**
+     * 查询启用中的黑名单记录;不存在返回 null。
+     */
+    MktMailBlacklistEntity findEnabled(String emailAddress);
+
+    /**
+     * 发信前校验:任一收件人在黑名单则抛 WebException(含黑名单理由),不发送。
+     */
+    void assertRecipientsNotBlacklisted(Collection<String> emails);
+
+    /**
+     * 去掉已在黑名单中的地址,其余原样保留(逗号拼接)。入参为空则原样返回;全部命中则返回 null。
+     */
+    String retainDeliverable(String joinedEmails);
+
+    /**
+     * 加入或刷新黑名单(同邮箱已存在则更新理由并启用)。
+     */
+    MktMailBlacklistEntity addOrRefresh(String emailAddress,
+                                        String reason,
+                                        String source,
+                                        Long operatorId,
+                                        Long accountId,
+                                        Long mailId);
+
+    IPage<MktMailBlacklistEntity> pageList(MktBlacklistQueryDTO query);
+}

+ 3 - 1
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java

@@ -7,6 +7,7 @@ import com.storlead.sales.mail.mkt.pojo.MktEditMailContentDTO;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
+import com.storlead.sales.mail.mkt.pojo.MktSendMailResult;
 
 /**
  * 营销邮件投递业务(对齐 CRM:草稿、附件、延时、发送)。
@@ -21,8 +22,9 @@ public interface MktMailMessageService extends MyBaseService<MktMailMessageEntit
     /**
      * 发送营销邮件;支持附件、CID、延时、打开追踪。
      * 投递走 TransportListener 回调;{@code dto.async=true} 时异步投递。
+     * 返回投递状态和邮件实体;正文仅在发送成功后落库,失败不保存正文。
      */
-    MktMailMessageEntity send(MktSendMailDTO dto, Long operatorId);
+    MktSendMailResult send(MktSendMailDTO dto, Long operatorId);
 
     /**
      * 仅更新正文;可写范围与发送一致(草稿/延时/失败)。

+ 188 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailBlacklistServiceImpl.java

@@ -0,0 +1,188 @@
+package com.storlead.sales.mail.mkt.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
+import com.storlead.framework.web.exception.WebException;
+import com.storlead.sales.mail.mkt.entity.MktMailBlacklistEntity;
+import com.storlead.sales.mail.mkt.enums.MktBlacklistSource;
+import com.storlead.sales.mail.mkt.mapper.MktMailBlacklistMapper;
+import com.storlead.sales.mail.mkt.pojo.MktBlacklistQueryDTO;
+import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+@Service
+public class MktMailBlacklistServiceImpl
+        extends MyBaseServiceImpl<MktMailBlacklistMapper, MktMailBlacklistEntity>
+        implements MktMailBlacklistService {
+
+    @Override
+    public MktMailBlacklistEntity findEnabled(String emailAddress) {
+        String email = normalizeEmail(emailAddress);
+        if (!StringUtils.hasText(email)) {
+            return null;
+        }
+        return this.getOne(new LambdaQueryWrapper<MktMailBlacklistEntity>()
+                .eq(MktMailBlacklistEntity::getEmailAddress, email)
+                .eq(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailBlacklistEntity::getEnabled, Boolean.TRUE)
+                .last("limit 1"));
+    }
+
+    @Override
+    public void assertRecipientsNotBlacklisted(Collection<String> emails) {
+        Set<String> uniq = normalizeEmails(emails);
+        if (uniq.isEmpty()) {
+            return;
+        }
+        for (String email : uniq) {
+            MktMailBlacklistEntity hit = findEnabled(email);
+            if (hit != null) {
+                String reason = StringUtils.hasText(hit.getReason()) ? hit.getReason() : "该邮箱在黑名单中";
+                throw new WebException("收件人 " + email + " 在黑名单中,无法发送。黑名单理由:" + reason);
+            }
+        }
+    }
+
+    @Override
+    public String retainDeliverable(String joinedEmails) {
+        if (!StringUtils.hasText(joinedEmails)) {
+            return joinedEmails;
+        }
+        List<String> kept = new ArrayList<>();
+        boolean removed = false;
+        for (String part : joinedEmails.split("[,;]")) {
+            if (!StringUtils.hasText(part)) {
+                continue;
+            }
+            String raw = part.trim();
+            if (findEnabled(raw) != null) {
+                removed = true;
+                continue;
+            }
+            kept.add(raw);
+        }
+        if (!removed) {
+            return joinedEmails;
+        }
+        if (kept.isEmpty()) {
+            return null;
+        }
+        return String.join(",", kept);
+    }
+
+    @Override
+    public MktMailBlacklistEntity addOrRefresh(String emailAddress,
+                                               String reason,
+                                               String source,
+                                               Long operatorId,
+                                               Long accountId,
+                                               Long mailId) {
+        String email = normalizeEmail(emailAddress);
+        if (!StringUtils.hasText(email)) {
+            return null;
+        }
+        String finalReason = StringUtils.hasText(reason) ? truncate(reason.trim(), 500) : "系统自动加入黑名单";
+        String finalSource = StringUtils.hasText(source) ? source : MktBlacklistSource.MANUAL;
+        Date now = new Date();
+
+        MktMailBlacklistEntity existing = this.getOne(new LambdaQueryWrapper<MktMailBlacklistEntity>()
+                .eq(MktMailBlacklistEntity::getEmailAddress, email)
+                .eq(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .last("limit 1"));
+        if (existing != null) {
+            existing.setReason(finalReason);
+            existing.setSource(finalSource);
+            existing.setAccountId(accountId);
+            existing.setMailId(mailId);
+            existing.setEnabled(Boolean.TRUE);
+            existing.setUpdateTime(now);
+            existing.setUpdateBy(operatorId);
+            this.updateById(existing);
+            return existing;
+        }
+
+        MktMailBlacklistEntity row = new MktMailBlacklistEntity();
+        row.setEmailAddress(email);
+        row.setReason(finalReason);
+        row.setSource(finalSource);
+        row.setAccountId(accountId);
+        row.setMailId(mailId);
+        row.setOwnerBy(operatorId);
+        row.setCreateBy(operatorId);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        row.setIsDelete(CommonConstant.DEL_FLAG_0);
+        row.setEnabled(Boolean.TRUE);
+        try {
+            this.save(row);
+            return row;
+        } catch (DuplicateKeyException e) {
+            return addOrRefresh(email, finalReason, finalSource, operatorId, accountId, mailId);
+        }
+    }
+
+    @Override
+    public IPage<MktMailBlacklistEntity> pageList(MktBlacklistQueryDTO query) {
+        Page<MktMailBlacklistEntity> page = new Page<>(query.getPageIndex(), query.getPageSize());
+        LambdaQueryWrapper<MktMailBlacklistEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MktMailBlacklistEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        if (StringUtils.hasText(query.getEmailAddress())) {
+            wrapper.like(MktMailBlacklistEntity::getEmailAddress, query.getEmailAddress().trim().toLowerCase(Locale.ROOT));
+        }
+        if (StringUtils.hasText(query.getSource())) {
+            wrapper.eq(MktMailBlacklistEntity::getSource, query.getSource());
+        }
+        if (query.getEnabled() != null) {
+            wrapper.eq(MktMailBlacklistEntity::getEnabled, query.getEnabled());
+        }
+        wrapper.orderByDesc(MktMailBlacklistEntity::getId);
+        return this.page(page, wrapper);
+    }
+
+    public static String normalizeEmail(String email) {
+        if (!StringUtils.hasText(email)) {
+            return null;
+        }
+        return email.trim().toLowerCase(Locale.ROOT);
+    }
+
+    private static Set<String> normalizeEmails(Collection<String> emails) {
+        Set<String> set = new LinkedHashSet<>();
+        if (CollectionUtils.isEmpty(emails)) {
+            return set;
+        }
+        for (String raw : emails) {
+            if (!StringUtils.hasText(raw)) {
+                continue;
+            }
+            for (String part : raw.split("[,;]")) {
+                String email = normalizeEmail(part);
+                if (StringUtils.hasText(email)) {
+                    set.add(email);
+                }
+            }
+        }
+        return set;
+    }
+
+    private static String truncate(String value, int max) {
+        if (value == null || value.length() <= max) {
+            return value;
+        }
+        return value.substring(0, max);
+    }
+}

+ 148 - 83
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -26,7 +26,9 @@ import com.storlead.sales.mail.mkt.pojo.MktEditMailContentDTO;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
 import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
+import com.storlead.sales.mail.mkt.pojo.MktSendMailResult;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
+import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import com.storlead.sales.mail.mkt.service.MktMailStatusService;
 import com.storlead.sales.mail.mkt.support.MktMailHeaders;
@@ -47,6 +49,7 @@ import javax.activation.FileDataSource;
 import javax.annotation.Resource;
 import javax.mail.BodyPart;
 import javax.mail.Message;
+import javax.mail.MessagingException;
 import javax.mail.Multipart;
 import javax.mail.Session;
 import javax.mail.Transport;
@@ -93,6 +96,9 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     @Resource
     private MktMailStatusService mktMailStatusService;
 
+    @Resource
+    private MktMailBlacklistService mktMailBlacklistService;
+
     @Resource
     private MailFileProperties mailFileProperties;
 
@@ -139,70 +145,100 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public MktMailMessageEntity send(MktSendMailDTO dto, Long operatorId) {
-        try {
-            if (dto == null || dto.getAccountId() == null) {
-                throw new WebException("发信账户不能为空");
-            }
-            if (CollectionUtils.isEmpty(dto.getRecipientls())) {
-                throw new WebException("收件人不能为空");
-            }
-            dto.setRecipient(dto.getRecipientls().get(0));
-            if (!StringUtils.hasText(dto.getRecipient())) {
-                throw new WebException("收件人不能为空");
-            }
-            if (!StringUtils.hasText(dto.getSubject())) {
-                throw new WebException("主题不能为空");
-            }
+    public MktSendMailResult send(MktSendMailDTO dto, Long operatorId) {
+        if (dto == null || dto.getAccountId() == null) {
+            throw new WebException("发信账户不能为空");
+        }
+        // 营销发信固定开启打开追踪(忽略入参 isTrack)
+        dto.setIsTrack(1);
+        if (CollectionUtils.isEmpty(dto.getRecipientls())) {
+            throw new WebException("收件人不能为空");
+        }
+        dto.setRecipient(dto.getRecipientls().get(0));
+        if (!StringUtils.hasText(dto.getRecipient())) {
+            throw new WebException("收件人不能为空");
+        }
+        if (!StringUtils.hasText(dto.getSubject())) {
+            throw new WebException("主题不能为空");
+        }
 
-            SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(dto.getAccountId(), MailSceneEnum.MKT);
-
-            MktMailMessageEntity entity = resolveWritableEntity(dto.getId(), operatorId);
-            fillMessageFields(entity, dto, account, operatorId);
-            ensureTrackToken(entity, dto.getIsTrack());
-            assertTrackReady(entity);
-
-            boolean delayed = dto.getDelaySendTime() != null && dto.getDelaySendTime().after(new Date());
-            entity.setFolder(MktMailFolderEnum.SENTING.code);
-            entity.setInOutMark(2);
-            if (delayed) {
-                entity.setStatus(STATUS_DELAY);
-                entity.setDelaySendTime(dto.getDelaySendTime());
-                this.saveOrUpdate(entity);
-                mktMailStatusService.ensureStatus(entity);
-                bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
-                copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
-                return entity;
-            }
+        SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(dto.getAccountId(), MailSceneEnum.MKT);
 
-            entity.setStatus(STATUS_SENDING);
-            entity.setDelaySendTime(null);
+        MktMailMessageEntity entity = resolveWritableEntity(dto.getId(), operatorId);
+        fillMessageFields(entity, dto, account, operatorId);
+        ensureTrackToken(entity, dto.getIsTrack());
+        assertTrackReady(entity);
+        // 主收件人在黑名单则整封不发;抄送/密送里的黑名单地址剔除后再发
+        applyRecipientBlacklist(entity, false);
+
+        boolean delayed = dto.getDelaySendTime() != null && dto.getDelaySendTime().after(new Date());
+        entity.setFolder(MktMailFolderEnum.SENTING.code);
+        entity.setInOutMark(2);
+        if (delayed) {
+            // 延时尚未投递,正文需先落库供到期任务使用
+            entity.setStatus(STATUS_DELAY);
+            entity.setDelaySendTime(dto.getDelaySendTime());
             this.saveOrUpdate(entity);
             mktMailStatusService.ensureStatus(entity);
             bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
-            // 正式附件拷入临时表,MIME 与成功后转正式统一按 mailId 处理,避免重复挂载
             copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
+            return toSendResult(null, entity);
+        }
 
-            SmtpPopSettingsEntity decrypted = decryptAccount(account);
-            boolean async = Boolean.TRUE.equals(dto.getAsync());
-            if (async) {
-                dispatchSmtpSend(entity.getId(), decrypted, true);
-                return entity;
-            }
-            MailSendStatus status = dispatchSmtpSend(entity.getId(), decrypted, false);
-            MktMailMessageEntity latest = this.getById(entity.getId());
-            if (latest != null) {
-                if (status == MailSendStatus.SERVER_LOGIN_FAIL || status == MailSendStatus.FAILED
-                        || status == MailSendStatus.SERVER_EXCEPTION_550) {
-                    // 状态已由 MktMailSender 回调回写
-                }
-                return latest;
-            }
-            return entity;
-        }catch (Exception e) {
-            log.error("send mail error ----", e);
-            return null;
+        entity.setStatus(STATUS_SENDING);
+        entity.setDelaySendTime(null);
+        this.saveOrUpdate(entity);
+        mktMailStatusService.ensureStatus(entity);
+        bindTempAttachments(entity.getId(), dto.getFileIds(), operatorId);
+        copyFormalToTemp(entity.getId(), dto.getOldFileIds(), operatorId);
+
+        SmtpPopSettingsEntity decrypted = decryptAccount(account);
+        boolean async = Boolean.TRUE.equals(dto.getAsync());
+        MailSendStatus status = dispatchSmtpSend(entity.getId(), decrypted, async, entity.getContent());
+        MktMailMessageEntity latest = this.getById(entity.getId());
+        if (latest == null) {
+            latest = entity;
+        }
+        if (async) {
+            return toSendResult(null, latest);
         }
+        return toSendResult(status, latest);
+    }
+
+    /**
+     * 主收件人在黑名单则抛出,不发送;抄送、密送中的黑名单地址去掉,不投递到这些邮箱。
+     *
+     * @param persistStrip 延时任务已落库时,把剔除后的抄送/密送写回
+     */
+    private void applyRecipientBlacklist(MktMailMessageEntity entity, boolean persistStrip) {
+        List<String> to = new ArrayList<>();
+        to.add(entity.getRecipient());
+        mktMailBlacklistService.assertRecipientsNotBlacklisted(to);
+        String cc = emptyIfNull(mktMailBlacklistService.retainDeliverable(entity.getRecipientCc()));
+        String bcc = emptyIfNull(mktMailBlacklistService.retainDeliverable(entity.getRecipientBcc()));
+        String oldCc = emptyIfNull(entity.getRecipientCc());
+        String oldBcc = emptyIfNull(entity.getRecipientBcc());
+        entity.setRecipientCc(StringUtils.hasText(cc) ? cc : "");
+        entity.setRecipientBcc(StringUtils.hasText(bcc) ? bcc : "");
+        if (!persistStrip || entity.getId() == null || (cc.equals(oldCc) && bcc.equals(oldBcc))) {
+            return;
+        }
+        LambdaUpdateWrapper<MktMailMessageEntity> update = new LambdaUpdateWrapper<>();
+        update.eq(MktMailMessageEntity::getId, entity.getId());
+        update.set(MktMailMessageEntity::getRecipientCc, cc);
+        update.set(MktMailMessageEntity::getRecipientBcc, bcc);
+        this.update(update);
+    }
+
+    private static String emptyIfNull(String value) {
+        return value == null ? "" : value;
+    }
+
+    private MktSendMailResult toSendResult(MailSendStatus status, MktMailMessageEntity mail) {
+        MktSendMailResult result = new MktSendMailResult();
+        result.setSendStatus(status);
+        result.setMail(mail);
+        return result;
     }
 
     @Override
@@ -252,11 +288,22 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
                 continue;
             }
             try {
+                applyRecipientBlacklist(entity, true);
+
                 SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(entity.getAccountId(), MailSceneEnum.MKT);
-                MailSendStatus status = dispatchSmtpSend(entity.getId(), decryptAccount(account), false);
+                MailSendStatus status = dispatchSmtpSend(entity.getId(), decryptAccount(account), false, null);
                 if (status == MailSendStatus.SUCCESS || status == MailSendStatus.PARTIAL_SUCCESS) {
                     success++;
                 }
+            } catch (WebException bex) {
+                log.warn("mkt delay send blocked by blacklist, id={}, msg={}", item.getId(), bex.getMessage());
+                LambdaUpdateWrapper<MktMailMessageEntity> fail = new LambdaUpdateWrapper<>();
+                fail.eq(MktMailMessageEntity::getId, item.getId());
+                fail.eq(MktMailMessageEntity::getStatus, STATUS_SENDING);
+                fail.set(MktMailMessageEntity::getStatus, STATUS_FAIL);
+                fail.set(MktMailMessageEntity::getFailReason, bex.getMessage());
+                this.update(fail);
+                mktMailStatusService.markSendStatus(item.getId(), STATUS_FAIL);
             } catch (Exception ex) {
                 log.error("mkt delay send fail, id={}", item.getId(), ex);
                 LambdaUpdateWrapper<MktMailMessageEntity> fail = new LambdaUpdateWrapper<>();
@@ -412,11 +459,9 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     }
 
     /**
-     * 对齐 CRM:Session + Transport + MktMailSender(TransportListener 回调)。
-     *
-     * @param async true 时事务提交后异步投递并立即返回;false 同步等待结果
+     * @param contentToSend 本次发出的正文;为空则使用库内已有正文(延时任务)
      */
-    private MailSendStatus dispatchSmtpSend(Long mailId, SmtpPopSettingsEntity account, boolean async) {
+    private MailSendStatus dispatchSmtpSend(Long mailId, SmtpPopSettingsEntity account, boolean async, String contentToSend) {
         if (async) {
             Runnable task = () -> {
                 try {
@@ -424,6 +469,9 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
                     if (entity == null || !Integer.valueOf(STATUS_SENDING).equals(entity.getStatus())) {
                         return;
                     }
+                    if (contentToSend != null) {
+                        entity.setContent(contentToSend);
+                    }
                     Session session = MailTransportSupport.createSmtpSession(account);
                     Transport transport = session.getTransport("smtp");
                     MimeMessage message = buildMimeMessage(session, entity, account);
@@ -443,7 +491,7 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
             } else {
                 task.run();
             }
-            return MailSendStatus.SUCCESS;
+            return null;
         }
 
         try {
@@ -451,6 +499,9 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
             if (entity == null) {
                 return MailSendStatus.FAILED;
             }
+            if (contentToSend != null) {
+                entity.setContent(contentToSend);
+            }
             Session session = MailTransportSupport.createSmtpSession(account);
             Transport transport = session.getTransport("smtp");
             MimeMessage message = buildMimeMessage(session, entity, account);
@@ -476,12 +527,25 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     private MimeMessage buildMimeMessage(Session session,
                                          MktMailMessageEntity entity,
                                          SmtpPopSettingsEntity account) throws Exception {
-        // 定时任务/直接插库场景:发送前补齐 track_token,并保证状态行存在
+        // 定时任务/直接插库场景:发送前补齐 track_token(回复绑定与打开追踪共用),并保证状态行存在
         prepareTrackBeforeSend(entity);
         // 开启追踪时必须能注入像素(避免只有 token、发出去却没有像素)
         assertTrackReady(entity);
 
-        MimeMessage message = new MimeMessage(session);
+        MimeMessage message = new MimeMessage(session) {
+            @Override
+            protected void updateMessageID() throws MessagingException {
+                if (entity.getId() != null && StringUtils.hasText(entity.getTrackToken())) {
+                    String mid = StringUtils.hasText(entity.getMessageId())
+                            ? entity.getMessageId()
+                            : MktMailHeaders.buildMessageId(entity.getId(), entity.getTrackToken(),
+                            account.getEmailAddress());
+                    setHeader("Message-ID", mid);
+                    return;
+                }
+                super.updateMessageID();
+            }
+        };
         String fromName = StringUtils.hasText(account.getSenderNickName())
                 ? account.getSenderNickName() : account.getEmailAddress();
         message.setFrom(new InternetAddress(account.getEmailAddress(), fromName, "UTF-8"));
@@ -498,16 +562,11 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         BodyPart htmlPart = new MimeBodyPart();
         List<MimeBodyPart> imageParts = new ArrayList<>();
         String originalHtml = entity.getContent() == null ? "" : entity.getContent();
-        String html = injectTrackPixel(originalHtml, entity);
-        // 像素写入 content 字段,便于库内核对;CID 转换只作用于发出去的 MIME
-        if (entity.getId() != null && !Objects.equals(originalHtml, html)) {
-            entity.setContent(html);
-            LambdaUpdateWrapper<MktMailMessageEntity> contentUpdate = new LambdaUpdateWrapper<>();
-            contentUpdate.eq(MktMailMessageEntity::getId, entity.getId());
-            contentUpdate.set(MktMailMessageEntity::getContent, html);
-            this.update(contentUpdate);
-            log.info("mkt persist track pixel into content, mailId={}", entity.getId());
-        }
+        // 无效果 div.class=token,测回复引用是否保留;另注入打开追踪像素
+        String withTokenDiv = MktMailHeaders.injectTokenClassDiv(originalHtml, entity.getTrackToken());
+        String html = injectTrackPixel(withTokenDiv, entity);
+        // 追踪像素写入内存中的 MIME;投递成功后由回调覆盖库内正文,失败不改正文
+        entity.setContent(html);
         String updatedHtml = EmailHelper.convertImageUrlsToCidBodyPart(
                 html, imageParts, mailFileProperties.getPath().getJpeg(), multipart);
         if (updatedHtml == null) {
@@ -523,9 +582,18 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
 
         message.setContent(multipart);
-        // 携带本地主键,收信从服务器拉回时按此头更新原记录,不新增
+        // 携带本地主键,收信从服务器拉回时按此头更新原记录,不新增(客户回复通常不会带回)
         if (entity.getId() != null) {
             message.setHeader(MktMailHeaders.LOCAL_MAIL_ID, String.valueOf(entity.getId()));
+            if (!StringUtils.hasText(entity.getMessageId()) && StringUtils.hasText(entity.getTrackToken())) {
+                String mid = MktMailHeaders.buildMessageId(entity.getId(), entity.getTrackToken(),
+                        account.getEmailAddress());
+                entity.setMessageId(mid);
+                LambdaUpdateWrapper<MktMailMessageEntity> midUpdate = new LambdaUpdateWrapper<>();
+                midUpdate.eq(MktMailMessageEntity::getId, entity.getId());
+                midUpdate.set(MktMailMessageEntity::getMessageId, mid);
+                this.update(midUpdate);
+            }
         }
         message.saveChanges();
         return message;
@@ -551,19 +619,18 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     }
 
     /**
-     * 实际 SMTP 发送前补齐追踪:支持定时任务 / 第三方直接插库。
-     * is_track=1 且无 track_token 时生成并回写库;同步 ensure 状态行。
+     * 实际 SMTP 发送前:保证有 track_token(回复绑定 Message-ID 与打开像素共用);
+     * is_track=1 时才注入像素。同步 ensure 状态行。
      */
     private void prepareTrackBeforeSend(MktMailMessageEntity entity) {
         if (entity == null || entity.getId() == null) {
             return;
         }
-        if (isTrackOn(entity) && !StringUtils.hasText(entity.getTrackToken())) {
+        if (!StringUtils.hasText(entity.getTrackToken())) {
             String token = UUID.randomUUID().toString().replace("-", "");
             entity.setTrackToken(token);
             LambdaUpdateWrapper<MktMailMessageEntity> update = new LambdaUpdateWrapper<>();
             update.eq(MktMailMessageEntity::getId, entity.getId());
-            update.set(MktMailMessageEntity::getIsTrack, 1);
             update.set(MktMailMessageEntity::getTrackToken, token);
             this.update(update);
             log.info("mkt prepare track_token before send, mailId={}", entity.getId());
@@ -748,12 +815,10 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     private void ensureTrackToken(MktMailMessageEntity entity, Integer isTrack) {
         int track = isTrack == null ? (entity.getIsTrack() == null ? 0 : entity.getIsTrack()) : isTrack;
         entity.setIsTrack(track);
-        if (track == 1 && !StringUtils.hasText(entity.getTrackToken())) {
+        // track_token 始终保留:打开像素与回复绑定 Message-ID 共用;像素仅在 is_track=1 时注入
+        if (!StringUtils.hasText(entity.getTrackToken())) {
             entity.setTrackToken(UUID.randomUUID().toString().replace("-", ""));
         }
-        if (track != 1) {
-            entity.setTrackToken(null);
-        }
     }
 
     private SmtpPopSettingsEntity decryptAccount(SmtpPopSettingsEntity account) {

+ 1 - 1
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailReceiveServiceImpl.java

@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 
 /**
- * 营销收信入口:复用公共 {@link MailReceiveEngine},落库由 {@link MktMailReceivePersist} 完成。
+ * 营销收信入口:头+正文一次落库;附件在 afterHeadsSaved 异步处理。
  */
 @Log4j2
 @Service

+ 129 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/support/MktBounceDetector.java

@@ -0,0 +1,129 @@
+package com.storlead.sales.mail.mkt.support;
+
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 退信失败收件人提取:系统退信发件人无业务意义,从正文说明里取被退回的邮箱。
+ * 例:收件人邮件地址(123456917@qq.com)不存在,邮件无法送达。
+ */
+public final class MktBounceDetector {
+
+    private static final Pattern EMAIL_PATTERN = Pattern.compile(
+            "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
+
+    /** 中文退信常见写法:收件人邮件地址(a@b.com)… */
+    private static final Pattern CN_RECIPIENT_ADDR = Pattern.compile(
+            "收件人(?:邮件)?地址\\s*[((]\\s*(" + EMAIL_PATTERN.pattern() + ")\\s*[))]",
+            Pattern.CASE_INSENSITIVE);
+
+    /** 收件人:a@b.com / 收件人 a@b.com */
+    private static final Pattern CN_RECIPIENT_LABEL = Pattern.compile(
+            "收件人\\s*[::\\s]\\s*(" + EMAIL_PATTERN.pattern() + ")",
+            Pattern.CASE_INSENSITIVE);
+
+    /** 英文 DSN:Final-Recipient: rfc822; a@b.com */
+    private static final Pattern DSN_RECIPIENT = Pattern.compile(
+            "(?i)(?:Final-Recipient|Original-Recipient|X-Failed-Recipients)\\s*:\\s*(?:rfc822;\\s*)?([^\\r\\n]+)");
+
+    private static final String[] BOUNCE_CONTENT_HINTS = {
+            "不存在", "无法送达", "无法投递", "投递失败", "退信", "邮递失败",
+            "undeliverable", "undelivered", "delivery failed", "user unknown",
+            "mailbox not found", "recipient rejected"
+    };
+
+    private MktBounceDetector() {
+    }
+
+    /**
+     * 正文是否像退信说明(含“不存在/无法送达”等)。
+     */
+    public static boolean looksLikeBounceContent(String content) {
+        if (!StringUtils.hasText(content)) {
+            return false;
+        }
+        String text = content.toLowerCase(Locale.ROOT);
+        for (String hint : BOUNCE_CONTENT_HINTS) {
+            if (text.contains(hint.toLowerCase(Locale.ROOT))) {
+                return true;
+            }
+        }
+        return CN_RECIPIENT_ADDR.matcher(content).find();
+    }
+
+    /**
+     * 从退信正文提取被退回的邮箱(去重);排除本账户地址与系统地址。
+     */
+    public static List<String> extractFailedRecipients(String content, String excludeAddr) {
+        Set<String> emails = new LinkedHashSet<>();
+        if (!StringUtils.hasText(content)) {
+            return new ArrayList<>();
+        }
+
+        collectByPattern(CN_RECIPIENT_ADDR, content, emails);
+        collectByPattern(CN_RECIPIENT_LABEL, content, emails);
+
+        Matcher dsn = DSN_RECIPIENT.matcher(content);
+        while (dsn.find()) {
+            collectEmails(dsn.group(1), emails);
+        }
+
+        // 仍未命中时:仅在正文含退信关键词时,取文中邮箱(避免普通邮件误抽)
+        if (emails.isEmpty() && looksLikeBounceContent(content)) {
+            collectEmails(content, emails);
+        }
+
+        String exclude = excludeAddr == null ? null : excludeAddr.trim().toLowerCase(Locale.ROOT);
+        List<String> result = new ArrayList<>();
+        for (String email : emails) {
+            if (exclude != null && exclude.equals(email)) {
+                continue;
+            }
+            if (email.contains("mailer-daemon") || email.startsWith("postmaster@")
+                    || email.startsWith("noreply@") || email.startsWith("no-reply@")) {
+                continue;
+            }
+            result.add(email);
+        }
+        return result;
+    }
+
+    public static String buildBounceReason(String content) {
+        if (!StringUtils.hasText(content)) {
+            return "检测到退信";
+        }
+        String snippet = content.replaceAll("\\s+", " ").trim();
+        if (snippet.length() > 200) {
+            snippet = snippet.substring(0, 200);
+        }
+        String reason = "退信:" + snippet;
+        return reason.length() > 500 ? reason.substring(0, 500) : reason;
+    }
+
+    private static void collectByPattern(Pattern pattern, String content, Set<String> out) {
+        Matcher m = pattern.matcher(content);
+        while (m.find()) {
+            String email = m.group(1);
+            if (StringUtils.hasText(email)) {
+                out.add(email.trim().toLowerCase(Locale.ROOT));
+            }
+        }
+    }
+
+    private static void collectEmails(String text, Set<String> out) {
+        if (!StringUtils.hasText(text)) {
+            return;
+        }
+        Matcher m = EMAIL_PATTERN.matcher(text);
+        while (m.find()) {
+            out.add(m.group().toLowerCase(Locale.ROOT));
+        }
+    }
+}

+ 129 - 4
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/support/MktMailHeaders.java

@@ -1,15 +1,140 @@
 package com.storlead.sales.mail.mkt.support;
 
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 /**
- * 营销发信与收信对齐用的自定义邮件头:携带本地主键 id。
+ * 营销发信埋点:可控 Message-ID 携带 track_token;
+ * 另在正文注入无展示效果的 div(class=token),用于测试回复引用是否保留。
+ * <p>
+ * Message-ID 格式:{@code <mkt.{mailId}.{trackToken}@{domain}>}
  */
 public final class MktMailHeaders {
 
-    /**
-     * 发出 MIME 时写入,收信从服务器拉回 Sent/INBOX 时用此头匹配本地 mkt_mail_message.id。
-     */
     public static final String LOCAL_MAIL_ID = "X-Storlead-Mkt-Mail-Id";
 
+    private static final Pattern MESSAGE_ID_PATTERN = Pattern.compile(
+            "(?i)<\\s*mkt\\.(\\d+)\\.([A-Za-z0-9_-]+)@([^>\\s]+)\\s*>");
+
+    /** 正文里 class=token 的空 div,用于回复绑定时从引用正文取 token */
+    private static final Pattern BODY_TOKEN_CLASS_PATTERN = Pattern.compile(
+            "(?i)<div\\s+[^>]*class\\s*=\\s*[\"']([A-Za-z0-9_-]{8,})[\"'][^>]*>");
+
     private MktMailHeaders() {
     }
+
+    public static String buildMessageId(Long mailId, String trackToken, String fromAddr) {
+        if (mailId == null) {
+            throw new IllegalArgumentException("mailId required");
+        }
+        if (!StringUtils.hasText(trackToken)) {
+            throw new IllegalArgumentException("trackToken required");
+        }
+        String domain = extractDomain(fromAddr);
+        return "<mkt." + mailId + "." + trackToken.trim() + "@" + domain + ">";
+    }
+
+    /**
+     * 注入无视觉效果的标记 div,class 即为 track_token(测回复是否保留)。
+     */
+    public static String injectTokenClassDiv(String html, String trackToken) {
+        if (!StringUtils.hasText(trackToken)) {
+            return html == null ? "" : html;
+        }
+        String body = html == null ? "" : html;
+        String token = trackToken.trim();
+        if (body.contains("class=\"" + token + "\"") || body.contains("class='" + token + "'")) {
+            return body;
+        }
+        String marker = "<div class=\"" + token
+                + "\" style=\"display:none!important;width:0;height:0;overflow:hidden;font-size:0;line-height:0;\"></div>";
+        int idx = body.toLowerCase(Locale.ROOT).lastIndexOf("</body>");
+        if (idx >= 0) {
+            return body.substring(0, idx) + marker + body.substring(idx);
+        }
+        return body + marker;
+    }
+
+    /**
+     * 从正文(含回复引用)解析 class=token 标记中的 track_token。
+     */
+    public static String parseTrackTokenFromBody(String content) {
+        if (!StringUtils.hasText(content)) {
+            return null;
+        }
+        Matcher m = BODY_TOKEN_CLASS_PATTERN.matcher(content);
+        if (m.find()) {
+            return m.group(1);
+        }
+        return null;
+    }
+
+    public static String parseTrackToken(String messageId) {
+        Matcher m = matchMessageId(messageId);
+        return m == null ? null : m.group(2);
+    }
+
+    public static String parseTrackTokenFromCandidates(Iterable<String> messageIds) {
+        if (messageIds == null) {
+            return null;
+        }
+        for (String id : messageIds) {
+            String token = parseTrackToken(id);
+            if (StringUtils.hasText(token)) {
+                return token;
+            }
+        }
+        return null;
+    }
+
+    public static String extractDomain(String emailOrHost) {
+        if (!StringUtils.hasText(emailOrHost)) {
+            return "storlead.local";
+        }
+        String v = emailOrHost.trim();
+        int at = v.lastIndexOf('@');
+        if (at >= 0 && at < v.length() - 1) {
+            return v.substring(at + 1).toLowerCase(Locale.ROOT);
+        }
+        return v.toLowerCase(Locale.ROOT);
+    }
+
+    public static List<String> splitMessageIds(String headerValue) {
+        Set<String> ids = new LinkedHashSet<>();
+        if (!StringUtils.hasText(headerValue)) {
+            return new ArrayList<>();
+        }
+        Matcher m = Pattern.compile("<[^>]+>").matcher(headerValue);
+        while (m.find()) {
+            ids.add(m.group().trim());
+        }
+        if (ids.isEmpty()) {
+            for (String part : headerValue.trim().split("\\s+")) {
+                if (StringUtils.hasText(part)) {
+                    ids.add(part.trim());
+                }
+            }
+        }
+        return new ArrayList<>(ids);
+    }
+
+    private static Matcher matchMessageId(String messageId) {
+        if (!StringUtils.hasText(messageId)) {
+            return null;
+        }
+        String v = messageId.trim();
+        Matcher m = MESSAGE_ID_PATTERN.matcher(v);
+        if (m.find()) {
+            return m;
+        }
+        Matcher bare = MESSAGE_ID_PATTERN.matcher("<" + v + ">");
+        return bare.find() ? bare : null;
+    }
 }

+ 64 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/util/MktMailSender.java

@@ -10,7 +10,9 @@ import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.mkt.callback.MktMailSendCallback;
 import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+import com.storlead.sales.mail.mkt.enums.MktBlacklistSource;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
+import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import com.storlead.sales.mail.mkt.service.MktMailStatusService;
 import lombok.extern.log4j.Log4j2;
@@ -21,11 +23,13 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
+import javax.mail.Address;
 import javax.mail.AuthenticationFailedException;
 import javax.mail.Message;
 import javax.mail.MessagingException;
 import javax.mail.SendFailedException;
 import javax.mail.Transport;
+import javax.mail.internet.InternetAddress;
 import javax.mail.event.TransportEvent;
 import javax.mail.event.TransportListener;
 import javax.mail.internet.MimeMessage;
@@ -60,6 +64,9 @@ public class MktMailSender {
     @Resource
     private MktMailStatusService mktMailStatusService;
 
+    @Resource
+    private MktMailBlacklistService mktMailBlacklistService;
+
     private final List<MktMailSendCallback> sendCallbacks;
 
     public MktMailSender(ObjectProvider<MktMailSendCallback> callbackProvider) {
@@ -136,6 +143,7 @@ public class MktMailSender {
         } catch (SendFailedException e) {
             log.error("mkt SendFailedException, mailId={}", mail == null ? null : mail.getId(), e);
             onEmailSentFailure(mail, e.getMessage());
+            blacklistOn550(mail, e);
             completeQuietly(future, MailSendStatus.SERVER_EXCEPTION_550);
         } catch (AuthenticationFailedException e) {
             log.error("mkt AuthenticationFailedException, mailId={}", mail == null ? null : mail.getId(), e);
@@ -180,6 +188,9 @@ public class MktMailSender {
             if (StringUtils.hasText(messageId)) {
                 update.set(MktMailMessageEntity::getMessageId, messageId);
             }
+            if (StringUtils.hasText(entity.getContent())) {
+                update.set(MktMailMessageEntity::getContent, entity.getContent());
+            }
             boolean updated = mktMailMessageService.update(update);
             if (!updated) {
                 return false;
@@ -231,6 +242,59 @@ public class MktMailSender {
         }
     }
 
+    /**
+     * 仅 {@link MailSendStatus#SERVER_EXCEPTION_550}:把拒收地址写入黑名单。
+     * 优先用异常里的无效地址,没有则回落本封的主收件人。
+     */
+    private void blacklistOn550(MktMailMessageEntity mail, SendFailedException exception) {
+        if (mail == null) {
+            return;
+        }
+        List<String> emails = addressList(exception == null ? null : exception.getInvalidAddresses());
+        if (emails.isEmpty() && StringUtils.hasText(mail.getRecipient())) {
+            emails.add(mail.getRecipient());
+        }
+        if (emails.isEmpty()) {
+            return;
+        }
+        String detail = exception == null ? null : exception.getMessage();
+        String blacklistReason = StringUtils.hasText(detail)
+                ? ("SMTP 550 拒收:" + truncate(detail, 450))
+                : "SMTP 550 拒收";
+        for (String email : emails) {
+            try {
+                mktMailBlacklistService.addOrRefresh(
+                        email,
+                        blacklistReason,
+                        MktBlacklistSource.SMTP_FAIL,
+                        mail.getOwnerBy(),
+                        mail.getAccountId(),
+                        mail.getId());
+                log.info("mkt 550 auto blacklist, email={}, mailId={}", email, mail.getId());
+            } catch (Exception e) {
+                log.error("mkt add smtp 550 blacklist error, mailId={}, email={}", mail.getId(), email, e);
+            }
+        }
+    }
+
+    private static List<String> addressList(Address[] addresses) {
+        List<String> emails = new ArrayList<>();
+        if (addresses == null) {
+            return emails;
+        }
+        for (Address address : addresses) {
+            if (address instanceof InternetAddress) {
+                String email = ((InternetAddress) address).getAddress();
+                if (StringUtils.hasText(email)) {
+                    emails.add(email);
+                }
+            } else if (address != null && StringUtils.hasText(address.toString())) {
+                emails.add(address.toString());
+            }
+        }
+        return emails;
+    }
+
     private void promoteTempAttachments(MktMailMessageEntity entity) {
         List<MailTempAttachmentEntity> temps = tempAttachmentService.list(
                 new LambdaQueryWrapper<MailTempAttachmentEntity>()