Эх сурвалжийг харах

修改像素跟踪和测试

chenkq 2 долоо хоног өмнө
parent
commit
bfb22ab35a

+ 1 - 0
storlead-framework/storlead-common/src/main/java/com/storlead/framework/common/properties/UrlChainDefinitionPorperties.java

@@ -84,6 +84,7 @@ public class UrlChainDefinitionPorperties {
 			"/mail/draft_download_url",
 			"/mail/download_url",
 			"/mail/mkt/track/**",
+			"/**/mail/mkt/track/**",
 			"/wxapi/wxclientmenu/**",
 			"/wxapi/wxclientmenu/*",
 			"/wxapi/getauth",

+ 4 - 0
storlead-framework/storlead-web/src/main/java/com/storlead/framework/web/filter/AuthRequestFilter.java

@@ -124,6 +124,10 @@ public class AuthRequestFilter implements Filter, Ordered {
     private Boolean loginHandle(HttpServletRequest req) {
         // 获取URL
         String url = req.getRequestURI();
+        // 营销打开追踪像素:必须免登录(邮件客户端无法带 token)
+        if (StrUtil.isNotBlank(url) && url.contains("/mail/mkt/track/")) {
+            return true;
+        }
         Boolean isNeedLogin = chainBlackAndWhiteUtil.IsWhiteUri(url);
         if (isNeedLogin) {
             return isNeedLogin;

+ 16 - 11
storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java

@@ -25,10 +25,8 @@ import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.io.FilenameUtils;
-import org.springframework.http.CacheControl;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -52,7 +50,7 @@ import java.util.Objects;
  * 营销邮件 API(与 CRM /mail 隔离):草稿、附件、发送、追踪。
  */
 @RestController
-@RequestMapping("/mail/mkt")
+@RequestMapping("/mkt")
 @Api(tags = "021.营销邮件接口")
 @Log4j2
 public class MktMailApiController {
@@ -238,20 +236,27 @@ public class MktMailApiController {
         return Result.result(stats);
     }
 
-    @GetMapping(value = "/track/{token}", produces = MediaType.IMAGE_GIF_VALUE)
+    @GetMapping("/track/{token}")
     @ApiOperation("打开追踪像素(免登录;始终返回 1x1 GIF)")
-    public ResponseEntity<byte[]> track(@PathVariable("token") String token) {
+    public void track(@PathVariable("token") String token, HttpServletResponse response) {
         try {
             mktMailMessageService.markOpened(token);
         } catch (Exception e) {
             log.warn("mkt track markOpened error, token={}", token, e);
         }
-        return ResponseEntity.ok()
-                .contentType(MediaType.IMAGE_GIF)
-                .cacheControl(CacheControl.noStore().mustRevalidate())
-                .header(HttpHeaders.PRAGMA, "no-cache")
-                .header("Expires", "0")
-                .body(PIXEL_GIF);
+        try {
+            response.resetBuffer();
+            response.setStatus(HttpServletResponse.SC_OK);
+            response.setContentType(MediaType.IMAGE_GIF_VALUE);
+            response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store, no-cache, must-revalidate");
+            response.setHeader(HttpHeaders.PRAGMA, "no-cache");
+            response.setHeader("Expires", "0");
+            response.setContentLength(PIXEL_GIF.length);
+            response.getOutputStream().write(PIXEL_GIF);
+            response.getOutputStream().flush();
+        } catch (Exception e) {
+            log.warn("mkt track write gif error, token={}", token, e);
+        }
     }
 
     private void writeFileResponse(String relativePath, String fileName, String fileExt, HttpServletResponse response) {

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

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

+ 396 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_message_insert_doc.json

@@ -0,0 +1,396 @@
+{
+  "table": "mkt_mail_message",
+  "purpose": "通过 SQL 直接插入营销邮件,由定时任务 DelaySendEmailTaskJob / sendDueDelayedMails 扫描发送",
+  "scene": "定时发送 + 可选打开追踪",
+  "db_not_null": ["account_id", "recipient"],
+  "business_required_for_delay_send": {
+    "description": "要被定时任务捞到并成功发出,除库表 NOT NULL 外还需满足下列条件",
+    "fields": [
+      {
+        "name": "account_id",
+        "required": true,
+        "note": "smtp_pop_settings.id,且 scene=MKT、enabled=1、密码可解密"
+      },
+      {
+        "name": "recipient",
+        "required": true,
+        "note": "收件人邮箱,单人一行"
+      },
+      {
+        "name": "subject",
+        "required": true,
+        "note": "主题;发送组 MIME 时会用到,建议必填"
+      },
+      {
+        "name": "content",
+        "required": true,
+        "note": "HTML 正文;像素在真正 SMTP 发送时注入,不必在 content 里手写"
+      },
+      {
+        "name": "status",
+        "required": true,
+        "value": -1,
+        "note": "必须为 -1(待定时发送),任务只扫 status=-1"
+      },
+      {
+        "name": "delay_send_time",
+        "required": true,
+        "note": "定时发送时间;任务条件 delay_send_time <= now()"
+      },
+      {
+        "name": "folder",
+        "required": true,
+        "recommended": "SENTING",
+        "note": "发件中;与系统发信路径一致"
+      },
+      {
+        "name": "in_out_mark",
+        "required": true,
+        "recommended": 2,
+        "note": "2=发件"
+      },
+      {
+        "name": "is_delete",
+        "required": true,
+        "value": 0,
+        "note": "必须为 0,任务会过滤已删除"
+      },
+      {
+        "name": "is_track",
+        "required_if_open_track": true,
+        "value": 1,
+        "note": "需要打开追踪时填 1;发送时若无 track_token 会自动生成并注入像素"
+      }
+    ]
+  },
+  "optional_on_insert": {
+    "track_token": "可不填;is_track=1 时由发送前 prepareTrackBeforeSend 自动生成",
+    "from_addr": "建议填账户邮箱;不填则仅依赖 SMTP 账户 From",
+    "from_name": "建议填发件显示名",
+    "biz_ref": "业务单号,便于回查/open_stats",
+    "biz_batch_no": "批次号",
+    "org_id": "组织 ID",
+    "owner_by": "归属人",
+    "create_by": "创建人",
+    "mkt_mail_status": "可不插;发送前 ensureStatus 会补状态行"
+  },
+  "status_enum": {
+    "-2": "草稿",
+    "-1": "待定时发送(SQL 插库走定时任务请用此值)",
+    "0": "发送中",
+    "1": "发送成功",
+    "2": "发送失败"
+  },
+  "fields": [
+    {
+      "name": "id",
+      "type": "bigint",
+      "nullable": false,
+      "default": "AUTO_INCREMENT",
+      "desc": "主键,勿手填"
+    },
+    {
+      "name": "account_id",
+      "type": "bigint",
+      "nullable": false,
+      "default": null,
+      "desc": "发信账户 ID → smtp_pop_settings.id(scene=MKT)"
+    },
+    {
+      "name": "org_id",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "组织 ID"
+    },
+    {
+      "name": "biz_ref",
+      "type": "varchar(64)",
+      "nullable": true,
+      "default": null,
+      "desc": "调用方业务单号"
+    },
+    {
+      "name": "biz_batch_no",
+      "type": "varchar(64)",
+      "nullable": true,
+      "default": null,
+      "desc": "批量批次号"
+    },
+    {
+      "name": "message_id",
+      "type": "varchar(255)",
+      "nullable": true,
+      "default": null,
+      "desc": "SMTP Message-ID,发送成功后系统回写,插库勿填"
+    },
+    {
+      "name": "track_token",
+      "type": "varchar(64)",
+      "nullable": true,
+      "default": null,
+      "desc": "打开追踪 token;is_track=1 时可空,发送前自动生成;唯一索引"
+    },
+    {
+      "name": "subject",
+      "type": "varchar(512)",
+      "nullable": true,
+      "default": null,
+      "desc": "主题(业务上必填)"
+    },
+    {
+      "name": "from_addr",
+      "type": "varchar(255)",
+      "nullable": true,
+      "default": null,
+      "desc": "发件人地址"
+    },
+    {
+      "name": "from_name",
+      "type": "varchar(255)",
+      "nullable": true,
+      "default": null,
+      "desc": "发件人显示名"
+    },
+    {
+      "name": "recipient",
+      "type": "varchar(512)",
+      "nullable": false,
+      "default": null,
+      "desc": "收件人邮箱"
+    },
+    {
+      "name": "recipient_cc",
+      "type": "text",
+      "nullable": true,
+      "default": null,
+      "desc": "抄送,逗号分隔"
+    },
+    {
+      "name": "recipient_bcc",
+      "type": "text",
+      "nullable": true,
+      "default": null,
+      "desc": "密送,逗号分隔"
+    },
+    {
+      "name": "content",
+      "type": "mediumtext",
+      "nullable": true,
+      "default": null,
+      "desc": "正文 HTML(业务上必填)"
+    },
+    {
+      "name": "folder",
+      "type": "varchar(32)",
+      "nullable": true,
+      "default": null,
+      "desc": "INBOX/SENT/SENTING/DRAFT;定时发信建议 SENTING"
+    },
+    {
+      "name": "msg_uid",
+      "type": "varchar(128)",
+      "nullable": true,
+      "default": null,
+      "desc": "服务器 UID,发信插库勿填"
+    },
+    {
+      "name": "recipient_date",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "收信时间,发信插库勿填"
+    },
+    {
+      "name": "sent_date",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "邮件头 SentDate,一般系统回写"
+    },
+    {
+      "name": "is_only_head",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "是否仅拉头"
+    },
+    {
+      "name": "is_read",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "是否已读(本地)"
+    },
+    {
+      "name": "in_out_mark",
+      "type": "tinyint",
+      "nullable": true,
+      "default": null,
+      "desc": "1收件 2发件;定时发信填 2"
+    },
+    {
+      "name": "email_size",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "邮件大小"
+    },
+    {
+      "name": "in_reply_to",
+      "type": "varchar(512)",
+      "nullable": true,
+      "default": null,
+      "desc": "In-Reply-To,发信插库一般为空"
+    },
+    {
+      "name": "reply_msg_id",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "回复的原邮件 ID"
+    },
+    {
+      "name": "status",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "投递状态;定时任务请显式写 -1(默认 0 是发送中,不会被定时任务扫描)"
+    },
+    {
+      "name": "fail_reason",
+      "type": "varchar(512)",
+      "nullable": true,
+      "default": null,
+      "desc": "失败原因,插库勿填"
+    },
+    {
+      "name": "is_track",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "是否打开追踪;需要像素填 1"
+    },
+    {
+      "name": "opened",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "是否已打开,插库保持 0"
+    },
+    {
+      "name": "first_open_time",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "首次打开时间"
+    },
+    {
+      "name": "open_count",
+      "type": "int",
+      "nullable": false,
+      "default": 0,
+      "desc": "打开次数"
+    },
+    {
+      "name": "sent_time",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "SMTP 成功时间,系统回写"
+    },
+    {
+      "name": "delay_send_time",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "定时发送时间;status=-1 时必填"
+    },
+    {
+      "name": "owner_by",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "数据归属人"
+    },
+    {
+      "name": "create_by",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "创建人"
+    },
+    {
+      "name": "update_by",
+      "type": "bigint",
+      "nullable": true,
+      "default": null,
+      "desc": "更新人"
+    },
+    {
+      "name": "create_time",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "创建时间,建议 NOW()"
+    },
+    {
+      "name": "update_time",
+      "type": "datetime",
+      "nullable": true,
+      "default": null,
+      "desc": "更新时间,建议 NOW()"
+    },
+    {
+      "name": "is_delete",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 0,
+      "desc": "逻辑删除;必须 0"
+    },
+    {
+      "name": "enabled",
+      "type": "tinyint",
+      "nullable": false,
+      "default": 1,
+      "desc": "是否启用"
+    },
+    {
+      "name": "sort",
+      "type": "int",
+      "nullable": true,
+      "default": null,
+      "desc": "排序"
+    }
+  ],
+  "example_insert_sql": "INSERT INTO mkt_mail_message (\n  account_id, org_id, biz_ref, biz_batch_no,\n  subject, from_addr, from_name, recipient, content,\n  folder, in_out_mark, status, delay_send_time, is_track,\n  owner_by, create_by, create_time, update_time, is_delete, enabled\n) VALUES (\n  1001, NULL, 'ACT-20260814-001', 'BATCH-01',\n  '促销活动', 'mkt@example.com', '营销号', 'user@example.com', '<html><body><p>你好</p></body></html>',\n  'SENTING', 2, -1, '2026-08-14 12:00:00', 1,\n  1, 1, NOW(), NOW(), 0, 1\n);",
+  "example_row_json": {
+    "account_id": 1001,
+    "biz_ref": "ACT-20260814-001",
+    "biz_batch_no": "BATCH-01",
+    "subject": "促销活动",
+    "from_addr": "mkt@example.com",
+    "from_name": "营销号",
+    "recipient": "user@example.com",
+    "content": "<html><body><p>你好</p></body></html>",
+    "folder": "SENTING",
+    "in_out_mark": 2,
+    "status": -1,
+    "delay_send_time": "2026-08-14 12:00:00",
+    "is_track": 1,
+    "track_token": null,
+    "opened": 0,
+    "open_count": 0,
+    "is_delete": 0,
+    "enabled": 1,
+    "owner_by": 1,
+    "create_by": 1
+  },
+  "notes": [
+    "定时任务扫描条件:is_delete=0 AND status=-1 AND delay_send_time IS NOT NULL AND delay_send_time<=NOW()",
+    "status 表默认是 0(发送中),SQL 插定时邮件必须显式写 status=-1",
+    "is_track=1 时可不写 track_token;真正 SMTP 发送前会自动生成并注入像素",
+    "像素依赖配置 storlead.mail.track-base-url(公网可达,含 context-path)",
+    "mkt_mail_status 可不插;发送链路会 ensureStatus;打开/回复/发送结果会回写该表"
+  ]
+}

+ 91 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_message_insert_guide.md

@@ -0,0 +1,91 @@
+# mkt_mail_message SQL 插入说明(定时发送)
+
+通过 SQL 直接写入营销邮件表,由定时任务扫描 `status=-1` 且 `delay_send_time <= NOW()` 的记录并 SMTP 发送。  
+若 `is_track=1`,发送前会自动补 `track_token` 并注入打开追踪像素。
+
+---
+
+## 案例
+
+```sql
+INSERT INTO mkt_mail_message (
+  account_id, subject, from_addr, from_name, recipient, content,
+  folder, in_out_mark, status, delay_send_time, is_track,
+  owner_by, create_by, create_time, update_time, is_delete, enabled
+) VALUES (
+  1001,                                    -- account_id:MKT 发信账户 ID
+  '促销活动',                               -- subject:主题
+  'mkt@example.com',                       -- from_addr:发件人邮箱
+  '营销号',                                 -- from_name:发件人显示名
+  'user@example.com',                      -- recipient:收件人
+  '<html><body><p>你好</p></body></html>', -- content:HTML 正文
+  'SENTING',                               -- folder:发件中
+  2,                                       -- in_out_mark:发件
+  -1,                                      -- status:待定时发送(必填 -1)
+  '2026-08-14 12:00:00',                   -- delay_send_time:定时发送时间
+  1,                                       -- is_track:开启打开追踪
+  1,                                       -- owner_by:归属人
+  1,                                       -- create_by:创建人
+  NOW(),                                   -- create_time
+  NOW(),                                   -- update_time
+  0,                                       -- is_delete:未删除
+  1                                        -- enabled:启用
+);
+```
+
+说明:
+
+- `account_id=1001` 需在 `smtp_pop_settings` 中存在,且 `scene=MKT`、可用。
+- 到点后任务会把 `status` 改为 `0`(发送中),成功为 `1`,失败为 `2`。
+- `track_token` 本例未写;发送时若 `is_track=1` 且无 token,系统自动生成并注入像素。
+- 需配置 `storlead.mail.track-base-url`(公网可访问,含 context-path)。
+
+---
+
+## 字段说明(本案例涉及)
+
+| 字段 | 类型 | 必填 | 本例值 | 说明 |
+|------|------|------|--------|------|
+| account_id | bigint | 是 | 1001 | 发信账户 ID,对应 `smtp_pop_settings.id`,账户须为营销场景(scene=MKT) |
+| subject | varchar(512) | 业务必填 | 促销活动 | 邮件主题 |
+| from_addr | varchar(255) | 建议填 | mkt@example.com | 发件人邮箱,一般与账户邮箱一致;SMTP 实际 From 以账户为准 |
+| from_name | varchar(255) | 建议填 | 营销号 | 发件人显示名 |
+| recipient | varchar(512) | 是 | user@example.com | 收件人邮箱;一收件人一行 |
+| content | mediumtext | 业务必填 | HTML | 邮件正文;追踪像素在真正发送时注入,不必手写进 content |
+| folder | varchar(32) | 建议填 | SENTING | 文件夹:定时/发送中用 `SENTING`;成功后可能变为 `SENT` |
+| in_out_mark | tinyint | 建议填 | 2 | 进出标记:`1`=收件,`2`=发件 |
+| status | tinyint | 是(定时) | -1 | 投递状态:`-2`草稿,`-1`待定时,`0`发送中,`1`成功,`2`失败。走定时任务必须写 `-1`(表默认是 `0`,不会被扫描) |
+| delay_send_time | datetime | 是(定时) | 2026-08-14 12:00:00 | 定时发送时间;任务条件为 `delay_send_time <= NOW()` |
+| is_track | tinyint | 追踪时必填 | 1 | 是否打开追踪:`0`否,`1`是。为 `1` 时发送前会补 token 并注入像素 |
+| owner_by | bigint | 建议填 | 1 | 数据归属人用户 ID |
+| create_by | bigint | 建议填 | 1 | 创建人用户 ID |
+| create_time | datetime | 建议填 | NOW() | 创建时间 |
+| update_time | datetime | 建议填 | NOW() | 更新时间 |
+| is_delete | tinyint | 是 | 0 | 逻辑删除:`0`未删,`1`已删。任务只处理 `0` |
+| enabled | tinyint | 建议填 | 1 | 是否启用:`1`启用,`0`禁用;默认 `1` |
+
+---
+
+## 本案例未写、但相关的字段
+
+| 字段 | 默认/处理 | 说明 |
+|------|-----------|------|
+| id | 自增 | 主键,勿手填 |
+| track_token | null → 发送时自动生成 | `is_track=1` 时可空;唯一索引 |
+| biz_ref | null | 业务单号,便于按活动回查 / open_stats |
+| biz_batch_no | null | 批次号 |
+| org_id | null | 组织 ID |
+| message_id | null | SMTP 成功后系统回写 |
+| opened / open_count | 0 | 打开态,插库保持默认 |
+| sent_time | null | 发送成功后系统回写 |
+
+---
+
+## 定时任务扫描条件(对照)
+
+```text
+is_delete = 0
+AND status = -1
+AND delay_send_time IS NOT NULL
+AND delay_send_time <= NOW()
+```

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

@@ -28,7 +28,7 @@ public class MktEmailPullTaskJob {
     @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);

+ 55 - 6
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -443,6 +443,11 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     private MimeMessage buildMimeMessage(Session session,
                                          MktMailMessageEntity entity,
                                          SmtpPopSettingsEntity account) throws Exception {
+        // 定时任务/直接插库场景:发送前补齐 track_token,并保证状态行存在
+        prepareTrackBeforeSend(entity);
+        // 开启追踪时必须能注入像素(避免只有 token、发出去却没有像素)
+        assertTrackReady(entity);
+
         MimeMessage message = new MimeMessage(session);
         String fromName = StringUtils.hasText(account.getSenderNickName())
                 ? account.getSenderNickName() : account.getEmailAddress();
@@ -459,8 +464,17 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         Multipart multipart = new MimeMultipart();
         BodyPart htmlPart = new MimeBodyPart();
         List<MimeBodyPart> imageParts = new ArrayList<>();
-        String html = entity.getContent() == null ? "" : entity.getContent();
-        html = injectTrackPixel(html, entity);
+        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());
+        }
         String updatedHtml = EmailHelper.convertImageUrlsToCidBodyPart(
                 html, imageParts, mailFileProperties.getPath().getJpeg(), multipart);
         if (updatedHtml == null) {
@@ -503,18 +517,49 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
                 .eq(MailTempAttachmentEntity::getScene, MailSceneEnum.MKT.getCode()));
     }
 
+    /**
+     * 实际 SMTP 发送前补齐追踪:支持定时任务 / 第三方直接插库。
+     * is_track=1 且无 track_token 时生成并回写库;同步 ensure 状态行。
+     */
+    private void prepareTrackBeforeSend(MktMailMessageEntity entity) {
+        if (entity == null || entity.getId() == null) {
+            return;
+        }
+        if (isTrackOn(entity) && !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());
+        }
+        try {
+            mktMailStatusService.ensureStatus(entity);
+        } catch (Exception e) {
+            log.error("mkt ensureStatus before send error, mailId={}", entity.getId(), e);
+        }
+    }
+
     private String injectTrackPixel(String html, MktMailMessageEntity entity) {
-        if (!Integer.valueOf(1).equals(entity.getIsTrack()) || !StringUtils.hasText(entity.getTrackToken())) {
+        if (!isTrackOn(entity) || !StringUtils.hasText(entity.getTrackToken())) {
             return html;
         }
         if (!StringUtils.hasText(trackBaseUrl)) {
             log.warn("storlead.mail.track-base-url 未配置,跳过打开追踪像素注入, mailId={}", entity.getId());
             return html;
         }
+        String body = html == null ? "" : html;
+        // 正文已含追踪像素则不再重复注入(直接插库可能已写过)
+        if (body.contains("/mail/mkt/track/")) {
+            return body;
+        }
         String base = trackBaseUrl.endsWith("/") ? trackBaseUrl.substring(0, trackBaseUrl.length() - 1) : trackBaseUrl;
-        String pixel = "<img src=\"" + base + "/mail/mkt/track/" + entity.getTrackToken()
+        String pixelUrl = base + "/mail/mkt/track/" + entity.getTrackToken();
+        String pixel = "<img src=\"" + pixelUrl
                 + "\" width=\"1\" height=\"1\" style=\"display:none!important;width:1px;height:1px;border:0;\" alt=\"\"/>";
-        String body = html == null ? "" : html;
+        log.info("mkt inject track pixel, mailId={}, url={}", entity.getId(), pixelUrl);
         int idx = body.toLowerCase().lastIndexOf("</body>");
         if (idx >= 0) {
             return body.substring(0, idx) + pixel + body.substring(idx);
@@ -523,7 +568,7 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     }
 
     private void assertTrackReady(MktMailMessageEntity entity) {
-        if (!Integer.valueOf(1).equals(entity.getIsTrack())) {
+        if (!isTrackOn(entity)) {
             return;
         }
         if (!StringUtils.hasText(trackBaseUrl)) {
@@ -534,6 +579,10 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
     }
 
+    private static boolean isTrackOn(MktMailMessageEntity entity) {
+        return entity != null && entity.getIsTrack() != null && entity.getIsTrack() == 1;
+    }
+
     private void bindTempAttachments(Long mailId, List<Long> fileIds, Long operatorId) {
         if (CollectionUtils.isEmpty(fileIds) || mailId == null) {
             return;