Преглед на файлове

邮件服务接口增加和调整,业务拆分

chenkq преди 3 седмици
родител
ревизия
1c30071435
променени са 25 файла, в които са добавени 1083 реда и са изтрити 1049 реда
  1. 5 0
      pom.xml
  2. 2 5
      storlead-api/pom.xml
  3. 0 483
      storlead-api/src/main/java/com/storlead/mail/common/SmtpPopSettingsApiController.java
  4. 128 0
      storlead-api/src/main/java/com/storlead/mail/crm/CrmSmtpPopSettingsApiController.java
  5. 0 442
      storlead-api/src/main/java/com/storlead/mail/crm/SmtpPopMailApiController.java
  6. 118 0
      storlead-api/src/main/java/com/storlead/mail/mkt/MktSmtpPopSettingsApiController.java
  7. 1 1
      storlead-api/src/main/resources/application-dev.yml
  8. 21 2
      storlead-mail/README.md
  9. 3 1
      storlead-mail/pom.xml
  10. 32 11
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailAccountBizService.java
  11. 28 0
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailAccountConnectService.java
  12. 171 16
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailAccountBizServiceImpl.java
  13. 106 0
      storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailAccountConnectServiceImpl.java
  14. 30 30
      storlead-mail/storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql
  15. 3 2
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/integration/config/MailIntegrationConfiguration.java
  16. 16 0
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/CrmMailAccountAppService.java
  17. 71 0
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/CrmMailAccountAppServiceImpl.java
  18. 156 0
      storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/support/MailOwnershipGuard.java
  19. 36 0
      storlead-mail/storlead-mail-dispatch/pom.xml
  20. 17 17
      storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/DelayDeleteServerEmailTaskJob.java
  21. 29 8
      storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/DelaySendEmailTaskJob.java
  22. 4 2
      storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/EmailPullHeadContentTaskJob.java
  23. 12 0
      storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/config/MailDispatchConfiguration.java
  24. 6 0
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java
  25. 88 29
      storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

+ 5 - 0
pom.xml

@@ -165,6 +165,11 @@
             <artifactId>storlead-mail-mkt</artifactId>
             <version>${revision}</version>
         </dependency>
+        <dependency>
+            <groupId>com.storlead.boot</groupId>
+            <artifactId>storlead-mail-dispatch</artifactId>
+            <version>${revision}</version>
+        </dependency>
         <!-- swagger -->
         <dependency>
             <groupId>io.springfox</groupId>

+ 2 - 5
storlead-api/pom.xml

@@ -15,13 +15,10 @@
     <name>storlead-api</name>
 
     <dependencies>
+        <!-- dispatch 依赖 crm + mkt,调度与业务一并引入 -->
         <dependency>
             <groupId>com.storlead.boot</groupId>
-            <artifactId>storlead-mail-crm</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>com.storlead.boot</groupId>
-            <artifactId>storlead-mail-mkt</artifactId>
+            <artifactId>storlead-mail-dispatch</artifactId>
         </dependency>
         <dependency>
             <groupId>com.storlead.boot</groupId>

+ 0 - 483
storlead-api/src/main/java/com/storlead/mail/common/SmtpPopSettingsApiController.java

@@ -1,483 +0,0 @@
-package com.storlead.mail.common;
-
-
-import cn.hutool.core.collection.CollectionUtil;
-import cn.hutool.core.util.StrUtil;
-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.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.storlead.framework.common.dto.page.PageDTO;
-import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
-import com.storlead.framework.util.LoginUserUtil;
-import com.storlead.framework.common.constant.CommonConstant;
-import com.storlead.framework.common.result.Result;
-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.enums.AccountScopeEnum;
-import com.storlead.sales.mail.common.enums.MailSceneEnum;
-import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
-import com.storlead.sales.mail.crm.entity.EmailsEntity;
-import com.storlead.sales.mail.crm.entity.MailboxAutoReplySetEntity;
-import com.storlead.sales.mail.crm.enums.EmailBoxEnum;
-import com.storlead.sales.mail.crm.pojo.vo.MailAcountVO;
-import com.storlead.sales.mail.crm.service.EmailsService;
-import com.storlead.sales.mail.crm.support.MailOwnershipGuard;
-import io.swagger.annotations.Api;
-import io.swagger.annotations.ApiOperation;
-import io.swagger.annotations.ApiResponse;
-import io.swagger.annotations.ApiResponses;
-import lombok.extern.log4j.Log4j2;
-import org.springframework.util.CollectionUtils;
-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 javax.mail.Authenticator;
-import javax.mail.PasswordAuthentication;
-import javax.mail.Session;
-import javax.mail.Transport;
-import java.util.*;
-
-/**
- * <p>
- * 邮箱smtp_setting 前端控制器
- * </p>
- *
- * @author chenkq
- * @since 2024-05-28
- */
-@Log4j2
-@RestController
-@RequestMapping("/smtp/pop/setting")
-@Api(tags = "020.邮件管理相关接口")
-public class SmtpPopSettingsApiController {
-
-    @Resource
-    private SmtpPopSettingsService smtpPopSettingsService;
-
-    @Resource
-    private EmailsService emailsService;
-
-    @Resource
-    private MailOwnershipGuard mailOwnershipGuard;
-
-
-    @PostMapping(value = "/pageList")
-    @ApiOperation(value = "获取我的邮箱配置信息" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-    })
-    public Result<?> pageList(PageDTO dto) {
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (Objects.isNull(currentUserId)) {
-            return Result.result(null);
-        }
-        AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
-        Page<SmtpPopSettingsEntity> page = new Page<>(dto.getPageIndex(),dto.getPageSize());
-        LambdaQueryWrapper<SmtpPopSettingsEntity> queryWrapper = new LambdaQueryWrapper();
-        queryWrapper.eq(SmtpPopSettingsEntity::getIsDelete,Integer.valueOf(0));
-        queryWrapper.eq(SmtpPopSettingsEntity::getOwnerBy,currentUserId);
-        // 个人邮箱配置页默认只看 CRM 账户
-        queryWrapper.and(w -> w.isNull(SmtpPopSettingsEntity::getScene)
-                .or().eq(SmtpPopSettingsEntity::getScene, "")
-                .or().eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.CRM.getCode()));
-        IPage<SmtpPopSettingsEntity> pageList =  smtpPopSettingsService.page(page,queryWrapper);
-        if (!CollectionUtils.isEmpty(pageList.getRecords())) {
-            pageList.getRecords().forEach(e -> {
-                e.setEmailPassword(encryptor.decrypt(e.getEmailPassword()));
-            });
-        }
-        return Result.result(pageList);
-    }
-
-    @PostMapping(value = "/setReplyContent")
-    @ApiOperation(value = "设置自动回复内容,并且开始" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = MailboxAutoReplySetEntity.class)
-    })
-    public Result<?> setReplyContent(Long id,String replyContent,String replyTitle,Integer autoReplyStatus) {
-        if (Objects.isNull(id) || Objects.isNull(autoReplyStatus)) {
-            return Result.error("参数错误");
-        }
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (mailOwnershipGuard.getOwnedSmtpPop(id, currentUserId) == null) {
-            return Result.error("无权操作");
-        }
-        if (Integer.valueOf(1).equals(autoReplyStatus)) {
-            if(StrUtil.isBlank(replyContent) || StrUtil.isBlank(replyTitle)) {
-                return Result.error("参数错误");
-            }
-        }
-        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper();
-        updateWrapper.set(SmtpPopSettingsEntity::getReplyContent,replyContent);
-        updateWrapper.set(SmtpPopSettingsEntity::getReplyTitle,replyTitle);
-        updateWrapper.set(SmtpPopSettingsEntity::getReplyOnTime,new Date());
-        updateWrapper.set(SmtpPopSettingsEntity::getIsAutoReply,autoReplyStatus);
-        updateWrapper.eq(SmtpPopSettingsEntity::getId,id);
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-        smtpPopSettingsService.update(updateWrapper);
-        return Result.ok();
-    }
-
-    @PostMapping(value = "/setAutoReplyStatus")
-    @ApiOperation(value = "设置自动回复是否开启:1:开启,0:关闭" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = MailboxAutoReplySetEntity.class)
-    })
-    public Result<?> setAutoReplyStatus(Long id,Integer autoReplyStatus) {
-        if (Objects.isNull(id) || Objects.isNull(autoReplyStatus)) {
-            return Result.error("参数错误");
-        }
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (mailOwnershipGuard.getOwnedSmtpPop(id, currentUserId) == null) {
-            return Result.error("无权操作");
-        }
-        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper();
-        updateWrapper.set(SmtpPopSettingsEntity::getIsAutoReply,autoReplyStatus);
-        updateWrapper.set(SmtpPopSettingsEntity::getReplyOnTime,new Date());
-        updateWrapper.eq(SmtpPopSettingsEntity::getId,id);
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-        smtpPopSettingsService.update(updateWrapper);
-        return Result.ok();
-    }
-
-    @PostMapping(value = "/delete")
-    @ApiOperation(value = "删除" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-    })
-    public Result<?> delete(Long id) {
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (Objects.isNull(currentUserId)) {
-            return Result.result(null);
-        }
-        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getById(id);
-        if (Objects.isNull(smtpPop) || mailOwnershipGuard.getOwnedSmtpPop(id, currentUserId) == null) {
-            return Result.error("参数错误");
-        }
-
-        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
-        updateWrapper.set(SmtpPopSettingsEntity::getIsDelete,Integer.valueOf(1));
-        updateWrapper.eq(SmtpPopSettingsEntity::getId,id);
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-        smtpPopSettingsService.update(updateWrapper);
-        return Result.ok();
-    }
-
-    @PostMapping(value = "/save")
-    @ApiOperation(value = "保存或修改" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = Result.class)
-    })
-    public Result<?> save(@RequestBody SmtpPopSettingsEntity entity) {
-
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (Objects.isNull(currentUserId)) {
-            return Result.result(null);
-        }
-        /**
-         * 保存邮箱先校验
-         */
-        if ("POP3".equals(entity.getProtocolType())) {
-            if (!testPop3(entity)) {
-                MailProperties properties = new MailProperties(entity);
-                MailConnection mailConnection = MailConnectionUtil.receiveEmailsConnection(properties);
-                if (Objects.isNull(mailConnection)) {
-                    return Result.error("邮箱校验失败,请输入正确的账号密码");
-                }
-                mailConnection.close();
-            }
-        }else if("IMAP".equals(entity.getProtocolType())) {
-            MailProperties properties = new MailProperties(entity);
-            MailConnection mailConnection = MailConnectionUtil.receiveEmailsConnection(properties);
-            if (!testImap(entity)) {
-                return Result.error("邮箱校验失败,请输入正确的账号密码");
-            }
-            mailConnection.close();
-        } else {
-            return Result.error("邮箱校验失败,请输入正确的账号密码");
-        }
-        if (!testSmtp(entity)) {
-            return Result.error("邮箱校验失败,请输入正确的账号密码");
-        }
-        /**
-         * 修改和增加根据邮箱进行查重
-         */
-        AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
-        entity.setEmailPassword(encryptor.encrypt(entity.getEmailPassword()));
-        if (!Integer.valueOf(1).equals(entity.getUseDefault())) {
-            LambdaQueryWrapper<SmtpPopSettingsEntity> queryWrapper = new LambdaQueryWrapper();
-            queryWrapper.eq(SmtpPopSettingsEntity::getUseDefault, Integer.valueOf(1));
-            queryWrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-            queryWrapper.eq(SmtpPopSettingsEntity::getIsDelete, Integer.valueOf(0));
-            queryWrapper.last("limit 1");
-            SmtpPopSettingsEntity c = smtpPopSettingsService.getOne(queryWrapper);
-            if (Objects.isNull(c)) {
-                return Result.error("请设置默认邮箱");
-            }
-            if (Objects.nonNull(c) && c.getId().equals(entity.getId())) {
-                return Result.error("请设置默认邮箱");
-            }
-        }
-
-        if (Objects.nonNull(entity.getId())) {
-            // 修改
-            SmtpPopSettingsEntity old = mailOwnershipGuard.getOwnedSmtpPop(entity.getId(), currentUserId);
-            if (Objects.isNull(old)) {
-                return Result.error("无权操作");
-            }
-            if(!old.getEmailAddress().equals(entity.getEmailAddress())){
-                return Result.error("已绑定的邮箱无法修改,可新增邮箱配置!");
-            }
-        }
-        entity.setOwnerBy(currentUserId);
-        if (!StringUtils.hasText(entity.getScene())) {
-            entity.setScene(MailSceneEnum.CRM.getCode());
-        }
-        if (!StringUtils.hasText(entity.getAccountScope())) {
-            entity.setAccountScope(AccountScopeEnum.PERSONAL.getCode());
-        }
-        if (entity.getEnableReceive() == null) {
-            entity.setEnableReceive(MailSceneEnum.isMkt(entity.getScene()) ? 0 : 1);
-        }
-        Boolean b = smtpPopSettingsService.saveOrUpdate(entity);
-        if (Integer.valueOf(1).equals(entity.getUseDefault()) && b) {
-            // 同场景内取消其他默认账户
-            LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper();
-            updateWrapper.set(SmtpPopSettingsEntity::getUseDefault,Integer.valueOf(0));
-            updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy,currentUserId);
-            updateWrapper.eq(SmtpPopSettingsEntity::getScene, entity.getScene());
-            updateWrapper.ne(SmtpPopSettingsEntity::getId,entity.getId());
-            smtpPopSettingsService.update(updateWrapper);
-        }
-
-        return Result.ok();
-    }
-
-    @PostMapping(value = "/defaultUse")
-    @ApiOperation(value = "设置默认邮箱" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = Result.class)
-    })
-    public Result<?> defaultUse(Long id) {
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (Objects.isNull(currentUserId) || Objects.isNull(id)) {
-            return Result.result(null);
-        }
-        SmtpPopSettingsEntity target = mailOwnershipGuard.getOwnedSmtpPop(id, currentUserId);
-        if (target == null) {
-            return Result.error("无权操作");
-        }
-        String scene = StringUtils.hasText(target.getScene()) ? target.getScene() : MailSceneEnum.CRM.getCode();
-
-        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper();
-        updateWrapper.set(SmtpPopSettingsEntity::getUseDefault,Integer.valueOf(0));
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy,currentUserId);
-        updateWrapper.eq(SmtpPopSettingsEntity::getScene, scene);
-        smtpPopSettingsService.update(updateWrapper);
-
-        updateWrapper = new LambdaUpdateWrapper();
-        updateWrapper.eq(SmtpPopSettingsEntity::getId,id);
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy,currentUserId);
-        updateWrapper.set(SmtpPopSettingsEntity::getUseDefault,Integer.valueOf(1));
-        smtpPopSettingsService.update(updateWrapper);
-        return Result.ok();
-    }
-
-    @PostMapping(value = "/mkt_account_list")
-    @ApiOperation(value = "获取营销邮箱账户列表")
-    public Result<?> mktAccountList(@RequestParam(required = false) Long orgId) {
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (Objects.isNull(currentUserId)) {
-            return Result.result(null);
-        }
-        LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(SmtpPopSettingsEntity::getIsDelete, 0)
-                .eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.MKT.getCode());
-        if (orgId != null) {
-            wrapper.eq(SmtpPopSettingsEntity::getOrgId, orgId);
-        } else {
-            wrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-        }
-        return Result.result(smtpPopSettingsService.list(wrapper));
-    }
-
-
-    @PostMapping(value = "/isEnabledDelete")
-    @ApiOperation(value = "是否开启删除服务器邮件;0 不开启,1:开启" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-    })
-    public Result<?> setReplyContent(Long id,Integer isEnabled) {
-        if (Objects.isNull(id) || Objects.isNull(isEnabled)) {
-            return Result.error("参数错误");
-        }
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        if (mailOwnershipGuard.getOwnedSmtpPop(id, currentUserId) == null) {
-            return Result.error("无权操作");
-        }
-        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper();
-        updateWrapper.set(SmtpPopSettingsEntity::getIsDeleteMail,isEnabled);
-        updateWrapper.eq(SmtpPopSettingsEntity::getId,id);
-        updateWrapper.eq(SmtpPopSettingsEntity::getOwnerBy, currentUserId);
-        smtpPopSettingsService.update(updateWrapper);
-        return Result.ok();
-    }
-
-
-    @PostMapping(value = "/personal_mail_account")
-    @ApiOperation(value = "获取个人邮箱账户" )
-    public Result personalMailAccount() {
-
-        Long currentUserId = LoginUserUtil.getCurrentUserId();
-        List<SmtpPopSettingsEntity> emails =  smtpPopSettingsService.getEmailAccount(currentUserId);
-        QueryWrapper<EmailsEntity> queryWrapper = new QueryWrapper<EmailsEntity>();
-        queryWrapper.select("smtp_pop_id", "COUNT(*) AS email_count");
-        queryWrapper.eq("owner_by", currentUserId);
-        queryWrapper.eq("folder", EmailBoxEnum.INBOX.code);
-        queryWrapper.eq("is_trash", Integer.valueOf(0));
-        queryWrapper.eq("is_read", Integer.valueOf(0));
-        queryWrapper.eq("is_delete", Integer.valueOf(0));
-        queryWrapper.isNotNull("smtp_pop_id");
-        queryWrapper.ne("`from`", "system@storlead.com");
-        queryWrapper.groupBy("smtp_pop_id");
-        List<Map<String,Object>> readMapls = emailsService.listMaps(queryWrapper);
-        Map<Long, Integer> readMap = new HashMap<>();
-        if (!CollectionUtils.isEmpty(readMapls)) {
-            for (Map<String, Object> map : readMapls) {
-                Long key = Long.valueOf(map.get("smtp_pop_id").toString());
-                Integer val = Integer.valueOf(map.get("email_count").toString());
-                readMap.put(key, val);
-            }
-        }
-
-        List<MailAcountVO> mails = new ArrayList<>();
-        if (CollectionUtil.isNotEmpty(emails)) {
-            emails.forEach(e -> {
-                MailAcountVO mailvo = new MailAcountVO();
-                mailvo.setMailAccount(e.getEmailAddress());
-                mailvo.setId(e.getId());
-                mailvo.setUseDefault(e.getUseDefault());
-                Integer readNum = readMap.get(e.getId());
-                if (Objects.nonNull(readNum)) {
-                    mailvo.setUnReadCount(readNum);
-                } else {
-                    mailvo.setUnReadCount(0);
-                }
-                mails.add(mailvo);
-            });
-        }
-        return Result.result(mails);
-    }
-
-    @PostMapping(value = "/test_connect")
-    @ApiOperation(value = "测试" )
-    @ApiResponses({
-            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-    })
-    public Result<?> receiveMail(@RequestBody SmtpPopSettingsEntity mailSet) {
-
-        if (!testSmtp(mailSet)) {
-            return Result.error("邮箱校验失败,请输入正确的账号密码");
-        }
-        if ("POP3".equals(mailSet.getProtocolType())) {
-            if (!testPop3(mailSet)) {
-                return Result.error("邮箱校验失败,请输入正确的账号密码");
-            }
-        }else if("IMAP".equals(mailSet.getProtocolType())) {
-            if (!testImap(mailSet)) {
-                return Result.error("邮箱校验失败,请输入正确的账号密码");
-            }
-        } else {
-            return Result.error("邮箱校验失败,请输入正确的账号密码");
-        }
-        return Result.ok();
-    }
-
-
-
-    public Boolean testImap(SmtpPopSettingsEntity mailSet){
-        try {
-            MailProperties properties = new MailProperties(mailSet);
-            MailConnection mailConnection = MailConnectionUtil.testReceiveEmailsConnection(properties);
-            if (Objects.isNull(mailConnection)) {
-                return false;
-            }
-            log.error("mailConnection -- testImap == "+mailConnection);
-//            String host = mailSet.getReceiveServer();
-//            String port = mailSet.getReceivePort();
-//            String username = mailSet.getEmailAddress();
-//            String password = mailSet.getEmailPassword();;
-//
-//            Properties properties = new Properties();
-//            properties.put("mail.imap.host", host);
-//            properties.put("mail.imap.port", port);
-//            properties.put("mail.imap.ssl.enable",Integer.valueOf(1).equals(mailSet.getReceiveSsl()) ? "true" : "false");
-//            Session session = Session.getDefaultInstance(properties);
-//            Store store = session.getStore("imap");
-//            store.connect(username, password);
-//            store.close();
-//            session = null;
-            mailConnection.close();
-
-            return true;
-        } catch (Exception e) {
-            log.error("testImap - error",e);
-            return false;
-        }
-    }
-
-    public Boolean testPop3(SmtpPopSettingsEntity mailSet){
-        try {
-            MailProperties properties = new MailProperties(mailSet);
-            MailConnection mailConnection = MailConnectionUtil.testReceiveEmailsConnection(properties);
-            if (Objects.isNull(mailConnection)) {
-                return false;
-            }
-            log.error("mailConnection -- testPop3 == "+mailConnection);
-            mailConnection.close();
-
-            return true;
-        } catch (Exception e) {
-            log.error("testPop3 - error",e);
-            return false;
-        }
-    }
-
-    public Boolean testSmtp(SmtpPopSettingsEntity smtpPop){
-        try {
-            Properties properties = new Properties();
-            properties.put("mail.smtp.host", smtpPop.getSmtpServer());
-            properties.put("mail.smtp.port", smtpPop.getSmtpPort());
-            properties.put("mail.smtp.auth", "true");
-            properties.put("mail.user", smtpPop.getEmailAddress());
-            properties.put("mail.smtp.starttls.enable",Integer.valueOf(1).equals(smtpPop.getSmtpTls()) ? "true" : "false");
-            properties.put("mail.smtp.ssl.enable", Integer.valueOf(1).equals(smtpPop.getSmtpSsl()) ? "true" : "false");
-
-            String username = smtpPop.getEmailAddress();
-            String password = smtpPop.getEmailPassword();
-
-            Session session = Session.getInstance(properties, new Authenticator() {
-                protected PasswordAuthentication getPasswordAuthentication() {
-                    return new PasswordAuthentication(username, password);
-                }
-            });
-            Transport transport = session.getTransport("smtp");
-            transport.connect();
-            transport.close();
-            return true;
-        } catch (Exception e) {
-            log.error("testSmtp - error",e);
-            return false;
-        }
-    }
-
-}

+ 128 - 0
storlead-api/src/main/java/com/storlead/mail/crm/CrmSmtpPopSettingsApiController.java

@@ -0,0 +1,128 @@
+package com.storlead.mail.crm;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.dto.page.PageDTO;
+import com.storlead.framework.common.result.Result;
+import com.storlead.framework.util.LoginUserUtil;
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.enums.MailSceneEnum;
+import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.common.service.MailAccountConnectService;
+import com.storlead.sales.mail.crm.service.CrmMailAccountAppService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.log4j.Log4j2;
+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.RestController;
+
+import javax.annotation.Resource;
+import java.util.Objects;
+
+/**
+ * CRM 个人邮箱账户接口(路径保持兼容:/smtp/pop/setting)。
+ */
+@Log4j2
+@RestController
+@RequestMapping("/smtp/pop/setting")
+@Api(tags = "020.CRM邮箱账户")
+public class CrmSmtpPopSettingsApiController {
+
+    @Resource
+    private MailAccountBizService mailAccountBizService;
+
+    @Resource
+    private MailAccountConnectService mailAccountConnectService;
+
+    @Resource
+    private CrmMailAccountAppService crmMailAccountAppService;
+
+    @PostMapping("/pageList")
+    @ApiOperation("CRM邮箱配置分页")
+    public Result<?> pageList(PageDTO dto) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        IPage<SmtpPopSettingsEntity> page = mailAccountBizService.pageAccounts(userId, MailSceneEnum.CRM.getCode(), dto);
+        return Result.result(page);
+    }
+
+    @PostMapping("/save")
+    @ApiOperation("保存/修改CRM邮箱")
+    public Result<?> save(@RequestBody SmtpPopSettingsEntity entity) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        entity.setScene(MailSceneEnum.CRM.getCode());
+        mailAccountBizService.saveAccount(entity, userId, MailSceneEnum.CRM);
+        return Result.ok();
+    }
+
+    @PostMapping("/delete")
+    @ApiOperation("删除CRM邮箱")
+    public Result<?> delete(Long id) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        mailAccountBizService.deleteAccount(id, userId, MailSceneEnum.CRM);
+        return Result.ok();
+    }
+
+    @PostMapping("/defaultUse")
+    @ApiOperation("设置默认CRM邮箱")
+    public Result<?> defaultUse(Long id) {
+        Long userId = requireLogin();
+        if (userId == null || id == null) {
+            return Result.result(null);
+        }
+        mailAccountBizService.setDefaultAccount(id, userId, MailSceneEnum.CRM);
+        return Result.ok();
+    }
+
+    @PostMapping("/setReplyContent")
+    @ApiOperation("设置自动回复内容并开启")
+    public Result<?> setReplyContent(Long id, String replyContent, String replyTitle, Integer autoReplyStatus) {
+        Long userId = requireLogin();
+        mailAccountBizService.setAutoReply(id, userId, autoReplyStatus, replyTitle, replyContent);
+        return Result.ok();
+    }
+
+    @PostMapping("/setAutoReplyStatus")
+    @ApiOperation("开关自动回复")
+    public Result<?> setAutoReplyStatus(Long id, Integer autoReplyStatus) {
+        Long userId = requireLogin();
+        mailAccountBizService.setAutoReply(id, userId, autoReplyStatus, null, null);
+        return Result.ok();
+    }
+
+    @PostMapping("/isEnabledDelete")
+    @ApiOperation("是否开启删除服务器邮件")
+    public Result<?> setDeleteMailEnabled(Long id, Integer isEnabled) {
+        Long userId = requireLogin();
+        mailAccountBizService.setDeleteMailEnabled(id, userId, isEnabled);
+        return Result.ok();
+    }
+
+    @PostMapping("/personal_mail_account")
+    @ApiOperation("个人邮箱账户(含未读数)")
+    public Result<?> personalMailAccount() {
+        Long userId = requireLogin();
+        return Result.result(crmMailAccountAppService.listPersonalAccountsWithUnread(userId));
+    }
+
+    @PostMapping("/test_connect")
+    @ApiOperation("测试邮箱连通性")
+    public Result<?> testConnect(@RequestBody SmtpPopSettingsEntity mailSet) {
+        mailAccountConnectService.validateConnect(mailSet);
+        return Result.ok();
+    }
+
+    private Long requireLogin() {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        return Objects.isNull(userId) ? null : userId;
+    }
+}

+ 0 - 442
storlead-api/src/main/java/com/storlead/mail/crm/SmtpPopMailApiController.java

@@ -1,442 +0,0 @@
-package com.storlead.mail.crm;//package com.storlead.sales.mail;
-//
-//import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-//import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-//import com.baomidou.mybatisplus.core.metadata.IPage;
-//import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-//import com.storlead.common.util.DateUtils;
-//import com.storlead.common.util.encryptor.AccessKeyEncryptor;
-//import com.storlead.frame.auth.util.LoginUserUtil;
-//import com.storlead.frame.core.assemble.Result;
-//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.crm.entity.EmailsEntity;
-//import com.storlead.sales.mail.crm.entity.MailAttachmentEntity;
-//import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
-//import com.storlead.sales.mail.crm.pojo.MailDTO;
-//import com.storlead.sales.mail.crm.pojo.SendMailDTO;
-//import com.storlead.sales.mail.common.properties.MailFileProperties;
-//import com.storlead.sales.mail.crm.service.EmailsService;
-//import com.storlead.sales.mail.crm.service.MailAttachmentService;
-//import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
-//import com.sun.mail.imap.IMAPFolder;
-//import io.swagger.annotations.Api;
-//import io.swagger.annotations.ApiOperation;
-//import io.swagger.annotations.ApiResponse;
-//import io.swagger.annotations.ApiResponses;
-//import lombok.extern.log4j.Log4j2;
-////import org.springframework.mail.javamail.JavaMailSender;
-//import org.springframework.mail.javamail.MimeMessageHelper;
-//import org.springframework.util.CollectionUtils;
-//import org.springframework.web.bind.annotation.*;
-//import org.springframework.web.multipart.MultipartFile;
-//
-//import javax.annotation.Resource;
-//import javax.mail.*;
-//import javax.mail.internet.InternetAddress;
-//import javax.mail.internet.MimeMessage;
-//import javax.mail.internet.MimeUtility;
-//import javax.mail.search.ComparisonTerm;
-//import javax.mail.search.ReceivedDateTerm;
-//import javax.mail.search.SearchTerm;
-//import java.io.File;
-//import java.io.FileOutputStream;
-//import java.io.IOException;
-//import java.text.SimpleDateFormat;
-//import java.util.*;
-//import java.util.stream.Collectors;
-//
-///**
-// * @program: sp-sales-platform
-// * @description:
-// * @author: chenkq
-// * @create: 2024-05-29 16:02
-// */
-//@RestController
-//@RequestMapping("/mail")
-//@Api(tags = "020.邮件管理相关接口")
-//public class SmtpPopMailApiController {
-//
-////    @Resource
-////    private JavaMailSender mailSender;
-//
-//    @Resource
-//    private EmailsService emailsService;
-//
-//    @Resource
-//    private MailFileProperties mailFileProperties;
-//
-//    @Resource
-//    private MailAttachmentService mailAttachmentService;
-//
-//    @Resource
-//    private SmtpPopSettingsService smtpPopSettingsService;
-//
-//
-//    @PostMapping(value = "/pageList")
-//    @ApiOperation(value = "获取邮件" )
-//    @ApiResponses({
-//            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-//    })
-//    public Result<?> pageList(com.storlead.common.object.Page dto) {
-//        Long currentUserId = LoginUserUtil.getCurrentUserId();
-//        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getDefaultSmtpPop(currentUserId);
-//
-//        if (Objects.isNull(currentUserId) && Objects.nonNull(smtpPop)) {
-//            return Result.result(null);
-//        }
-//        Page<EmailsEntity> page = new Page<>(dto.getPageIndex(),dto.getPageSize());
-//        LambdaQueryWrapper<EmailsEntity> queryWrapper = new LambdaQueryWrapper();
-//        queryWrapper.eq(EmailsEntity::getOwnerBy,currentUserId);
-//        queryWrapper.eq(EmailsEntity::getFrom,smtpPop.getEmailAddress());
-//        IPage<EmailsEntity> pageList = emailsService.page(page,queryWrapper);
-//        return Result.result(pageList);
-//    }
-//
-//    @PostMapping(value = "/sendMail")
-//    @ApiOperation(value = "发送邮件" )
-//    @ApiResponses({
-//            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-//    })
-//    public Result<?> sendMail(SendMailDTO dto) {
-//        Long currentUserId = LoginUserUtil.getCurrentUserId();
-//        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getDefaultSmtpPop(currentUserId);
-//        AccessKeyEncryptor accessKeyEncryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
-//        String pass = accessKeyEncryptor.decrypt(smtpPop.getEmailPassword());
-//        smtpPop.setEmailPassword(pass);
-//        if (Objects.isNull(currentUserId) && Objects.nonNull(smtpPop)) {
-//            return Result.result(null);
-//        }
-//        try {
-//           Session session = getSendEmailsSession(smtpPop);
-//            try {
-//
-//                InternetAddress [] froms = new InternetAddress[dto.getRecipientls().size()];
-//                for (int i = 0; i < dto.getRecipientls().size(); i++) {
-//                    froms[i] = new InternetAddress(dto.getRecipientls().get(i));
-//                }
-//                if (CollectionUtils.isEmpty(dto.getFiles())) {
-//                    Message message = new MimeMessage(session);
-//                    message.setFrom(new InternetAddress(smtpPop.getEmailAddress()));
-//                    message.setRecipients(Message.RecipientType.TO, froms);
-//                    message.setSubject(dto.getSubject());
-//                    message.setText(dto.getContent());
-//                    Transport.send(message);
-//                } else {
-////                    MimeMessage message = mailSender.createMimeMessage();
-////                    MimeMessageHelper helper = new MimeMessageHelper(message, true);
-////                    helper.setFrom(new InternetAddress(smtpPop.getEmailAddress()));
-////                    helper.setTo(froms);
-////                    helper.setSubject(dto.getSubject());
-////                    helper.setText(dto.getContent());
-////                    List<MultipartFile> files = dto.getFiles();
-////                    for (MultipartFile multipartFile : files) {
-////                        File file = multipartFile.getResource().getFile();
-////                        helper.addAttachment(file.getName(), file);
-////                    }
-//                }
-//            }catch (Exception e) {
-//                log.error("getSendEmailsSession -error",e);
-//            }
-//            return Result.ok();
-//        } catch (Exception e) {
-//            e.printStackTrace();
-//            log.error("send sendMail -error",e);
-//        }
-//        return Result.ok();
-//    }
-//
-//
-//    @PostMapping(value = "/receiveMail")
-//    @ApiOperation(value = "接收邮件" )
-//    @ApiResponses({
-//            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-//    })
-//    public Result<?> receiveMail(MailDTO dto) {
-//        Long currentUserId = LoginUserUtil.getCurrentUserId();
-//        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getDefaultSmtpPop(currentUserId);
-//        if (Objects.isNull(currentUserId) && Objects.nonNull(smtpPop)) {
-//            return Result.error("请先设置邮箱账号!");
-//        }
-//
-//        AccessKeyEncryptor accessKeyEncryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
-//        String pass = accessKeyEncryptor.decrypt(smtpPop.getEmailPassword());
-//        smtpPop.setEmailPassword(pass);
-//        receiveEmails(smtpPop, "INBOX");
-////        receiveEmails(smtpPop, "Sent Items");
-////        receiveEmails(smtpPop, "Drafts");
-////        receiveEmails(smtpPop, "Trash");
-//        return Result.ok();
-//    }
-//
-//
-//    @PostMapping(value = "/receiveMail1")
-//    @ApiOperation(value = "接收邮件" )
-//    @ApiResponses({
-//            @ApiResponse(code = 200, message = "", response = SmtpPopSettingsEntity.class)
-//    })
-//    public Result<?> receiveMail1(MailDTO dto) {
-//        Long currentUserId = LoginUserUtil.getCurrentUserId();
-//        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getDefaultSmtpPop(currentUserId);
-//        if (Objects.isNull(currentUserId) && Objects.nonNull(smtpPop)) {
-//            return Result.error("请先设置邮箱账号!");
-//        }
-//        return Result.ok();
-//    }
-//
-//    private void receiveEmails(SmtpPopSettingsEntity smtpPop, String folderName) {
-//        MailProperties properties = new MailProperties(smtpPop);
-//        MailConnection mailConnection = MailConnectionUtil.receiveEmailsConnection(properties);
-//        if (Objects.isNull(mailConnection)) {
-//            return;
-//        }
-//        List<EmailsEntity> entities = new ArrayList<>();
-//
-//        QueryWrapper queryWrapper = new QueryWrapper<>();
-//        queryWrapper.eq("smtp_pop_id",smtpPop.getId());
-//        queryWrapper.eq("folder",folderName);
-//        queryWrapper.select("max(recipient_date) as recipientDate");
-//        Map<String, Date> qv = emailsService.getMap(queryWrapper);
-//
-//        QueryWrapper queryMsgIdWp = new QueryWrapper<>();
-//        queryMsgIdWp.select("msg_uid");
-//        queryMsgIdWp.eq("folder",folderName);
-//        queryMsgIdWp.eq("smtp_pop_id",smtpPop.getId());
-//        queryMsgIdWp.last("order by msg_uid desc limit 100");
-//
-//        List<String> dis = emailsService.listObjs(queryMsgIdWp);
-//        List<Long> msgUids = new ArrayList<>();
-//        if (!CollectionUtils.isEmpty(dis)) {
-//            msgUids = dis.stream().map(Long::valueOf).collect(Collectors.toList());
-//        }
-//        try {
-//            Store store = mailConnection.getStore();
-//            Folder inbox = store.getFolder(folderName);
-//            inbox.open(Folder.READ_WRITE);
-//            Date beginDate = null;
-//            if (!CollectionUtils.isEmpty(qv) && Objects.nonNull(qv.get("recipientDate"))) {
-//                beginDate = qv.get("recipientDate");
-//            }
-//            if (beginDate == null) {
-//                SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-//                String dateStr = "2024-03-28"; // 替换为你需要的日期
-//                beginDate = dateFormat.parse(dateStr);
-//            }
-//
-//            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-//            Date date = dateFormat.parse("2024-05-28");
-//
-//            SearchTerm searchTerm = new ReceivedDateTerm(ComparisonTerm.GT, date);
-//            Message[] messages = inbox.search(searchTerm);
-//            for (Message message : messages) {
-//                Long mesId = getMessageId(message,inbox);
-//                if (!CollectionUtils.isEmpty(msgUids) && msgUids.contains(mesId)) {
-//                    continue;
-//                }
-//                msgUids.add(mesId);
-//                EmailsEntity entity = convertMessageToEmailVo(message,smtpPop);
-//                entity.setSmtpPopId(smtpPop.getId());
-//                entity.setMsgUid(mesId);
-//                entity.setFolder(folderName);
-//                entities.add(entity);
-//                emailsService.save(entity);
-//
-//                String downloadDir = mailFileProperties.getPath().getPath()+File.separator+smtpPop.getEmailAddress()+File.separator;
-//                downloadAttachmentFromMessage(message,downloadDir,entity.getId());
-//            }
-//            inbox.close(false);
-//            mailConnection.close();
-//        }catch (Exception e) {
-//            log.error("receiveEmails - error =",e);
-//            mailConnection.close();
-//        }
-//
-//    }
-//
-//    private Session getSendEmailsSession(SmtpPopSettingsEntity smtpPop) {
-//
-//        Properties properties = new Properties();
-//        properties.put("mail.smtp.host", smtpPop.getSmtpServer());
-//        properties.put("mail.smtp.port", smtpPop.getSmtpPort());
-//        properties.put("mail.smtp.auth", "true");
-//        properties.put("mail.user", smtpPop.getEmailAddress());
-//        properties.put("mail.smtp.starttls.enable",smtpPop.getUseTls());
-//        properties.put("mail.smtp.ssl.enable", smtpPop.getUseSsl());
-//
-//        Session session = Session.getInstance(properties, new Authenticator() {
-//            protected PasswordAuthentication getPasswordAuthentication() {
-//                return new PasswordAuthentication(smtpPop.getEmailAddress(), smtpPop.getEmailPassword());
-//            }
-//        });
-//        return session;
-//    }
-//
-//        private static Long getMessageId(Message message, Folder folder) throws MessagingException {
-//        if (folder instanceof IMAPFolder) {
-//            IMAPFolder imapFolder = (IMAPFolder) folder;
-//            return  imapFolder.getUID(message);
-//        } else {
-//            return null;
-//        }
-//    }
-//
-//
-//    private EmailsEntity convertMessageToEmailVo(Message message,SmtpPopSettingsEntity smtpPop) throws MessagingException, IOException {
-//
-//        EmailsEntity entity = new EmailsEntity();
-//        String subject =  MimeUtility.decodeText(message.getSubject());
-//        entity.setSubject(subject);
-//
-//        Map<String,String>  fromMap = decodeAndPrintAddresses(message.getFrom());
-//        entity.setFrom(mapKeyToString(fromMap));
-//        entity.setFromName(mapValueToString(fromMap));
-//
-//        Map<String,String>  toMap = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.TO));
-//        entity.setRecipient(mapKeyToString(toMap));
-//        entity.setRecipientName(mapValueToString(toMap));
-//
-//        if (Objects.nonNull(message.getSentDate())) {
-//            entity.setSentDate(message.getSentDate());
-//        }
-//
-//        if (Objects.nonNull(message.getReceivedDate())) {
-//            entity.setRecipientDate(message.getReceivedDate());
-//        }
-//        boolean isSeen = message.isSet(Flags.Flag.SEEN);
-//        entity.setIsRead(isSeen);
-//
-////      Map<String,String>  ccMap = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.CC));
-////      Map<String,String>  bccMap = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.BCC));
-//        Object content = message.getContent();
-//        if (content instanceof String) {
-//            entity.setContent(message.getContent().toString());
-//        } else if (content instanceof Multipart) {
-//            Multipart multipart = (Multipart) content;
-//            String str = parseMultipart(multipart);
-//            entity.setContent(str);
-//        }
-//        return entity;
-//    }
-//
-//    private String parseMultipart(Multipart multipart) {
-//        try {
-//            StringBuilder stringBuilder = new StringBuilder();
-//            for (int i = 0; i < multipart.getCount(); i++) {
-//                BodyPart bodyPart = multipart.getBodyPart(i);
-//                if (bodyPart.isMimeType("text/plain")) {
-//                    stringBuilder.append(bodyPart.getContent());
-//                } else if (bodyPart.isMimeType("text/html")) {
-//                    stringBuilder.append(bodyPart.getContent());
-//                } else if (bodyPart.isMimeType("multipart/alternative")) {
-//                    parseMultipart((Multipart) bodyPart.getContent());
-//                } else if (bodyPart.isMimeType("multipart/*")) {
-//                    parseMultipart((Multipart) bodyPart.getContent());
-//                }
-//            }
-//            return stringBuilder.toString();
-//        }catch (Exception e) {
-//            return "";
-//        }
-//    }
-//
-//    private void downloadAttachmentFromMessage(Message message, String downloadDir, long emailId) {
-//        try {
-//            Object content = message.getContent();
-//            if (content instanceof Multipart) {
-//                Multipart multipart = (Multipart) content;
-//                for (int i = 0; i < multipart.getCount(); i++) {
-//                    BodyPart bodyPart = multipart.getBodyPart(i);
-//                    if (Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) {
-//                        saveAttachment(bodyPart, downloadDir, emailId);
-//                    }
-//                }
-//            }
-//        }catch (Exception e) {
-//            log.error("downloadAttachmentFromMessage -- error = ",e);
-//        }
-//    }
-//
-//    private void saveAttachment(BodyPart bodyPart,  String downloadDir, long emailId) throws MessagingException, IOException {
-//        String fileName = bodyPart.getFileName();
-//        fileName = MimeUtility.decodeText(fileName);
-//        String path = DateUtils.date2Str(new Date(), DateUtils.date_yyyy_mm);
-//        // 如果不存在这创建路径
-//        downloadDir = downloadDir + File.separator + path;
-//        if (!new File(downloadDir).exists()) {
-//            new File(downloadDir).mkdirs();
-//        }
-//        File file = new File(downloadDir +  File.separator +fileName);
-//        try (FileOutputStream output = new FileOutputStream(file)) {
-//            bodyPart.getInputStream().transferTo(output);
-//        }
-//        System.out.println("Saved attachment: " + file.getAbsolutePath());
-//        saveAttachmentToDatabase(file, emailId);
-//    }
-//
-//    private void saveAttachmentToDatabase(File file, long emailId) {
-//
-//        String fileName = file.getName();
-//        MailAttachmentEntity attachment = new MailAttachmentEntity();
-//        attachment.setEmailId(emailId);
-//        attachment.setFileName(fileName);
-//        attachment.setFilePath(file.getAbsolutePath());
-//        attachment.setFilePath(file.getAbsolutePath());
-//
-//        String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1);
-//        attachment.setFileExt(fileExtension);
-//        attachment.setFileSize(file.length());
-//        mailAttachmentService.save(attachment);
-//    }
-//
-//    private static String formatDate(Date date) {
-//        if (date == null) return "N/A";
-//        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-//        return sdf.format(date);
-//    }
-//
-//    private static String decodeAddresses(Address [] addresses) throws MessagingException {
-//        try {
-//            if (addresses == null) return "[]";
-//            String[] decodedAddresses = new String[addresses.length];
-//            for (int i = 0; i < addresses.length; i++) {
-//                decodedAddresses[i] = MimeUtility.decodeText(addresses[i].toString());
-//            }
-//            return Arrays.toString(decodedAddresses);
-//        }catch (Exception e) {
-//            return "";
-//        }
-//    }
-//
-//    private static Map<String,String> decodeAndPrintAddresses(Address[] addresses) throws MessagingException, IOException {
-//
-//        Map<String,String> map = new HashMap<>();
-//        if (addresses == null) return new HashMap<>();
-//        StringBuilder decodedAddresses = new StringBuilder();
-//        for (Address address : addresses) {
-//            InternetAddress internetAddress = (InternetAddress) address;
-//            String personal = internetAddress.getPersonal();
-//            String email = internetAddress.getAddress();
-//            if (personal != null) {
-//                personal = MimeUtility.decodeText(personal);
-//            }
-//            map.put(email,(personal != null ? personal : "N/A"));
-////            decodedAddresses.append("[Name: ").append(personal != null ? personal : "N/A").append(", Email: ").append(email).append("] ");
-//        }
-//        return map;
-//    }
-//
-//    private static String mapKeyToString(Map<String,String> map) {
-//        if (map == null) return "";
-//        return map.entrySet().stream()
-//                .map(entry -> entry.getKey())
-//                .collect(Collectors.joining(","));
-//    }
-//
-//    private static String mapValueToString(Map<String,String> map) {
-//        if (map == null) return "";
-//        return map.entrySet().stream()
-//                .map(entry -> entry.getValue())
-//                .collect(Collectors.joining(","));
-//    }
-//}

+ 118 - 0
storlead-api/src/main/java/com/storlead/mail/mkt/MktSmtpPopSettingsApiController.java

@@ -0,0 +1,118 @@
+package com.storlead.mail.mkt;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.dto.page.PageDTO;
+import com.storlead.framework.common.result.Result;
+import com.storlead.framework.util.LoginUserUtil;
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.enums.MailSceneEnum;
+import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.common.service.MailAccountConnectService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.log4j.Log4j2;
+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.List;
+import java.util.Objects;
+
+/**
+ * 营销邮箱账户接口。
+ */
+@Log4j2
+@RestController
+@RequestMapping("/mail/mkt/account")
+@Api(tags = "021.营销邮箱账户")
+public class MktSmtpPopSettingsApiController {
+
+    @Resource
+    private MailAccountBizService mailAccountBizService;
+
+    @Resource
+    private MailAccountConnectService mailAccountConnectService;
+
+    @PostMapping("/page_list")
+    @ApiOperation("营销邮箱配置分页")
+    public Result<?> pageList(PageDTO dto) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        IPage<SmtpPopSettingsEntity> page = mailAccountBizService.pageAccounts(userId, MailSceneEnum.MKT.getCode(), dto);
+        return Result.result(page);
+    }
+
+    @PostMapping("/list")
+    @ApiOperation("营销邮箱账户列表")
+    public Result<?> list(@RequestParam(required = false) Long orgId) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        List<SmtpPopSettingsEntity> list = mailAccountBizService.listMktAccounts(userId, orgId);
+        return Result.result(list);
+    }
+
+    @PostMapping("/save")
+    @ApiOperation("保存/修改营销邮箱")
+    public Result<?> save(@RequestBody SmtpPopSettingsEntity entity) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        entity.setScene(MailSceneEnum.MKT.getCode());
+        if (entity.getEnableReceive() == null) {
+            entity.setEnableReceive(0);
+        }
+        // 营销发信通道默认只强制校验 SMTP
+        boolean requireReceive = org.springframework.util.StringUtils.hasText(entity.getProtocolType());
+        mailAccountBizService.saveAccount(entity, userId, MailSceneEnum.MKT, requireReceive);
+        return Result.ok();
+    }
+
+    @PostMapping("/delete")
+    @ApiOperation("删除营销邮箱")
+    public Result<?> delete(Long id) {
+        Long userId = requireLogin();
+        if (userId == null) {
+            return Result.result(null);
+        }
+        mailAccountBizService.deleteAccount(id, userId, MailSceneEnum.MKT);
+        return Result.ok();
+    }
+
+    @PostMapping("/default_use")
+    @ApiOperation("设置默认营销发信账户")
+    public Result<?> defaultUse(Long id) {
+        Long userId = requireLogin();
+        if (userId == null || id == null) {
+            return Result.result(null);
+        }
+        mailAccountBizService.setDefaultAccount(id, userId, MailSceneEnum.MKT);
+        return Result.ok();
+    }
+
+    @PostMapping("/test_connect")
+    @ApiOperation("测试营销邮箱连通性(以SMTP为主)")
+    public Result<?> testConnect(@RequestBody SmtpPopSettingsEntity mailSet) {
+        // 营销可仅发信;若未填收信协议则只测 SMTP
+        if (mailSet != null && !org.springframework.util.StringUtils.hasText(mailSet.getProtocolType())) {
+            if (!mailAccountConnectService.testSmtp(mailSet)) {
+                return Result.error("邮箱校验失败,请输入正确的账号密码");
+            }
+            return Result.ok();
+        }
+        mailAccountConnectService.validateConnect(mailSet);
+        return Result.ok();
+    }
+
+    private Long requireLogin() {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        return Objects.isNull(userId) ? null : userId;
+    }
+}

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

@@ -4,7 +4,7 @@ server:
     max-swallow-size: -1
     basedir: /app/temp
   servlet:
-    context-path: /router/rest/
+    context-path: /router/mail
   compression:
     enabled: true
     min-response-size: 1024

+ 21 - 2
storlead-mail/README.md

@@ -1,6 +1,6 @@
 # storlead-mail 子模块说明
 
-邮件服务拆为个 Maven 子模块,避免 CRM 与营销业务混在一起。
+邮件服务拆为个 Maven 子模块,避免 CRM 与营销业务混在一起,调度统一编排
 
 ## 模块
 
@@ -9,14 +9,25 @@
 | common | `storlead-mail-common` | 账户(`smtp_pop_settings`)、SMTP/IMAP 连接、公共投递、`MailSceneEnum` |
 | crm | `storlead-mail-crm` | CRM 个人邮箱:`emails` / `client_sent_emails`、收信、文件夹、CRM 集成任务 |
 | mkt | `storlead-mail-mkt` | 营销投递:`mkt_mail_message`、发送与打开追踪 |
+| dispatch | `storlead-mail-dispatch` | **统一调度**:定时发送(CRM+MKT)、CRM 收信拉取、服务器邮件延迟删除 |
 
-依赖方向:`crm` → `common`,`mkt` → `common`,**crm 与 mkt 互不依赖**。
+依赖方向:
+
+```
+crm → common
+mkt → common
+dispatch → crm + mkt
+api → dispatch
+```
+
+**crm 与 mkt 互不依赖**;调度放在 dispatch,避免任务只挂在 crm 里。
 
 ## 包名
 
 - `com.storlead.sales.mail.common.*`
 - `com.storlead.sales.mail.crm.*`
 - `com.storlead.sales.mail.mkt.*`
+- `com.storlead.sales.mail.dispatch.*`
 
 API:
 
@@ -24,6 +35,14 @@ API:
 - `com.storlead.mail.crm` — CRM 邮件接口(原 `/mail/**`)
 - `com.storlead.mail.mkt` — 营销接口(`/mail/mkt/**`)
 
+## 调度任务
+
+| Job | 覆盖范围 |
+|-----|----------|
+| `DelaySendEmailTaskJob` | CRM 延时发信 + 营销延时发信 |
+| `EmailPullHeadContentTaskJob` | 仅 CRM 收信(营销账户默认不收) |
+| `DelayDeleteServerEmailTaskJob` | 仅 CRM IMAP 服务器删除 |
+
 ## 库表变更
 
 执行:`storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql`

+ 3 - 1
storlead-mail/pom.xml

@@ -14,11 +14,13 @@
     <artifactId>storlead-mail</artifactId>
     <packaging>pom</packaging>
     <name>storlead-mail</name>
-    <description>Mail service aggregator: common / crm / mkt</description>
+    <description>Mail service aggregator: common / crm / mkt / dispatch</description>
 
     <modules>
         <module>storlead-mail-common</module>
         <module>storlead-mail-crm</module>
         <module>storlead-mail-mkt</module>
+        <module>storlead-mail-dispatch</module>
     </modules>
 </project>
+

+ 32 - 11
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailAccountBizService.java

@@ -1,37 +1,58 @@
 package com.storlead.sales.mail.common.service;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.storlead.framework.common.dto.page.PageDTO;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
 import com.storlead.sales.mail.common.enums.MailSceneEnum;
 
 import java.util.List;
 
 /**
- * 公共邮箱账户业务:按场景查询与校验,供 CRM / MKT 复用。
+ * 公共邮箱账户业务:按场景查询与维护,供 CRM / MKT 复用。
  */
 public interface MailAccountBizService {
 
+    SmtpPopSettingsEntity getDefaultAccount(Long userId, String scene);
+
+    List<SmtpPopSettingsEntity> listAccounts(Long userId, String scene);
+
+    List<SmtpPopSettingsEntity> listOrgAccounts(Long orgId, String scene);
+
     /**
-     * 获取用户默认账户(指定场景,默认 CRM)。
+     * 分页查询账户(密码解密后返回)。
      */
-    SmtpPopSettingsEntity getDefaultAccount(Long userId, String scene);
+    IPage<SmtpPopSettingsEntity> pageAccounts(Long userId, String scene, PageDTO pageDTO);
 
     /**
-     * 获取用户账户列表(指定场景)
+     * 营销账户列表:按组织或个人
      */
-    List<SmtpPopSettingsEntity> listAccounts(Long userId, String scene);
+    List<SmtpPopSettingsEntity> listMktAccounts(Long userId, Long orgId);
+
+    SmtpPopSettingsEntity requireAccount(Long accountId, MailSceneEnum scene);
+
+    SmtpPopSettingsEntity requireOwnedAccount(Long accountId, Long userId, MailSceneEnum scene);
 
     /**
-     * 按组织获取营销账户。
+     * 保存账户:连通性校验、加密、默认账户互斥、场景默认值
      */
-    List<SmtpPopSettingsEntity> listOrgAccounts(Long orgId, String scene);
+    void saveAccount(SmtpPopSettingsEntity entity, Long userId, MailSceneEnum scene);
 
     /**
-     * 校验账户存在、未删除,且场景匹配。
+     * @param requireReceive 是否强制校验收信连通性
      */
-    SmtpPopSettingsEntity requireAccount(Long accountId, MailSceneEnum scene);
+    void saveAccount(SmtpPopSettingsEntity entity, Long userId, MailSceneEnum scene, boolean requireReceive);
 
     /**
-     * 校验用户对账户的个人归属,且场景匹配
+     * 逻辑删除账户
      */
-    SmtpPopSettingsEntity requireOwnedAccount(Long accountId, Long userId, MailSceneEnum scene);
+    void deleteAccount(Long id, Long userId, MailSceneEnum scene);
+
+    /**
+     * 设置同场景默认账户。
+     */
+    void setDefaultAccount(Long id, Long userId, MailSceneEnum scene);
+
+    void setAutoReply(Long id, Long userId, Integer autoReplyStatus, String replyTitle, String replyContent);
+
+    void setDeleteMailEnabled(Long id, Long userId, Integer enabled);
 }

+ 28 - 0
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/MailAccountConnectService.java

@@ -0,0 +1,28 @@
+package com.storlead.sales.mail.common.service;
+
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+
+/**
+ * 邮箱连通性校验(SMTP / IMAP / POP3)。
+ */
+public interface MailAccountConnectService {
+
+    /**
+     * 按协议校验收信 + SMTP 发信。失败抛 WebException。
+     */
+    void validateConnect(SmtpPopSettingsEntity account);
+
+    /**
+     * @param requireReceive true 时必须校验收信协议;false 时仅校验 SMTP(营销发信通道常用)
+     */
+    void validateConnect(SmtpPopSettingsEntity account, boolean requireReceive);
+
+    /**
+     * 仅校验 SMTP。
+     */
+    boolean testSmtp(SmtpPopSettingsEntity account);
+
+    boolean testImap(SmtpPopSettingsEntity account);
+
+    boolean testPop3(SmtpPopSettingsEntity account);
+}

+ 171 - 16
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailAccountBizServiceImpl.java

@@ -1,16 +1,25 @@
 package com.storlead.sales.mail.common.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+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.common.dto.page.PageDTO;
+import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
 import com.storlead.framework.web.exception.WebException;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.enums.AccountScopeEnum;
 import com.storlead.sales.mail.common.enums.MailSceneEnum;
 import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.common.service.MailAccountConnectService;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
+import java.util.Date;
 import java.util.List;
 import java.util.Objects;
 
@@ -20,6 +29,9 @@ public class MailAccountBizServiceImpl implements MailAccountBizService {
     @Resource
     private SmtpPopSettingsService smtpPopSettingsService;
 
+    @Resource
+    private MailAccountConnectService mailAccountConnectService;
+
     @Override
     public SmtpPopSettingsEntity getDefaultAccount(Long userId, String scene) {
         String sceneCode = MailSceneEnum.of(scene).getCode();
@@ -33,7 +45,6 @@ public class MailAccountBizServiceImpl implements MailAccountBizService {
         if (entity != null) {
             return entity;
         }
-        // 兼容未刷 scene 的存量数据:仅 CRM 场景回退查空 scene
         if (MailSceneEnum.isCrm(sceneCode)) {
             wrapper = new LambdaQueryWrapper<>();
             wrapper.eq(SmtpPopSettingsEntity::getOwnerBy, userId)
@@ -50,18 +61,7 @@ public class MailAccountBizServiceImpl implements MailAccountBizService {
 
     @Override
     public List<SmtpPopSettingsEntity> listAccounts(Long userId, String scene) {
-        String sceneCode = MailSceneEnum.of(scene).getCode();
-        LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(SmtpPopSettingsEntity::getOwnerBy, userId)
-                .eq(SmtpPopSettingsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
-        if (MailSceneEnum.isCrm(sceneCode)) {
-            wrapper.and(w -> w.isNull(SmtpPopSettingsEntity::getScene)
-                    .or().eq(SmtpPopSettingsEntity::getScene, "")
-                    .or().eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.CRM.getCode()));
-        } else {
-            wrapper.eq(SmtpPopSettingsEntity::getScene, sceneCode);
-        }
-        return smtpPopSettingsService.list(wrapper);
+        return smtpPopSettingsService.list(buildSceneOwnerWrapper(userId, scene));
     }
 
     @Override
@@ -73,6 +73,22 @@ public class MailAccountBizServiceImpl implements MailAccountBizService {
                 .eq(SmtpPopSettingsEntity::getIsDelete, CommonConstant.DEL_FLAG_0));
     }
 
+    @Override
+    public IPage<SmtpPopSettingsEntity> pageAccounts(Long userId, String scene, PageDTO pageDTO) {
+        Page<SmtpPopSettingsEntity> page = new Page<>(pageDTO.getPageIndex(), pageDTO.getPageSize());
+        IPage<SmtpPopSettingsEntity> pageList = smtpPopSettingsService.page(page, buildSceneOwnerWrapper(userId, scene));
+        decryptPasswords(pageList.getRecords());
+        return pageList;
+    }
+
+    @Override
+    public List<SmtpPopSettingsEntity> listMktAccounts(Long userId, Long orgId) {
+        if (orgId != null) {
+            return listOrgAccounts(orgId, MailSceneEnum.MKT.getCode());
+        }
+        return listAccounts(userId, MailSceneEnum.MKT.getCode());
+    }
+
     @Override
     public SmtpPopSettingsEntity requireAccount(Long accountId, MailSceneEnum scene) {
         if (accountId == null) {
@@ -95,13 +111,152 @@ public class MailAccountBizServiceImpl implements MailAccountBizService {
         return account;
     }
 
+    @Override
+    public void saveAccount(SmtpPopSettingsEntity entity, Long userId, MailSceneEnum scene) {
+        saveAccount(entity, userId, scene, true);
+    }
+
+    @Override
+    public void saveAccount(SmtpPopSettingsEntity entity, Long userId, MailSceneEnum scene, boolean requireReceive) {
+        if (entity == null || userId == null) {
+            throw new WebException("参数错误");
+        }
+        mailAccountConnectService.validateConnect(entity, requireReceive);
+
+        AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
+        entity.setEmailPassword(encryptor.encrypt(entity.getEmailPassword()));
+
+        String sceneCode = scene.getCode();
+        if (!StringUtils.hasText(entity.getScene())) {
+            entity.setScene(sceneCode);
+        }
+        assertScene(entity, scene);
+
+        if (!Integer.valueOf(1).equals(entity.getUseDefault())) {
+            SmtpPopSettingsEntity currentDefault = getDefaultAccount(userId, sceneCode);
+            if (currentDefault == null || Objects.equals(currentDefault.getId(), entity.getId())) {
+                throw new WebException("请设置默认邮箱");
+            }
+        }
+
+        if (entity.getId() != null) {
+            SmtpPopSettingsEntity old = requireOwnedAccount(entity.getId(), userId, scene);
+            if (!Objects.equals(old.getEmailAddress(), entity.getEmailAddress())) {
+                throw new WebException("已绑定的邮箱无法修改,可新增邮箱配置!");
+            }
+        }
+
+        entity.setOwnerBy(userId);
+        if (!StringUtils.hasText(entity.getAccountScope())) {
+            entity.setAccountScope(AccountScopeEnum.PERSONAL.getCode());
+        }
+        if (entity.getEnableReceive() == null) {
+            entity.setEnableReceive(MailSceneEnum.isMkt(entity.getScene()) ? 0 : 1);
+        }
+
+        boolean ok = smtpPopSettingsService.saveOrUpdate(entity);
+        if (Integer.valueOf(1).equals(entity.getUseDefault()) && ok) {
+            clearOtherDefaults(userId, entity.getScene(), entity.getId());
+        }
+    }
+
+    @Override
+    public void deleteAccount(Long id, Long userId, MailSceneEnum scene) {
+        requireOwnedAccount(id, userId, scene);
+        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(SmtpPopSettingsEntity::getIsDelete, CommonConstant.DEL_FLAG_1)
+                .eq(SmtpPopSettingsEntity::getId, id)
+                .eq(SmtpPopSettingsEntity::getOwnerBy, userId);
+        smtpPopSettingsService.update(updateWrapper);
+    }
+
+    @Override
+    public void setDefaultAccount(Long id, Long userId, MailSceneEnum scene) {
+        SmtpPopSettingsEntity target = requireOwnedAccount(id, userId, scene);
+        String sceneCode = StringUtils.hasText(target.getScene()) ? target.getScene() : scene.getCode();
+        clearOtherDefaults(userId, sceneCode, null);
+        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(SmtpPopSettingsEntity::getUseDefault, 1)
+                .eq(SmtpPopSettingsEntity::getId, id)
+                .eq(SmtpPopSettingsEntity::getOwnerBy, userId);
+        smtpPopSettingsService.update(updateWrapper);
+    }
+
+    @Override
+    public void setAutoReply(Long id, Long userId, Integer autoReplyStatus, String replyTitle, String replyContent) {
+        if (id == null || autoReplyStatus == null) {
+            throw new WebException("参数错误");
+        }
+        requireOwnedAccount(id, userId, MailSceneEnum.CRM);
+        if (Integer.valueOf(1).equals(autoReplyStatus)
+                && (!StringUtils.hasText(replyTitle) || !StringUtils.hasText(replyContent))) {
+            throw new WebException("参数错误");
+        }
+        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(SmtpPopSettingsEntity::getIsAutoReply, autoReplyStatus)
+                .set(SmtpPopSettingsEntity::getReplyOnTime, new Date())
+                .eq(SmtpPopSettingsEntity::getId, id)
+                .eq(SmtpPopSettingsEntity::getOwnerBy, userId);
+        if (replyTitle != null) {
+            updateWrapper.set(SmtpPopSettingsEntity::getReplyTitle, replyTitle);
+        }
+        if (replyContent != null) {
+            updateWrapper.set(SmtpPopSettingsEntity::getReplyContent, replyContent);
+        }
+        smtpPopSettingsService.update(updateWrapper);
+    }
+
+    @Override
+    public void setDeleteMailEnabled(Long id, Long userId, Integer enabled) {
+        if (id == null || enabled == null) {
+            throw new WebException("参数错误");
+        }
+        requireOwnedAccount(id, userId, MailSceneEnum.CRM);
+        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(SmtpPopSettingsEntity::getIsDeleteMail, enabled)
+                .eq(SmtpPopSettingsEntity::getId, id)
+                .eq(SmtpPopSettingsEntity::getOwnerBy, userId);
+        smtpPopSettingsService.update(updateWrapper);
+    }
+
+    private LambdaQueryWrapper<SmtpPopSettingsEntity> buildSceneOwnerWrapper(Long userId, String scene) {
+        String sceneCode = MailSceneEnum.of(scene).getCode();
+        LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SmtpPopSettingsEntity::getOwnerBy, userId)
+                .eq(SmtpPopSettingsEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        if (MailSceneEnum.isCrm(sceneCode)) {
+            wrapper.and(w -> w.isNull(SmtpPopSettingsEntity::getScene)
+                    .or().eq(SmtpPopSettingsEntity::getScene, "")
+                    .or().eq(SmtpPopSettingsEntity::getScene, MailSceneEnum.CRM.getCode()));
+        } else {
+            wrapper.eq(SmtpPopSettingsEntity::getScene, sceneCode);
+        }
+        return wrapper;
+    }
+
+    private void clearOtherDefaults(Long userId, String scene, Long excludeId) {
+        LambdaUpdateWrapper<SmtpPopSettingsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(SmtpPopSettingsEntity::getUseDefault, 0)
+                .eq(SmtpPopSettingsEntity::getOwnerBy, userId)
+                .eq(SmtpPopSettingsEntity::getScene, scene);
+        if (excludeId != null) {
+            updateWrapper.ne(SmtpPopSettingsEntity::getId, excludeId);
+        }
+        smtpPopSettingsService.update(updateWrapper);
+    }
+
+    private void decryptPasswords(List<SmtpPopSettingsEntity> records) {
+        if (CollectionUtils.isEmpty(records)) {
+            return;
+        }
+        AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
+        records.forEach(e -> e.setEmailPassword(encryptor.decrypt(e.getEmailPassword())));
+    }
+
     private void assertScene(SmtpPopSettingsEntity account, MailSceneEnum expected) {
         MailSceneEnum actual = MailSceneEnum.of(account.getScene());
         if (expected != null && actual != expected) {
             throw new WebException("邮箱账户场景不匹配,期望 " + expected.getCode() + ",实际 " + actual.getCode());
         }
-        if (!StringUtils.hasText(account.getScene()) && expected == MailSceneEnum.CRM) {
-            return;
-        }
     }
 }

+ 106 - 0
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/service/impl/MailAccountConnectServiceImpl.java

@@ -0,0 +1,106 @@
+package com.storlead.sales.mail.common.service.impl;
+
+import com.storlead.framework.web.exception.WebException;
+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.service.MailAccountConnectService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.mail.Authenticator;
+import javax.mail.PasswordAuthentication;
+import javax.mail.Session;
+import javax.mail.Transport;
+import java.util.Objects;
+import java.util.Properties;
+
+@Slf4j
+@Service
+public class MailAccountConnectServiceImpl implements MailAccountConnectService {
+
+    @Override
+    public void validateConnect(SmtpPopSettingsEntity account) {
+        validateConnect(account, true);
+    }
+
+    @Override
+    public void validateConnect(SmtpPopSettingsEntity account, boolean requireReceive) {
+        if (account == null) {
+            throw new WebException("邮箱配置不能为空");
+        }
+        if (requireReceive) {
+            String protocol = account.getProtocolType();
+            if ("POP3".equals(protocol)) {
+                if (!testPop3(account)) {
+                    throw new WebException("邮箱校验失败,请输入正确的账号密码");
+                }
+            } else if ("IMAP".equals(protocol)) {
+                if (!testImap(account)) {
+                    throw new WebException("邮箱校验失败,请输入正确的账号密码");
+                }
+            } else {
+                throw new WebException("邮箱校验失败,请输入正确的账号密码");
+            }
+        }
+        if (!testSmtp(account)) {
+            throw new WebException("邮箱校验失败,请输入正确的账号密码");
+        }
+    }
+
+    @Override
+    public boolean testSmtp(SmtpPopSettingsEntity smtpPop) {
+        try {
+            Properties properties = new Properties();
+            properties.put("mail.smtp.host", smtpPop.getSmtpServer());
+            properties.put("mail.smtp.port", smtpPop.getSmtpPort());
+            properties.put("mail.smtp.auth", "true");
+            properties.put("mail.user", smtpPop.getEmailAddress());
+            properties.put("mail.smtp.starttls.enable", Integer.valueOf(1).equals(smtpPop.getSmtpTls()) ? "true" : "false");
+            properties.put("mail.smtp.ssl.enable", Integer.valueOf(1).equals(smtpPop.getSmtpSsl()) ? "true" : "false");
+
+            String username = smtpPop.getEmailAddress();
+            String password = smtpPop.getEmailPassword();
+            Session session = Session.getInstance(properties, new Authenticator() {
+                @Override
+                protected PasswordAuthentication getPasswordAuthentication() {
+                    return new PasswordAuthentication(username, password);
+                }
+            });
+            Transport transport = session.getTransport("smtp");
+            transport.connect();
+            transport.close();
+            return true;
+        } catch (Exception e) {
+            log.error("testSmtp - error", e);
+            return false;
+        }
+    }
+
+    @Override
+    public boolean testImap(SmtpPopSettingsEntity mailSet) {
+        return testReceive(mailSet, "testImap");
+    }
+
+    @Override
+    public boolean testPop3(SmtpPopSettingsEntity mailSet) {
+        return testReceive(mailSet, "testPop3");
+    }
+
+    private boolean testReceive(SmtpPopSettingsEntity mailSet, String tag) {
+        try {
+            MailProperties properties = new MailProperties(mailSet);
+            MailConnection mailConnection = MailConnectionUtil.testReceiveEmailsConnection(properties);
+            if (Objects.isNull(mailConnection)) {
+                return false;
+            }
+            log.debug("{} ok", tag);
+            mailConnection.close();
+            return true;
+        } catch (Exception e) {
+            log.error("{} - error", tag, e);
+            return false;
+        }
+    }
+}

+ 30 - 30
storlead-mail/storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql

@@ -11,36 +11,36 @@ CREATE INDEX idx_smtp_pop_org_scene ON smtp_pop_settings (org_id, scene, is_dele
 
 -- 营销邮件投递表(与 CRM emails 分表)
 CREATE TABLE IF NOT EXISTS mkt_mail_message (
-    id              bigint       NOT NULL AUTO_INCREMENT,
-    account_id      bigint       NOT NULL COMMENT 'smtp_pop_settings.id',
-    org_id          bigint       NULL,
-    biz_ref         varchar(64)  NULL COMMENT '调用方业务单号',
-    biz_batch_no    varchar(64)  NULL,
-    message_id      varchar(255) NULL,
-    track_token     varchar(64)  NULL,
-    subject         varchar(512) NULL,
-    from_addr       varchar(255) NULL,
-    from_name       varchar(255) NULL,
-    recipient       varchar(512) NOT NULL,
-    recipient_cc    text         NULL,
-    recipient_bcc   text         NULL,
-    content         mediumtext   NULL,
-    status          tinyint      NOT NULL DEFAULT 0 COMMENT '-1待定时 0发送中 1成功 2失败',
-    fail_reason     varchar(512) NULL,
-    is_track        tinyint      NOT NULL DEFAULT 0,
-    opened          tinyint      NOT NULL DEFAULT 0,
-    first_open_time datetime     NULL,
-    open_count      int          NOT NULL DEFAULT 0,
-    sent_time       datetime     NULL,
-    delay_send_time datetime     NULL,
-    owner_by        bigint       NULL,
-    create_by       bigint       NULL,
-    update_by       bigint       NULL,
-    create_time     datetime     NULL,
-    update_time     datetime     NULL,
-    is_delete       tinyint      NOT NULL DEFAULT 0,
-    enabled         tinyint      NOT NULL DEFAULT 1,
-    sort            int          NULL,
+    id              bigint       NOT NULL AUTO_INCREMENT COMMENT '主键,自增;一封营销投递记录一行(建议一收件人一行)',
+    account_id      bigint       NOT NULL COMMENT '发信账户ID,对应 smtp_pop_settings.id,且该账户 scene=MKT',
+    org_id          bigint       NULL COMMENT '组织ID;组织级营销账号时冗余,便于按组织隔离查询',
+    biz_ref         varchar(64)  NULL COMMENT '调用方业务单号(如营销系统活动/任务ID),邮件服务不解析业务含义,仅回查',
+    biz_batch_no    varchar(64)  NULL COMMENT '同一次批量发送的批次号,用于按批汇总状态',
+    message_id      varchar(255) NULL COMMENT 'SMTP发出后的Message-ID,发送成功后回写,用于对账/排重',
+    track_token     varchar(64)  NULL COMMENT '打开追踪唯一token;is_track=1时生成,对应像素URL',
+    subject         varchar(512) NULL COMMENT '邮件主题',
+    from_addr       varchar(255) NULL COMMENT '发件人邮箱地址(一般取账户email_address)',
+    from_name       varchar(255) NULL COMMENT '发件人显示名(一般取账户昵称)',
+    recipient       varchar(512) NOT NULL COMMENT '收件人邮箱;单收件人一行,便于打开追踪归属',
+    recipient_cc    text         NULL COMMENT '抄送人,多个用逗号分隔',
+    recipient_bcc   text         NULL COMMENT '密送人,多个用逗号分隔',
+    content         mediumtext   NULL COMMENT '邮件正文(HTML/文本);体积大时可后续改为外置只存引用',
+    status          tinyint      NOT NULL DEFAULT 0 COMMENT '投递状态:-1待定时发送;0发送中;1发送成功;2发送失败',
+    fail_reason     varchar(512) NULL COMMENT '发送失败原因(异常信息摘要)',
+    is_track        tinyint      NOT NULL DEFAULT 0 COMMENT '是否开启打开追踪:0否;1是',
+    opened          tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素回调):0未打开;1已打开;存在网关预取假阳性,仅为弱信号',
+    first_open_time datetime     NULL COMMENT '首次打开时间(首次有效像素回调时间)',
+    open_count      int          NOT NULL DEFAULT 0 COMMENT '打开次数累计(每次追踪回调+1,含重复打开/预取)',
+    sent_time       datetime     NULL COMMENT '实际SMTP发送成功时间',
+    delay_send_time datetime     NULL COMMENT '定时发送时间;未到点时status=-1',
+    owner_by        bigint       NULL COMMENT '数据归属人(操作/归属用户ID)',
+    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_track_token (track_token),
     KEY idx_mkt_biz_ref (biz_ref, status),

+ 3 - 2
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/integration/config/MailIntegrationConfiguration.java

@@ -2,10 +2,11 @@ package com.storlead.sales.mail.crm.integration.config;
 
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.scheduling.annotation.EnableScheduling;
 
+/**
+ * CRM 集成配置。定时调度统一由 mail-dispatch 模块开启。
+ */
 @Configuration
-@EnableScheduling
 @EnableConfigurationProperties(MailIntegrationProperties.class)
 public class MailIntegrationConfiguration {
 }

+ 16 - 0
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/CrmMailAccountAppService.java

@@ -0,0 +1,16 @@
+package com.storlead.sales.mail.crm.service;
+
+import com.storlead.sales.mail.crm.pojo.vo.MailAcountVO;
+
+import java.util.List;
+
+/**
+ * CRM 邮箱账户应用服务(含未读数等 CRM 侧聚合)。
+ */
+public interface CrmMailAccountAppService {
+
+    /**
+     * 个人邮箱账户列表(含收件箱未读数)。
+     */
+    List<MailAcountVO> listPersonalAccountsWithUnread(Long userId);
+}

+ 71 - 0
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/CrmMailAccountAppServiceImpl.java

@@ -0,0 +1,71 @@
+package com.storlead.sales.mail.crm.service.impl;
+
+import cn.hutool.core.collection.CollectionUtil;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.enums.MailSceneEnum;
+import com.storlead.sales.mail.common.service.MailAccountBizService;
+import com.storlead.sales.mail.crm.entity.EmailsEntity;
+import com.storlead.sales.mail.crm.enums.EmailBoxEnum;
+import com.storlead.sales.mail.crm.pojo.vo.MailAcountVO;
+import com.storlead.sales.mail.crm.service.CrmMailAccountAppService;
+import com.storlead.sales.mail.crm.service.EmailsService;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Service
+public class CrmMailAccountAppServiceImpl implements CrmMailAccountAppService {
+
+    @Resource
+    private MailAccountBizService mailAccountBizService;
+
+    @Resource
+    private EmailsService emailsService;
+
+    @Override
+    public List<MailAcountVO> listPersonalAccountsWithUnread(Long userId) {
+        List<SmtpPopSettingsEntity> emails = mailAccountBizService.listAccounts(userId, MailSceneEnum.CRM.getCode());
+
+        QueryWrapper<EmailsEntity> queryWrapper = new QueryWrapper<>();
+        queryWrapper.select("smtp_pop_id", "COUNT(*) AS email_count");
+        queryWrapper.eq("owner_by", userId);
+        queryWrapper.eq("folder", EmailBoxEnum.INBOX.code);
+        queryWrapper.eq("is_trash", 0);
+        queryWrapper.eq("is_read", 0);
+        queryWrapper.eq("is_delete", 0);
+        queryWrapper.isNotNull("smtp_pop_id");
+        queryWrapper.ne("`from`", "system@storlead.com");
+        queryWrapper.groupBy("smtp_pop_id");
+        List<Map<String, Object>> readMapls = emailsService.listMaps(queryWrapper);
+
+        Map<Long, Integer> readMap = new HashMap<>();
+        if (!CollectionUtils.isEmpty(readMapls)) {
+            for (Map<String, Object> map : readMapls) {
+                Long key = Long.valueOf(map.get("smtp_pop_id").toString());
+                Integer val = Integer.valueOf(map.get("email_count").toString());
+                readMap.put(key, val);
+            }
+        }
+
+        List<MailAcountVO> mails = new ArrayList<>();
+        if (CollectionUtil.isNotEmpty(emails)) {
+            emails.forEach(e -> {
+                MailAcountVO mailvo = new MailAcountVO();
+                mailvo.setMailAccount(e.getEmailAddress());
+                mailvo.setId(e.getId());
+                mailvo.setUseDefault(e.getUseDefault());
+                Integer readNum = readMap.get(e.getId());
+                mailvo.setUnReadCount(Objects.nonNull(readNum) ? readNum : 0);
+                mails.add(mailvo);
+            });
+        }
+        return mails;
+    }
+}

+ 156 - 0
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/support/MailOwnershipGuard.java

@@ -0,0 +1,156 @@
+package com.storlead.sales.mail.crm.support;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.storlead.framework.common.constant.CommonConstant;
+import com.storlead.framework.mybatis.entity.SysBaseField;
+import com.storlead.sales.mail.crm.entity.EmailsEntity;
+import com.storlead.sales.mail.crm.entity.MailAttachmentEntity;
+import com.storlead.sales.mail.crm.entity.MailTempAttachmentEntity;
+import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.crm.entity.UserEmailFolderEntity;
+import com.storlead.sales.mail.crm.service.EmailsService;
+import com.storlead.sales.mail.crm.service.MailAttachmentService;
+import com.storlead.sales.mail.crm.service.MailTempAttachmentService;
+import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.crm.service.UserEmailFolderService;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import javax.annotation.Resource;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 邮件模块资源归属校验,防止水平越权(IDOR)。
+ */
+@Component
+public class MailOwnershipGuard {
+
+    @Resource
+    private EmailsService emailsService;
+    @Resource
+    private SmtpPopSettingsService smtpPopSettingsService;
+    @Resource
+    private MailAttachmentService mailAttachmentService;
+    @Resource
+    private MailTempAttachmentService tempAttachmentService;
+    @Resource
+    private UserEmailFolderService userEmailFolderService;
+
+    public boolean isOwnedBy(Long ownerBy, Long userId) {
+        return ownerBy != null && userId != null && ownerBy.equals(userId);
+    }
+
+    public EmailsEntity getOwnedEmail(Long mailId, Long userId) {
+        if (mailId == null || userId == null) {
+            return null;
+        }
+        EmailsEntity email = emailsService.getById(mailId);
+        return isOwnedBy(email != null ? email.getOwnerBy() : null, userId) ? email : null;
+    }
+
+    public List<EmailsEntity> listOwnedEmails(Collection<Long> mailIds, Long userId) {
+        if (CollectionUtils.isEmpty(mailIds) || userId == null) {
+            return Collections.emptyList();
+        }
+        return emailsService.list(new LambdaQueryWrapper<EmailsEntity>()
+                .in(EmailsEntity::getId, mailIds)
+                .eq(EmailsEntity::getOwnerBy, userId));
+    }
+
+    public boolean ownsAllEmails(Collection<Long> mailIds, Long userId) {
+        if (CollectionUtils.isEmpty(mailIds) || userId == null) {
+            return false;
+        }
+        long owned = emailsService.count(new LambdaQueryWrapper<EmailsEntity>()
+                .in(EmailsEntity::getId, mailIds)
+                .eq(EmailsEntity::getOwnerBy, userId));
+        return owned == mailIds.size();
+    }
+
+    public SmtpPopSettingsEntity getOwnedSmtpPop(Long smtpPopId, Long userId) {
+        if (smtpPopId == null || userId == null) {
+            return null;
+        }
+        SmtpPopSettingsEntity smtpPop = smtpPopSettingsService.getById(smtpPopId);
+        if (smtpPop == null || !Integer.valueOf(0).equals(smtpPop.getIsDelete())) {
+            return null;
+        }
+        return isOwnedBy(smtpPop.getOwnerBy(), userId) ? smtpPop : null;
+    }
+
+    public UserEmailFolderEntity getOwnedFolder(Long folderId, Long userId) {
+        if (folderId == null || userId == null) {
+            return null;
+        }
+        UserEmailFolderEntity folder = userEmailFolderService.getById(folderId);
+        if (folder == null || !Integer.valueOf(0).equals(folder.getIsDelete())) {
+            return null;
+        }
+        return isOwnedBy(folder.getOwnerBy(), userId) ? folder : null;
+    }
+
+    public MailAttachmentEntity getOwnedAttachment(Long attachmentId, Long userId) {
+        if (attachmentId == null || userId == null) {
+            return null;
+        }
+        MailAttachmentEntity attachment = mailAttachmentService.getById(attachmentId);
+        if (attachment == null) {
+            return null;
+        }
+        EmailsEntity email = emailsService.getById(attachment.getEmailId());
+        return email != null && isOwnedBy(email.getOwnerBy(), userId) ? attachment : null;
+    }
+
+    public MailTempAttachmentEntity getOwnedTempAttachment(Long attachmentId, Long userId) {
+        if (attachmentId == null || userId == null) {
+            return null;
+        }
+        MailTempAttachmentEntity attachment = tempAttachmentService.getById(attachmentId);
+        if (attachment == null) {
+            return null;
+        }
+        if (attachment.getEmailId() != null) {
+            EmailsEntity email = emailsService.getById(attachment.getEmailId());
+            return email != null && isOwnedBy(email.getOwnerBy(), userId) ? attachment : null;
+        }
+        return isOwnedBy(attachment.getOwnerBy(), userId) ? attachment : null;
+    }
+
+    public <T extends SysBaseField> T getOwnedEntity(IService<T> service, Long id, Long userId) {
+        if (id == null || userId == null) {
+            return null;
+        }
+        T entity = service.getById(id);
+        if (entity == null || !Objects.equals(entity.getIsDelete(), CommonConstant.DEL_FLAG_0)) {
+            return null;
+        }
+        return isOwnedBy(entity.getOwnerBy(), userId) ? entity : null;
+    }
+
+    public <T extends SysBaseField> boolean softDeleteOwned(IService<T> service, Collection<Long> ids, Long userId) {
+        if (CollectionUtils.isEmpty(ids) || userId == null) {
+            return false;
+        }
+        UpdateWrapper<T> wrapper = new UpdateWrapper<>();
+        wrapper.in("id", ids);
+        wrapper.eq("owner_by", userId);
+        wrapper.set("is_delete", CommonConstant.DEL_FLAG_1);
+        return service.update(wrapper);
+    }
+
+    public <T extends SysBaseField> boolean updateEnabledOwned(IService<T> service, Long id, Boolean enabled, Long userId) {
+        if (id == null || userId == null) {
+            return false;
+        }
+        UpdateWrapper<T> wrapper = new UpdateWrapper<>();
+        wrapper.eq("id", id);
+        wrapper.eq("owner_by", userId);
+        wrapper.set("enabled", enabled);
+        return service.update(wrapper);
+    }
+}

+ 36 - 0
storlead-mail/storlead-mail-dispatch/pom.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.storlead.boot</groupId>
+        <artifactId>storlead-mail</artifactId>
+        <version>1.0</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>storlead-mail-dispatch</artifactId>
+    <packaging>jar</packaging>
+    <name>storlead-mail-dispatch</name>
+    <description>Mail schedulers that orchestrate both CRM and marketing mail flows</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.storlead.boot</groupId>
+            <artifactId>storlead-mail-crm</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.storlead.boot</groupId>
+            <artifactId>storlead-mail-mkt</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+</project>

+ 17 - 17
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/dispatch/DelayDeleteServerEmailTaskJob.java → storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/DelayDeleteServerEmailTaskJob.java

@@ -1,34 +1,38 @@
-package com.storlead.sales.mail.crm.dispatch;
+package com.storlead.sales.mail.dispatch;
 
 import cn.hutool.core.collection.CollectionUtil;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
 import com.storlead.sales.mail.common.connection.config.MailProperties;
-import com.storlead.sales.mail.crm.entity.EmailsEntity;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
-import com.storlead.sales.mail.crm.service.EmailsService;
 import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.crm.entity.EmailsEntity;
+import com.storlead.sales.mail.crm.service.EmailsService;
 import com.sun.mail.imap.IMAPFolder;
 import lombok.NoArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
 
 import javax.annotation.Resource;
-import javax.mail.*;
-import java.util.*;
+import javax.mail.Authenticator;
+import javax.mail.Flags;
+import javax.mail.Folder;
+import javax.mail.Message;
+import javax.mail.PasswordAuthentication;
+import javax.mail.Session;
+import javax.mail.Store;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
 import java.util.stream.Collectors;
 
 /**
- * 延迟删除邮件服务器上过期邮件的定时任务。
+ * 延迟删除邮件服务器上过期邮件(CRM IMAP)
  * <p>
- * 按 cron 扫描待删除记录,通过 IMAP 在收件箱/发件箱中彻底删除对应邮件,
- * 并回写本地 {@code delete_tag} 状态。执行时间由
- * {@code storlead.mail.scheduler.delay-delete-cron} 配置(默认每天 2:00)。
- * </p>
- * <p>
- * 说明:延迟<strong>发送</strong>邮件请见 {@link AutoSendDelayEmailTaskJob}。
+ * 延迟<strong>发送</strong>请见 {@link DelaySendEmailTaskJob}(同时覆盖 CRM 与营销)。
  * </p>
  */
 @Slf4j
@@ -36,16 +40,12 @@ import java.util.stream.Collectors;
 @NoArgsConstructor
 public class DelayDeleteServerEmailTaskJob {
 
-
     @Resource
     private SmtpPopSettingsService smtpPopSettingsService;
 
     @Resource
     private EmailsService emailsService;
 
-    /**
-     * 定时执行:从邮件服务器延迟删除已标记过期的邮件。
-     */
     // @Scheduled(cron = "${storlead.mail.scheduler.delay-delete-cron:0 0 2 * * ?}")
     public void delayDeleteServerEmailTask() {
         List<EmailsEntity> taskEntities = emailsService.getDelayDeleteMail();

+ 29 - 8
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/dispatch/AutoSendDelayEmailTaskJob.java → storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/DelaySendEmailTaskJob.java

@@ -1,18 +1,18 @@
-package com.storlead.sales.mail.crm.dispatch;
+package com.storlead.sales.mail.dispatch;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.util.encryptor.AccessKeyEncryptor;
-import com.storlead.sales.mail.crm.entity.EmailsEntity;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
+import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.crm.entity.EmailsEntity;
 import com.storlead.sales.mail.crm.enums.EmailBoxEnum;
 import com.storlead.sales.mail.crm.service.EmailsService;
-import com.storlead.sales.mail.common.service.SmtpPopSettingsService;
+import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import lombok.NoArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.core.env.Environment;
-import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
 
@@ -22,14 +22,20 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+/**
+ * 定时发送任务:同时处理 CRM 延时邮件与营销延时邮件。
+ */
 @Slf4j
 @Component
 @NoArgsConstructor
-public class AutoSendDelayEmailTaskJob {
+public class DelaySendEmailTaskJob {
 
     @Resource
     private EmailsService emailsService;
 
+    @Resource
+    private MktMailMessageService mktMailMessageService;
+
     @Resource
     private Environment environment;
 
@@ -42,7 +48,12 @@ public class AutoSendDelayEmailTaskJob {
         if ("dev".equals(active)) {
             return;
         }
-        log.info("定时发送邮件任务开始");
+        log.info("定时发送邮件任务开始(CRM + MKT)");
+        sendCrmDelayEmails();
+        sendMktDelayEmails();
+    }
+
+    private void sendCrmDelayEmails() {
         LambdaQueryWrapper<EmailsEntity> wrapper = new LambdaQueryWrapper<>();
         LocalDateTime now = LocalDateTime.now();
         wrapper.select(EmailsEntity::getId, EmailsEntity::getSmtpPopId, EmailsEntity::getOwnerBy);
@@ -66,15 +77,25 @@ public class AutoSendDelayEmailTaskJob {
             return;
         }
         Map<Long, SmtpPopSettingsEntity> smtpPopMap = smtpPopls.stream()
-                .collect(Collectors.toMap(SmtpPopSettingsEntity::getId, a -> a));
-        log.info("定时发送邮件任务,共{}条", emailsls.size());
+                .collect(Collectors.toMap(SmtpPopSettingsEntity::getId, a -> a, (a, b) -> a));
+        log.info("CRM 定时发送,共{}条", emailsls.size());
         for (EmailsEntity emailVal : emailsls) {
             EmailsEntity email = emailsService.getById(emailVal.getId());
             SmtpPopSettingsEntity smtpPop = smtpPopMap.get(email.getSmtpPopId());
+            if (smtpPop == null) {
+                continue;
+            }
             AccessKeyEncryptor accessKeyEncryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
             String pass = accessKeyEncryptor.decrypt(smtpPop.getEmailPassword());
             smtpPop.setEmailPassword(pass);
             emailsService.sendEmail(email, smtpPop);
         }
     }
+
+    private void sendMktDelayEmails() {
+        int count = mktMailMessageService.sendDueDelayedMails();
+        if (count > 0) {
+            log.info("MKT 定时发送,共{}条", count);
+        }
+    }
 }

+ 4 - 2
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/dispatch/EmailPullHeadContentTaskJob.java → storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/EmailPullHeadContentTaskJob.java

@@ -1,4 +1,4 @@
-package com.storlead.sales.mail.crm.dispatch;
+package com.storlead.sales.mail.dispatch;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
@@ -13,6 +13,9 @@ import org.springframework.util.CollectionUtils;
 import javax.annotation.Resource;
 import java.util.List;
 
+/**
+ * 收信调度:仅拉取 CRM 个人邮箱(营销账户默认不收信)。
+ */
 @Slf4j
 @Component
 @NoArgsConstructor
@@ -26,7 +29,6 @@ public class EmailPullHeadContentTaskJob {
 
     //@Scheduled(cron = "${storlead.mail.scheduler.pull-email-cron:0 */5 * * * ?}")
     public void pullHeadMailTask() {
-        // 仅拉取 CRM 个人邮箱,营销账户不进收信任务
         LambdaQueryWrapper<SmtpPopSettingsEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
         lambdaQueryWrapper.eq(SmtpPopSettingsEntity::getIsDelete, 0);
         lambdaQueryWrapper.eq(SmtpPopSettingsEntity::getEnabled, 1);

+ 12 - 0
storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/config/MailDispatchConfiguration.java

@@ -0,0 +1,12 @@
+package com.storlead.sales.mail.dispatch.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 邮件调度模块配置:统一开启定时任务,覆盖 CRM 与营销。
+ */
+@Configuration
+@EnableScheduling
+public class MailDispatchConfiguration {
+}

+ 6 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java

@@ -13,9 +13,15 @@ public interface MktMailMessageService extends MyBaseService<MktMailMessageEntit
 
     /**
      * 使用 MKT 场景账户发送一封营销邮件,写入 mkt_mail_message。
+     * 若设置了未来定时时间,则仅落库为待发送(status=-1),由调度任务触发。
      */
     MktMailMessageEntity send(MktSendMailDTO dto, Long operatorId);
 
+    /**
+     * 扫描并发送到期的营销定时邮件,返回处理条数。
+     */
+    int sendDueDelayedMails();
+
     IPage<MktMailMessageEntity> pageList(MktMailQueryDTO query);
 
     /**

+ 88 - 29
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -1,9 +1,11 @@
 package com.storlead.sales.mail.mkt.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 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.common.util.encryptor.AccessKeyEncryptor;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.framework.web.exception.WebException;
 import com.storlead.sales.mail.common.entity.SmtpPopSettingsEntity;
@@ -25,7 +27,9 @@ import javax.mail.Session;
 import javax.mail.internet.InternetAddress;
 import javax.mail.internet.MimeMessage;
 import java.util.Date;
+import java.util.List;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 @Service
 public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageMapper, MktMailMessageEntity>
@@ -70,40 +74,56 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         if (Integer.valueOf(1).equals(entity.getIsTrack())) {
             entity.setTrackToken(UUID.randomUUID().toString().replace("-", ""));
         }
+
+        boolean delayed = dto.getDelaySendTime() != null && dto.getDelaySendTime().after(new Date());
+        if (delayed) {
+            entity.setStatus(-1);
+            this.save(entity);
+            return entity;
+        }
+
         entity.setStatus(0);
         this.save(entity);
+        doSmtpSend(entity, decryptAccount(account));
+        return entity;
+    }
 
-        try {
-            Session session = MailTransportSupport.createSmtpSession(account);
-            MimeMessage message = new MimeMessage(session);
-            String fromName = StringUtils.hasText(account.getSenderNickName())
-                    ? account.getSenderNickName() : account.getEmailAddress();
-            message.setFrom(new InternetAddress(account.getEmailAddress(), fromName, "UTF-8"));
-            message.setRecipient(Message.RecipientType.TO, new InternetAddress(entity.getRecipient()));
-            if (StringUtils.hasText(entity.getRecipientCc())) {
-                message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(entity.getRecipientCc()));
+    @Override
+    public int sendDueDelayedMails() {
+        Date now = new Date();
+        List<MktMailMessageEntity> dueList = this.list(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId, MktMailMessageEntity::getAccountId)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailMessageEntity::getStatus, -1)
+                .isNotNull(MktMailMessageEntity::getDelaySendTime)
+                .le(MktMailMessageEntity::getDelaySendTime, now));
+        if (CollectionUtils.isEmpty(dueList)) {
+            return 0;
+        }
+        List<Long> ids = dueList.stream().map(MktMailMessageEntity::getId).collect(Collectors.toList());
+        LambdaUpdateWrapper<MktMailMessageEntity> claiming = new LambdaUpdateWrapper<>();
+        claiming.set(MktMailMessageEntity::getStatus, 0);
+        claiming.in(MktMailMessageEntity::getId, ids);
+        claiming.eq(MktMailMessageEntity::getStatus, -1);
+        this.update(claiming);
+
+        int success = 0;
+        for (MktMailMessageEntity item : dueList) {
+            MktMailMessageEntity entity = this.getById(item.getId());
+            if (entity == null || !Integer.valueOf(0).equals(entity.getStatus())) {
+                continue;
             }
-            if (StringUtils.hasText(entity.getRecipientBcc())) {
-                message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(entity.getRecipientBcc()));
+            try {
+                SmtpPopSettingsEntity account = mailAccountBizService.requireAccount(entity.getAccountId(), MailSceneEnum.MKT);
+                doSmtpSend(entity, decryptAccount(account));
+                success++;
+            } catch (Exception ex) {
+                entity.setStatus(2);
+                entity.setFailReason(ex.getMessage());
+                this.updateById(entity);
             }
-            message.setSubject(entity.getSubject(), "UTF-8");
-            String html = entity.getContent() == null ? "" : entity.getContent();
-            message.setContent(html, "text/html;charset=UTF-8");
-            message.saveChanges();
-            MailTransportSupport.send(message);
-
-            entity.setMessageId(message.getMessageID());
-            entity.setStatus(1);
-            entity.setSentTime(new Date());
-            entity.setFailReason(null);
-            this.updateById(entity);
-        } catch (Exception ex) {
-            entity.setStatus(2);
-            entity.setFailReason(ex.getMessage());
-            this.updateById(entity);
-            throw new WebException("营销邮件发送失败: " + ex.getMessage(), ex);
         }
-        return entity;
+        return success;
     }
 
     @Override
@@ -148,7 +168,46 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         return this.updateById(entity);
     }
 
-    private String joinEmails(java.util.List<String> emails) {
+    private void doSmtpSend(MktMailMessageEntity entity, SmtpPopSettingsEntity account) {
+        try {
+            Session session = MailTransportSupport.createSmtpSession(account);
+            MimeMessage message = new MimeMessage(session);
+            String fromName = StringUtils.hasText(account.getSenderNickName())
+                    ? account.getSenderNickName() : account.getEmailAddress();
+            message.setFrom(new InternetAddress(account.getEmailAddress(), fromName, "UTF-8"));
+            message.setRecipient(Message.RecipientType.TO, new InternetAddress(entity.getRecipient()));
+            if (StringUtils.hasText(entity.getRecipientCc())) {
+                message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(entity.getRecipientCc()));
+            }
+            if (StringUtils.hasText(entity.getRecipientBcc())) {
+                message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(entity.getRecipientBcc()));
+            }
+            message.setSubject(entity.getSubject(), "UTF-8");
+            String html = entity.getContent() == null ? "" : entity.getContent();
+            message.setContent(html, "text/html;charset=UTF-8");
+            message.saveChanges();
+            MailTransportSupport.send(message);
+
+            entity.setMessageId(message.getMessageID());
+            entity.setStatus(1);
+            entity.setSentTime(new Date());
+            entity.setFailReason(null);
+            this.updateById(entity);
+        } catch (Exception ex) {
+            entity.setStatus(2);
+            entity.setFailReason(ex.getMessage());
+            this.updateById(entity);
+            throw new WebException("营销邮件发送失败: " + ex.getMessage(), ex);
+        }
+    }
+
+    private SmtpPopSettingsEntity decryptAccount(SmtpPopSettingsEntity account) {
+        AccessKeyEncryptor encryptor = AccessKeyEncryptor.getAccessKeyEncryptor("");
+        account.setEmailPassword(encryptor.decrypt(account.getEmailPassword()));
+        return account;
+    }
+
+    private String joinEmails(List<String> emails) {
         if (CollectionUtils.isEmpty(emails)) {
             return null;
         }