Просмотр исходного кода

v2.6.0 (#14)

1、支持多域名
2、支持PostgreSQL数据库
3、更新全部依赖
4、支持垃圾箱邮件删除
jinnrry 1 год назад
Родитель
Сommit
172e5871ec

+ 4 - 1
.github/workflows/unitTest.yml

@@ -26,7 +26,10 @@ jobs:
           MYSQL_DATABASE: pmail
           MYSQL_ROOT_PASSWORD: githubTest
         options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-
+      postgres:
+        image: postgres
+        env:
+          POSTGRESQL_PASSWORD: githubTest
     container:
       image: golang
     env:

+ 4 - 1
Makefile

@@ -53,4 +53,7 @@ test:
 	export setup_port=17888 && cd server && go test -v ./...
 
 test_mysql:
-	export setup_port=17888 && cd server && go test -args "mysql" -v ./...
+	export setup_port=17888 && cd server && go test -args "mysql" -v ./...
+
+test_postgres:
+	export setup_port=17888 && cd server && go test -args "postgres" -v ./...

+ 1 - 1
README.md

@@ -32,7 +32,7 @@ beautiful and cute Logo for this project!
 > service doesn't use the certificate anymore, the smtp protocol still needs the certificate)
 
 * Support pop3, smtp protocol, you can use any mail client you like.
-
+* Support multi-domain, multi-user and complete support for sending and receiving e-mail.
 
 
 # How to run

+ 3 - 0
README_CN.md

@@ -39,6 +39,9 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
 
 只要支持pop3、smtp协议的邮件客户端均可使用
 
+### 6、多域名、多用户支持
+
+支持多域名、多用户且完整支持收发邮件
 
 # 如何部署
 



+ 14 - 10
fe/src/i18n/i18n.js

@@ -19,9 +19,9 @@ var lang = {
     "sender": "Sender",
     "title": "Title",
     "date": "Date",
-    "to": "To:",
-    "cc": "Cc:",
-    "sender_desc": "Only the email prefix is required",
+    "to": "To",
+    "cc": "Cc",
+    "sender_desc": "Sender",
     "to_desc": "Recipient's e-mail address",
     "cc_desc": "Cc's e-mail address",
     "send": "send",
@@ -44,6 +44,7 @@ var lang = {
     "SetDomail": "Set Domain",
     "setDNS": "Set DNS",
     "setSSL": "Set SSL",
+    "dns_root_desc": "Fill in the \"@\" or empty, as determined by your domain name service provider.",
     "setDatabase": "Set Database",
     "setAdminPassword": "Set Password",
     "admin_account": "Administrator Account",
@@ -57,10 +58,13 @@ var lang = {
     "type": "Type",
     "db_select_ph": "please select your database",
     "mysql_dsn": "MySQL DSN",
+    "pg_dsn": "PostgreSQL DSN",
     "sqlite_db_path": "Data File Path",
     "domain_desc": "Set your domain infomation.",
     "smtp_domain": "SMTP Domain",
     "web_domain": "Web Domain",
+    "multi_domain_setting": "Multi-Domain Setting",
+    "multi_domain_setting_desc": "Bind this mailbox to multiple domains.",
     "dns_desc": "Please add the following information to your DNS records",
     "ssl_auto": "Automatically configure SSL certificates (recommended)",
     "wait_desc": "Please Wait.",
@@ -89,8 +93,6 @@ var lang = {
     "rule_do":"Do the following:",
     "from":"From Email Address",
     "subject":"Email Subject",
-    "to":"Recipient's address",
-    "cc":"Cc's address",
     "content":"Email Content",
     "equal":"Equal",
     "regex":"Regex Match",
@@ -127,9 +129,9 @@ var zhCN = {
     "sender": "发件人",
     "title": "主题",
     "date": "时间",
-    "to": "收件人:",
-    "cc": "抄送:",
-    "sender_desc": "只需要邮箱前缀",
+    "to": "收件人",
+    "cc": "抄送",
+    "sender_desc": "发件人",
     "to_desc": "接收人邮件地址",
     "cc_desc": "抄送人邮箱地址",
     "send": "发送",
@@ -150,6 +152,9 @@ var zhCN = {
     "settings": "设置",
     "security": "安全",
     "SetDomail": "域名设置",
+    "dns_root_desc": "填入@或者空,不同域名服务商写法不同",
+    "multi_domain_setting": "多域名设置",
+    "multi_domain_setting_desc": "将此邮箱绑定到多个域名上",
     "setDNS": "DNS设置",
     "setSSL": "SSL设置",
     "setDatabase": "数据库设置",
@@ -165,6 +170,7 @@ var zhCN = {
     "type": "类型",
     "db_select_ph": "请选择你的数据库",
     "mysql_dsn": "MySQL DSN",
+    "pg_dsn": "PostgreSQL DSN",
     "sqlite_db_path": "存储位置",
     "domain_desc": "设置你的域名信息。",
     "smtp_domain": "SMTP域名地址",
@@ -197,8 +203,6 @@ var zhCN = {
     "rule_do":"执行操作:",
     "from":"发件人地址",
     "subject":"邮件主题",
-    "to":"收件人地址",
-    "cc":"抄送地址",
     "content":"邮件内容",
     "equal":"等于",
     "regex":"正则匹配",

+ 24 - 7
fe/src/views/EditerView.vue

@@ -2,7 +2,15 @@
     <div id="main">
         <el-form label-width="100px" :rules="rules" ref="ruleFormRef" :model="ruleForm" status-icon>
             <el-form-item :label="lang.sender" prop="sender">
-                <el-input :disabled="!$userInfos.is_admin" v-model="ruleForm.sender" :placeholder="lang.sender_desc"></el-input>
+
+                <div style="display: flex;">
+                    <el-input style="max-width: 300px" :disabled="!$userInfos.is_admin" v-model="ruleForm.sender" :placeholder="lang.sender_desc" />
+                    <div>@</div>
+                    <el-select v-model="ruleForm.pickDomain">
+                        <el-option :value="item" v-for="item in ruleForm.domains">{{ item }}</el-option>
+                    </el-select>
+                </div>
+
             </el-form-item>
 
 
@@ -84,7 +92,7 @@ import lang from '../i18n/i18n';
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import { i18nChangeLanguage } from '@wangeditor/editor'
 import { useRouter } from 'vue-router';
-const router = useRouter(); 
+const router = useRouter();
 import useGroupStore from '../stores/group'
 const groupStore = useGroupStore()
 import { getCurrentInstance } from 'vue'
@@ -93,9 +101,9 @@ const $http = app.appContext.config.globalProperties.$http
 const $isLogin = app.appContext.config.globalProperties.$isLogin
 const $userInfos = app.appContext.config.globalProperties.$userInfos
 
-if (lang.lang == "zhCn"){
+if (lang.lang == "zhCn") {
     i18nChangeLanguage('zh-CN')
-}else{
+} else {
     i18nChangeLanguage('en')
 }
 
@@ -123,15 +131,20 @@ const ruleForm = reactive({
     receivers: '',
     cc: '',
     subject: '',
+    domains:[],
+    pickDomain:""
 })
 const fileList = reactive([]);
 
 
-const init =function(){
+const init = function () {
     if (Object.keys($userInfos.value).length == 0) {
         $http.post("/api/user/info", {}).then(res => {
             if (res.errorNo == 0) {
                 $userInfos.value = res.data
+                ruleForm.sender = res.data.account
+                ruleForm.domains = res.data.domains
+                ruleForm.pickDomain = res.data.domains[0]
             } else {
                 ElMessage({
                     type: 'error',
@@ -139,10 +152,14 @@ const init =function(){
                 })
             }
         })
+    }else{
+        ruleForm.sender = $userInfos.value.account
+        ruleForm.domains = $userInfos.value.domains
+        ruleForm.pickDomain = $userInfos.value.domains[0]
     }
+
 }
 init()
-ruleForm.sender = $userInfos.value.account
 
 
 const validateSender = function (rule, value, callback) {
@@ -240,7 +257,7 @@ const send = function (formEl) {
             let text = editorRef.value.getText()
 
             $http.post("/api/email/send", {
-                from: { name: ruleForm.sender, email: "" },
+                from: { name: ruleForm.sender, email: ruleForm.sender + "@" +ruleForm.pickDomain },
                 to: objectTos,
                 cc: objectCcs,
                 subject: ruleForm.subject,

+ 21 - 7
fe/src/views/EmailDetailView.vue

@@ -4,13 +4,27 @@
         <el-divider />
 
         <div>
-            <span>{{ lang.to }}:
-                <span class="userItem" v-for="to in tos">{{ to.Name }} {{ to.EmailAddress }} ;</span>
-            </span>
-
-            <span v-if="showCC">{{ lang.cc }}:
-                <span class="userItem" v-for="ccs in cc">{{ cc.Name }} {{ cc.EmailAddress }} ;</span>
-            </span>
+            <div>{{ lang.to }}:
+                <el-tooltip v-for="to in tos" class="box-item" effect="dark" :content="to.EmailAddress" placement="top">
+                    <el-tag size="small" type="info">{{to.Name != '' ? to.Name : to.EmailAddress }}</el-tag>
+                </el-tooltip>
+            </div>
+
+            <div v-if="showCC">{{ lang.cc }}:
+                <el-tooltip v-for="item in ccs" class="box-item" effect="dark" :content="item.EmailAddress" placement="top">
+                    <el-tag size="small" type="info">{{item.Name != '' ? item.Name : item.EmailAddress }}</el-tag>
+                </el-tooltip>
+            </div>
+
+            <div>{{ lang.sender }}:
+                <el-tooltip class="box-item" effect="dark" :content="detailData.from_address" placement="top">
+                    <el-tag size="small" type="info">{{detailData.from_name != '' ? detailData.from_name : detailData.from_address }}</el-tag>
+                </el-tooltip>
+            </div>
+
+            <div>{{ lang.date }}:
+                {{ detailData.send_date }}
+            </div>
         </div>
         <el-divider />
         <div class="content" id="text" v-if="detailData.html == ''">

+ 23 - 20
fe/src/views/ListView.vue

@@ -17,7 +17,7 @@
                 <template #dropdown>
                     <el-dropdown-menu>
                         <el-dropdown-item @click="move(group.id)" v-for="group in groupList">{{ group.name
-                        }}</el-dropdown-item>
+                            }}</el-dropdown-item>
                     </el-dropdown-menu>
                 </template>
             </el-dropdown>
@@ -26,43 +26,43 @@
             <el-table ref="taskTableDataRef" @selection-change="selectionLineChange" :data="data" :show-header="true"
                 :border="false" @row-click="rowClick" :row-style="rowStyle">
                 <el-table-column type="selection" width="30" />
-                <el-table-column prop="title" label="" width="50">
+                <el-table-column prop="is_read" label="" width="50">
                     <template #default="scope">
                         <div>
                             <span v-if="!scope.row.is_read">
                                 {{ lang.new }}
                             </span>
                             <span style="font-weight: 900;color: #FF0000;" v-if="scope.row.dangerous">
-                                <el-tooltip effect="dark" 
-                                :content="lang.dangerous"
-                                    placement="top-start">
+                                <el-tooltip effect="dark" :content="lang.dangerous" placement="top-start">
                                     !
                                 </el-tooltip>
-                                
+
                             </span>
                             <span style="font-weight: 900;color: #FF0000;" v-if="scope.row.error != ''">
-                                <el-tooltip effect="dark" 
-                                :content="scope.row.error"
-                                    placement="top-start">
+                                <el-tooltip effect="dark" :content="scope.row.error" placement="top-start">
                                     !
                                 </el-tooltip>
-                                
+
                             </span>
                         </div>
                     </template>
                 </el-table-column>
                 <el-table-column prop="title" :label="lang.sender" width="150">
                     <template #default="scope">
-                        <span v-if="scope.row.is_read">
-                            <div v-if="scope.row.sender.Name != ''">{{ scope.row.sender.Name }}</div>
-                            {{ scope.row.sender.EmailAddress }}
-                        </span>
-                        <span v-else style="font-weight:bolder;">
-                            <div v-if="scope.row.sender.Name != ''">{{ scope.row.sender.Name }}</div>
-                            {{ scope.row.sender.EmailAddress }}
-                        </span>
+                        <el-tooltip class="box-item" effect="dark" :content="scope.row.sender.EmailAddress" placement="top">
+                            <el-tag size="small" type="info">{{scope.row.sender.Name != '' ? scope.row.sender.Name : scope.row.sender.EmailAddress }}</el-tag>
+                        </el-tooltip>
                     </template>
                 </el-table-column>
+
+                <el-table-column prop="title" :label="lang.to" width="150">
+                    <template #default="scope">
+                        <el-tooltip v-for="toInfo in scope.row.to" class="box-item" effect="dark" :content="toInfo.EmailAddress" placement="top">
+                            <el-tag size="small" type="info">{{toInfo.Name != '' ? toInfo.Name : toInfo.EmailAddress }}</el-tag>
+                        </el-tooltip>
+                    </template>
+                </el-table-column>
+
                 <el-table-column prop="desc" :label="lang.title">
                     <template #default="scope">
                         <div v-if="scope.row.is_read">{{ scope.row.title }}</div>
@@ -103,7 +103,7 @@ const app = getCurrentInstance()
 const $http = app.appContext.config.globalProperties.$http
 
 
-const router = useRouter(); 
+const router = useRouter();
 
 
 const groupStore = useGroupStore()
@@ -222,6 +222,9 @@ const del = function () {
         ids.push(element.id)
     });
 
+    let groupTag = JSON.parse(tag)
+
+
     ElMessageBox.confirm(
         lang.del_email_confirm,
         'Warning',
@@ -232,7 +235,7 @@ const del = function () {
         }
     )
         .then(() => {
-            $http.post("/api/email/del", { "ids": ids }).then(res => {
+            $http.post("/api/email/del", { "ids": ids ,"forcedDel":groupTag.status == 3 }).then(res => {
                 if (res.errorNo == 0) {
                     updateList()
                     ElMessage({

+ 54 - 9
fe/src/views/SetupView.vue

@@ -32,6 +32,7 @@
                             @change="dbSettings.dsn = ''">
                             <el-option label="MySQL" value="mysql" />
                             <el-option label="SQLite3" value="sqlite" />
+                            <el-option label="PostgreSQL" value="postgres" />
                         </el-select>
                     </el-form-item>
 
@@ -40,6 +41,11 @@
                             placeholder="root:12345@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local"></el-input>
                     </el-form-item>
 
+                    <el-form-item :label="lang.pg_dsn" v-if="dbSettings.type == 'postgres'">
+                        <el-input :rows="2" type="textarea" v-model="dbSettings.dsn"
+                            placeholder="postgres://postgres:12345@127.0.0.1:5432/pmail?sslmode=disable"></el-input>
+                    </el-form-item>
+
                     <el-form-item :label="lang.sqlite_db_path" v-if="dbSettings.type == 'sqlite'">
                         <el-input v-model="dbSettings.dsn" placeholder="./config/pmail.db"></el-input>
                     </el-form-item>
@@ -92,6 +98,15 @@
                     <el-form-item :label="lang.web_domain">
                         <el-input placeholder="pmail.domain.com" v-model="domainSettings.web_domain"></el-input>
                     </el-form-item>
+
+                    <el-form-item :label="lang.multi_domain_setting">
+                        <span>{{ lang.multi_domain_setting_desc }} <el-button @click="addDomain" size="small"
+                                type="success" :icon="Plus" circle></el-button></span>
+                        <el-input :placeholder="'domain' + i + '.com'" v-for="(item, i) in domainSettings.multi_domain"
+                            v-model="domainSettings.multi_domain[i]"></el-input>
+                    </el-form-item>
+
+
                 </el-form>
             </div>
         </div>
@@ -102,9 +117,19 @@
                 <h2>{{ lang.setDNS }}</h2>
                 <div style="margin-top: 10px;">{{ lang.dns_desc }}</div>
             </div>
-            <div class="form" width="600px">
-                <el-table :data="dnsInfos" border style="width: 100%">
-                    <el-table-column prop="host" label="HOSTNAME" width="110px" />
+            <div class="form" width="600px" v-for="(info,domain) in dnsInfos">
+                <h3>{{ domain }}</h3>
+                <el-table :data="info" border style="width: 100%">
+                    <el-table-column prop="host" label="HOSTNAME" width="110px" >
+                        <template #default="scope">
+                            <div style="display: flex; align-items: center">
+                                <el-tooltip :content="lang.dns_root_desc" placement="top" v-if="scope.row.host == '' || scope.row.host == '@' ">
+                                    {{ scope.row.host }}
+                                </el-tooltip>
+                                <span v-else>{{ scope.row.host }}</span>
+                            </div>
+                        </template>
+                    </el-table-column>
                     <el-table-column prop="type" label="TYPE" width="110px" />
                     <el-table-column prop="value" label="VALUE">
                         <template #default="scope">
@@ -115,7 +140,6 @@
                                 <span v-else>{{ scope.row.value }}</span>
                             </div>
                         </template>
-
                     </el-table-column>
                     <el-table-column prop="ttl" label="TTL" width="110px" />
                 </el-table>
@@ -205,6 +229,11 @@ import { ElMessage } from 'element-plus'
 import lang from '../i18n/i18n';
 import axios from 'axios'
 import { getCurrentInstance } from 'vue'
+import {
+    Plus
+} from '@element-plus/icons-vue'
+
+
 const app = getCurrentInstance()
 const $http = app.appContext.config.globalProperties.$http
 const waitDesc = ref(lang.wait_desc)
@@ -225,7 +254,8 @@ const dbSettings = reactive({
 
 const domainSettings = reactive({
     "web_domain": "",
-    "smtp_domain": ""
+    "smtp_domain": "",
+    "multi_domain": []
 })
 
 const sslSettings = reactive({
@@ -241,11 +271,15 @@ const active = ref(0)
 const fullscreenLoading = ref(false)
 const dnsChecking = ref(false)
 
-const dnsInfos = ref([
-])
+const dnsInfos = ref({})
 
 const port = ref(80)
 
+
+const addDomain = () => {
+    domainSettings.multi_domain.push([])
+}
+
 const setPassword = () => {
     if (adminSettings.hadSeted) {
         active.value++;
@@ -302,6 +336,7 @@ const getDomainConfig = () => {
         } else {
             domainSettings.web_domain = res.data.web_domain;
             domainSettings.smtp_domain = res.data.smtp_domain;
+            domainSettings.multi_domain = res.data.domains;
         }
     })
 }
@@ -385,7 +420,11 @@ const checkStatus = () => {
                 checkStatus()
             }, 1000);
         } else {
-            window.location.href = "https://" + domainSettings.web_domain;
+            if(sslSettings.type == 1){
+                window.location.href = "http://" + domainSettings.web_domain;
+            }else{
+                window.location.href = "https://" + domainSettings.web_domain;
+            }
         }
     }).catch((error) => {
         setTimeout(function () {
@@ -396,7 +435,13 @@ const checkStatus = () => {
 
 
 const setDomainConfig = () => {
-    $http.post("/api/setup", { "action": "set", "step": "domain", "web_domain": domainSettings.web_domain, "smtp_domain": domainSettings.smtp_domain }).then((res) => {
+    $http.post("/api/setup", {
+        "action": "set",
+        "step": "domain",
+        "web_domain": domainSettings.web_domain,
+        "smtp_domain": domainSettings.smtp_domain,
+        "multi_domain": domainSettings.multi_domain.join(",")
+    }).then((res) => {
         if (res.errorNo != 0) {
             ElMessage.error(res.errorMsg)
         } else {

+ 8 - 8
server/config/config.go

@@ -13,13 +13,12 @@ import (
 var IsInit bool
 
 type Config struct {
-	LogLevel           string   `json:"logLevel"` // 日志级别
-	Domain             string   `json:"domain"`
-	Domains            []string `json:"domains"` //多域名设置,把所有收信域名都填进去
-	WebDomain          string   `json:"webDomain"`
-	DkimPrivateKeyPath string   `json:"dkimPrivateKeyPath"`
-	SSLType            string   `json:"sslType"` // 0表示自动生成证书,HTTP挑战模式,1表示用户上传证书,2表示自动-DNS挑战模式
-	//DomainServiceName    string            `json:"domainServerName"` // 域名服务商名称
+	LogLevel             string            `json:"logLevel"` // 日志级别
+	Domain               string            `json:"domain"`
+	Domains              []string          `json:"domains"` //多域名设置,把所有收信域名都填进去
+	WebDomain            string            `json:"webDomain"`
+	DkimPrivateKeyPath   string            `json:"dkimPrivateKeyPath"`
+	SSLType              string            `json:"sslType"` // 0表示自动生成证书,HTTP挑战模式,1表示用户上传证书,2表示自动-DNS挑战模式
 	SSLPrivateKeyPath    string            `json:"SSLPrivateKeyPath"`
 	SSLPublicKeyPath     string            `json:"SSLPublicKeyPath"`
 	DbDSN                string            `json:"dbDSN"`
@@ -52,11 +51,12 @@ func (c *Config) SetSetupPort(setupPort int) {
 
 const DBTypeMySQL = "mysql"
 const DBTypeSQLite = "sqlite"
+const DBTypePostgres = "postgres"
 const SSLTypeAutoHTTP = "0" //自动生成证书
 const SSLTypeAutoDNS = "2"  //自动生成证书,DNS api验证
 const SSLTypeUser = "1"     //用户上传证书
 
-var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite}
+var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite, DBTypePostgres}
 
 var Instance *Config = &Config{}
 

+ 3 - 2
server/controllers/email/delete.go

@@ -11,7 +11,8 @@ import (
 )
 
 type emailDeleteRequest struct {
-	IDs []int `json:"ids"`
+	IDs       []int64 `json:"ids"`
+	ForcedDel bool    `json:"forcedDel"`
 }
 
 func EmailDelete(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
@@ -30,7 +31,7 @@ func EmailDelete(ctx *context.Context, w http.ResponseWriter, req *http.Request)
 		return
 	}
 
-	err = del_email.DelEmail(ctx, reqData.IDs)
+	err = del_email.DelEmail(ctx, reqData.IDs, reqData.ForcedDel)
 	if err != nil {
 		response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 		return

+ 5 - 0
server/controllers/email/list.go

@@ -26,6 +26,7 @@ type emilItem struct {
 	Datetime  string `json:"datetime"`
 	IsRead    bool   `json:"is_read"`
 	Sender    User   `json:"sender"`
+	To        []User `json:"to"`
 	Dangerous bool   `json:"dangerous"`
 	Error     string `json:"error"`
 }
@@ -76,6 +77,9 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 		var sender User
 		_ = json.Unmarshal([]byte(email.Sender), &sender)
 
+		var tos []User
+		_ = json.Unmarshal([]byte(email.To), &tos)
+
 		lst = append(lst, &emilItem{
 			ID:        email.Id,
 			Title:     email.Subject,
@@ -83,6 +87,7 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 			Datetime:  email.SendDate.Format("2006-01-02 15:04:05"),
 			IsRead:    email.IsRead == 1,
 			Sender:    sender,
+			To:        tos,
 			Dangerous: email.SPFCheck == 0 && email.DKIMCheck == 0,
 			Error:     email.Error.String,
 		})

+ 9 - 0
server/controllers/email/send.go

@@ -16,6 +16,7 @@ import (
 	"pmail/hooks/framework"
 	"pmail/i18n"
 	"pmail/models"
+	"pmail/utils/array"
 	"pmail/utils/async"
 	"pmail/utils/context"
 	"pmail/utils/send"
@@ -69,6 +70,14 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 		return
 	}
 
+	if reqData.From.Email != "" {
+		infos := strings.Split(reqData.From.Email, "@")
+		if len(infos) != 2 || !array.InArray(infos[1], config.Instance.Domains) {
+			response.NewErrorResponse(response.ParamsError, "params error", "").FPrint(w)
+			return
+		}
+	}
+
 	if reqData.From.Email == "" && reqData.From.Name != "" {
 		reqData.From.Email = reqData.From.Name + "@" + config.Instance.Domain
 	}

+ 9 - 6
server/controllers/group.go

@@ -9,6 +9,7 @@ import (
 	"pmail/dto"
 	"pmail/dto/response"
 	"pmail/i18n"
+	"pmail/models"
 	"pmail/services/group"
 	"pmail/utils/array"
 	"pmail/utils/context"
@@ -66,17 +67,19 @@ func AddGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 		log.WithContext(ctx).Errorf("%+v", err)
 	}
 
-	res, err := db.Instance.Exec(db.WithContext(ctx, "insert into `group` (name,parent_id,user_id) values (?,?,?)"), reqData.Name, reqData.ParentId, ctx.UserID)
-	if err != nil {
-		response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w)
-		return
+	var newGroup models.Group = models.Group{
+		Name:     reqData.Name,
+		ParentId: reqData.ParentId,
+		UserId:   ctx.UserID,
 	}
-	id, err := res.LastInsertId()
+
+	_, err = db.Instance.Insert(&newGroup)
 	if err != nil {
 		response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w)
 		return
 	}
-	response.NewSuccessResponse(id).FPrint(w)
+
+	response.NewSuccessResponse(newGroup.ID).FPrint(w)
 }
 
 type delGroupRequest struct {

+ 8 - 0
server/controllers/login.go

@@ -6,11 +6,13 @@ import (
 	log "github.com/sirupsen/logrus"
 	"io"
 	"net/http"
+	"pmail/config"
 	"pmail/db"
 	"pmail/dto/response"
 	"pmail/i18n"
 	"pmail/models"
 	"pmail/session"
+	"pmail/utils/array"
 	"pmail/utils/context"
 	"pmail/utils/errors"
 	"pmail/utils/password"
@@ -44,10 +46,16 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 	if user.ID != 0 {
 		userStr, _ := json.Marshal(user)
 		session.Instance.Put(req.Context(), "user", string(userStr))
+
+		domains := config.Instance.Domains
+		domains = array.Difference(domains, []string{config.Instance.Domain})
+		domains = append([]string{config.Instance.Domain}, domains...)
+
 		response.NewSuccessResponse(map[string]any{
 			"account":  user.Account,
 			"name":     user.Name,
 			"is_admin": user.IsAdmin,
+			"domains":  domains,
 		}).FPrint(w)
 	} else {
 		response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w)

+ 11 - 10
server/controllers/setup.go

@@ -3,6 +3,7 @@ package controllers
 import (
 	"encoding/json"
 	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cast"
 	"io"
 	"net/http"
 	"os"
@@ -41,9 +42,8 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 
 	var reqData map[string]string
 	err = json.Unmarshal(reqBytes, &reqData)
-
 	if err != nil {
-		response.NewSuccessResponse("").FPrint(w)
+		response.NewErrorResponse(response.ServerError, "", err.Error()).FPrint(w)
 		return
 	}
 
@@ -62,7 +62,7 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 	}
 
 	if reqData["step"] == "database" && reqData["action"] == "set" {
-		err := setup.SetDatabaseSettings(ctx, reqData["db_type"], reqData["db_dsn"])
+		err := setup.SetDatabaseSettings(ctx, cast.ToString(reqData["db_type"]), cast.ToString(reqData["db_dsn"]))
 		if err != nil {
 			response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 			return
@@ -83,7 +83,7 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 	}
 
 	if reqData["step"] == "password" && reqData["action"] == "set" {
-		err := setup.SetAdminPassword(ctx, reqData["account"], reqData["password"])
+		err := setup.SetAdminPassword(ctx, cast.ToString(reqData["account"]), cast.ToString(reqData["password"]))
 		if err != nil {
 			response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 			return
@@ -93,20 +93,21 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 	}
 
 	if reqData["step"] == "domain" && reqData["action"] == "get" {
-		smtpDomain, webDomain, err := setup.GetDomainSettings()
+		smtpDomain, webDomain, domains, err := setup.GetDomainSettings()
 		if err != nil {
 			response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 			return
 		}
-		response.NewSuccessResponse(map[string]string{
+		response.NewSuccessResponse(map[string]any{
 			"smtp_domain": smtpDomain,
 			"web_domain":  webDomain,
+			"domains":     domains,
 		}).FPrint(w)
 		return
 	}
 
 	if reqData["step"] == "domain" && reqData["action"] == "set" {
-		err := setup.SetDomainSettings(reqData["smtp_domain"], reqData["web_domain"])
+		err := setup.SetDomainSettings(cast.ToString(reqData["smtp_domain"]), cast.ToString(reqData["web_domain"]), reqData["multi_domain"])
 		if err != nil {
 			response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 			return
@@ -148,20 +149,20 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 			keyPath := reqData["key_path"]
 			crtPath := reqData["crt_path"]
 
-			_, err := os.Stat(keyPath)
+			_, err := os.Stat(cast.ToString(keyPath))
 			if err != nil {
 				response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 				return
 			}
 
-			_, err = os.Stat(crtPath)
+			_, err = os.Stat(cast.ToString(crtPath))
 			if err != nil {
 				response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 				return
 			}
 		}
 
-		err = ssl.SetSSL(reqData["ssl_type"], reqData["key_path"], reqData["crt_path"])
+		err = ssl.SetSSL(cast.ToString(reqData["ssl_type"]), cast.ToString(reqData["key_path"]), cast.ToString(reqData["crt_path"]))
 		if err != nil {
 			response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
 			return

+ 8 - 0
server/controllers/user.go

@@ -7,9 +7,11 @@ import (
 	"io"
 	"math"
 	"net/http"
+	"pmail/config"
 	"pmail/db"
 	"pmail/dto/response"
 	"pmail/models"
+	"pmail/utils/array"
 	"pmail/utils/context"
 	"pmail/utils/password"
 )
@@ -104,10 +106,16 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 }
 
 func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
+
+	domains := config.Instance.Domains
+	domains = array.Difference(domains, []string{config.Instance.Domain})
+	domains = append([]string{config.Instance.Domain}, domains...)
+
 	response.NewSuccessResponse(map[string]any{
 		"account":  ctx.UserAccount,
 		"name":     ctx.UserName,
 		"is_admin": ctx.IsAdmin,
+		"domains":  domains,
 	}).FPrint(w)
 }
 

+ 5 - 0
server/db/init.go

@@ -3,6 +3,7 @@ package db
 import (
 	"fmt"
 	_ "github.com/go-sql-driver/mysql"
+	_ "github.com/lib/pq"
 	log "github.com/sirupsen/logrus"
 	_ "modernc.org/sqlite"
 	"pmail/config"
@@ -27,6 +28,10 @@ func Init(version string) error {
 		Instance, err = xorm.NewEngine("sqlite", dsn)
 		Instance.SetMaxOpenConns(1)
 		Instance.SetMaxIdleConns(1)
+	case "postgres":
+		Instance, err = xorm.NewEngine("postgres", dsn)
+		Instance.SetMaxOpenConns(100)
+		Instance.SetMaxIdleConns(10)
 	default:
 		return errors.New("Database Type Error!")
 	}

+ 13 - 11
server/go.mod

@@ -5,20 +5,22 @@ go 1.22.0
 require (
 	github.com/Jinnrry/gopop v0.0.0-20231113115125-fbdf52ae39ea
 	github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885
+	github.com/alexedwards/scs/postgresstore v0.0.0-20240316134038-7e11d57e8885
 	github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885
 	github.com/alexedwards/scs/v2 v2.8.0
 	github.com/emersion/go-message v0.18.1
 	github.com/emersion/go-msgauth v0.6.8
 	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
-	github.com/emersion/go-smtp v0.21.2
-	github.com/go-acme/lego/v4 v4.17.3
+	github.com/emersion/go-smtp v0.21.3
+	github.com/go-acme/lego/v4 v4.17.4
 	github.com/go-sql-driver/mysql v1.8.1
+	github.com/lib/pq v1.10.9
 	github.com/mileusna/spf v0.9.5
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/cast v1.6.0
-	golang.org/x/crypto v0.24.0
+	golang.org/x/crypto v0.25.0
 	golang.org/x/text v0.16.0
-	modernc.org/sqlite v1.30.0
+	modernc.org/sqlite v1.30.1
 	xorm.io/builder v0.3.13
 	xorm.io/xorm v1.3.9
 )
@@ -27,14 +29,14 @@ require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
-	github.com/go-jose/go-jose/v4 v4.0.2 // indirect
+	github.com/go-jose/go-jose/v4 v4.0.3 // indirect
 	github.com/goccy/go-json v0.10.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/miekg/dns v1.1.59 // indirect
+	github.com/miekg/dns v1.1.61 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -44,13 +46,13 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rogpeppe/go-internal v1.12.0 // indirect
 	github.com/syndtr/goleveldb v1.0.0 // indirect
-	golang.org/x/mod v0.18.0 // indirect
-	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/mod v0.19.0 // indirect
+	golang.org/x/net v0.27.0 // indirect
 	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.21.0 // indirect
-	golang.org/x/tools v0.22.0 // indirect
+	golang.org/x/sys v0.22.0 // indirect
+	golang.org/x/tools v0.23.0 // indirect
 	modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
-	modernc.org/libc v1.52.1 // indirect
+	modernc.org/libc v1.54.4 // indirect
 	modernc.org/mathutil v1.6.0 // indirect
 	modernc.org/memory v1.8.0 // indirect
 	modernc.org/strutil v1.2.0 // indirect

+ 29 - 0
server/go.sum

@@ -6,6 +6,8 @@ github.com/Jinnrry/gopop v0.0.0-20231113115125-fbdf52ae39ea h1:GISNlu8fPa2K+aySm
 github.com/Jinnrry/gopop v0.0.0-20231113115125-fbdf52ae39ea/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
 github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885 h1:C7QAamNjR5yz6di4KJWAKcnxueKBgq4L/JGXhlnu35w=
 github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885/go.mod h1:p8jK3D80sw1PFrCSdlcJF1O75bp55HqbgDyyCLM0FrE=
+github.com/alexedwards/scs/postgresstore v0.0.0-20240316134038-7e11d57e8885 h1:012heQQRqytD5mSoXNzhfoTQaoPj6iRMvKh9DlUScoI=
+github.com/alexedwards/scs/postgresstore v0.0.0-20240316134038-7e11d57e8885/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M=
 github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
 github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
 github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
@@ -26,6 +28,8 @@ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpz
 github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA=
 github.com/emersion/go-smtp v0.21.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
+github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
+github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -35,8 +39,12 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/go-acme/lego/v4 v4.17.3 h1:5our7Qdyik0abag40abdmQuytq97iweaNHFMT4pYDnQ=
 github.com/go-acme/lego/v4 v4.17.3/go.mod h1:Ol6l04hnmavqVHKYS/ByhXXqE64x8yVYhomha82uAUk=
+github.com/go-acme/lego/v4 v4.17.4 h1:h0nePd3ObP6o7kAkndtpTzCw8shOZuWckNYeUQwo36Q=
+github.com/go-acme/lego/v4 v4.17.4/go.mod h1:dU94SvPNqimEeb7EVilGGSnS0nU1O5Exir0pQ4QFL4U=
 github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
 github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
+github.com/go-jose/go-jose/v4 v4.0.3 h1:o8aphO8Hv6RPmH+GfzVuyf7YXSBibp+8YyHdOoDESGo=
+github.com/go-jose/go-jose/v4 v4.0.3/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -72,6 +80,9 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -80,6 +91,8 @@ github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S
 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
 github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
 github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
+github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
+github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
 github.com/mileusna/spf v0.9.5 h1:P6cmaIBwrhZaP9stXMzGOtxe+gIu65OVbZCmrAv9rgU=
 github.com/mileusna/spf v0.9.5/go.mod h1:o6IdTae6QptAbLgx/+ueXSTSpkG+f1cqLemQJSew8sI=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -130,12 +143,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
 golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
 golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -148,6 +165,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
 golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -179,6 +198,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
 golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -198,6 +219,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
 golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
+golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -221,8 +244,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
 modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
 modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo=
 modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
 modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
 modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
 modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
@@ -231,6 +256,8 @@ modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0
 modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
 modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M=
 modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ=
+modernc.org/libc v1.54.4 h1:eDr4WnANZv+aRBKNCDo4khJbaHpxoTNOxeXqpznSZyY=
+modernc.org/libc v1.54.4/go.mod h1:CH8KSvv67UxcGCOLizggw3Zi3yT+sUjLWysK/YeUnqk=
 modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
 modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
 modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -241,6 +268,8 @@ modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
 modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
 modernc.org/sqlite v1.30.0 h1:8YhPUs/HTnlEgErn/jSYQTwHN/ex8CjHHjg+K9iG7LM=
 modernc.org/sqlite v1.30.0/go.mod h1:cgkTARJ9ugeXSNaLBPK3CqbOe7Ec7ZhWPoMFGldEYEw=
+modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
+modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
 modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
 modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
 modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

+ 5 - 0
server/main_test.go

@@ -243,6 +243,7 @@ func testCreateUser(t *testing.T) {
 		t.Error(err)
 	}
 	if data.ErrorNo != 0 {
+		t.Error(data)
 		t.Error("Create User Api Error!")
 	}
 
@@ -311,6 +312,10 @@ func testDataBaseSet(t *testing.T) {
 	if array.InArray("mysql", argList) {
 		configData = `
 {"action":"set","step":"database","db_type":"mysql","db_dsn":"root:githubTest@tcp(mysql:3306)/pmail?parseTime=True"}
+`
+	} else if array.InArray("postgres", argList) {
+		configData = `
+{"action":"set","step":"database","db_type":"postgres","db_dsn":"postgres://postgres:githubTest@127.0.0.1:5432/pmail?sslmode=disable"}
 `
 	}
 

+ 1 - 1
server/pop3_server/action.go

@@ -312,7 +312,7 @@ func (a action) Noop(session *gopop.Session) error {
 func (a action) Quit(session *gopop.Session) error {
 	log.WithContext(session.Ctx).Debugf("POP3 CMD: QUIT ")
 	if len(session.DeleteIds) > 0 {
-		del_email.DelEmailI64(session.Ctx.(*context.Context), session.DeleteIds)
+		del_email.DelEmail(session.Ctx.(*context.Context), session.DeleteIds, false)
 	}
 
 	return nil

+ 32 - 27
server/services/del_email/del_email.go

@@ -1,50 +1,55 @@
 package del_email
 
 import (
-	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cast"
 	"pmail/consts"
 	"pmail/db"
 	"pmail/models"
 	"pmail/utils/context"
+	"xorm.io/xorm"
 )
-import . "xorm.io/builder"
 
-func DelEmail(ctx *context.Context, ids []int) error {
-
-	if len(ids) == 0 {
-		return nil
-	}
-
-	where, params, err := ToSQL(Eq{"user_id": ctx.UserID}.And(Eq{"email_id": ids}))
-
-	if err != nil {
-		log.Errorf("del email err: %v", err)
+func DelEmail(ctx *context.Context, ids []int64, forcedDel bool) error {
+	session := db.Instance.NewSession()
+	defer session.Close()
+	if err := session.Begin(); err != nil {
 		return err
 	}
-
-	_, err = db.Instance.Table(&models.UserEmail{}).Where(where, params...).Update(map[string]interface{}{"status": consts.EmailStatusDel})
-	if err != nil {
-		log.Errorf("del email err: %v", err)
+	for _, id := range ids {
+		err := deleteOne(ctx, session, cast.ToInt64(id), forcedDel)
+		if err != nil {
+			session.Rollback()
+			return err
+		}
 	}
-	return err
+	return session.Commit()
 }
 
-func DelEmailI64(ctx *context.Context, ids []int64) error {
+type num struct {
+	Num int `xorm:"num"`
+}
 
-	if len(ids) == 0 {
-		return nil
+func deleteOne(ctx *context.Context, session *xorm.Session, id int64, forcedDel bool) error {
+	if !forcedDel {
+		_, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Update(map[string]interface{}{"status": consts.EmailStatusDel})
+		return err
 	}
-
-	where, params, err := ToSQL(Eq{"user_id": ctx.UserID}.And(Eq{"email_id": ids}))
-
+	// 先删除关联关系
+	var ue models.UserEmail
+	_, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Delete(&ue)
 	if err != nil {
-		log.Errorf("del email err: %v", err)
 		return err
 	}
-
-	_, err = db.Instance.Table(&models.UserEmail{}).Where(where, params...).Update(map[string]interface{}{"status": consts.EmailStatusDel})
+	// 检查email是否还有人有权限
+	var Num num
+	_, err = session.Table(&models.UserEmail{}).Select("count(1) as num").Where("email_id=? ", id).Get(&Num)
 	if err != nil {
-		log.Errorf("del email err: %v", err)
+		return err
+	}
+	if Num.Num == 0 {
+		var email models.Email
+		_, err = session.Table(&email).Where("id=?", id).Delete(&email)
+
 	}
 	return err
 }

+ 1 - 1
server/services/list/list.go

@@ -72,7 +72,7 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str
 	sql += " order by e.id desc"
 
 	if limit < 10000 {
-		sql += fmt.Sprintf(" limit %d,%d ", offset, limit)
+		sql += fmt.Sprintf(" LIMIT %d OFFSET %d ", limit, offset)
 	}
 
 	return sql, sqlParams

+ 0 - 53
server/services/list/list_test.go

@@ -1,54 +1 @@
 package list
-
-import (
-	"pmail/dto"
-	"pmail/utils/context"
-	"reflect"
-	"testing"
-)
-
-func Test_genSQL(t *testing.T) {
-	type args struct {
-		ctx      *context.Context
-		count    bool
-		tagInfo  dto.SearchTag
-		keyword  string
-		pop3List bool
-		offset   int
-		limit    int
-	}
-	tests := []struct {
-		name  string
-		args  args
-		want  string
-		want1 []any
-	}{
-		{
-			name: "Group搜索",
-			args: args{
-				ctx: &context.Context{
-					UserID: 1,
-				},
-				count:    false,
-				tagInfo:  dto.SearchTag{-1, -1, 2},
-				keyword:  "",
-				pop3List: false,
-				offset:   0,
-				limit:    0,
-			},
-			want:  "select e.*,ue.is_read from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ?  and ue.status != 3 and ue.group_id=?  order by e.id desc limit 0,10 ",
-			want1: []any{1, 2},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, got1 := genSQL(tt.args.ctx, tt.args.count, tt.args.tagInfo, tt.args.keyword, tt.args.pop3List, tt.args.offset, tt.args.limit)
-			if got != tt.want {
-				t.Errorf("genSQL() got = \n%v, want \n%v", got, tt.want)
-			}
-			if !reflect.DeepEqual(got1, tt.want1) {
-				t.Errorf("genSQL() got1 = \n%v, want \n%v", got1, tt.want1)
-			}
-		})
-	}
-}

+ 8 - 1
server/services/setup/db.go

@@ -43,7 +43,14 @@ func GetAdminPassword(ctx *context.Context) (string, error) {
 
 func SetAdminPassword(ctx *context.Context, account, pwd string) error {
 	encodePwd := password.Encode(pwd)
-	_, err := db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user (account, name, password,is_admin) VALUES (?, 'admin',?,1)"), account, encodePwd)
+	var user models.User = models.User{
+		Account:  account,
+		Name:     "admin",
+		Password: encodePwd,
+		IsAdmin:  1,
+	}
+
+	_, err := db.Instance.Insert(&user)
 	if err != nil {
 		return errors.Wrap(err)
 	}

+ 13 - 8
server/services/setup/dns.go

@@ -17,19 +17,24 @@ type DNSItem struct {
 	Tips  string `json:"tips"`
 }
 
-func GetDNSSettings(ctx *context.Context) ([]*DNSItem, error) {
+func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
 	configData, err := ReadConfig()
 	if err != nil {
 		return nil, errors.Wrap(err)
 	}
 
-	ret := []*DNSItem{
-		{Type: "A", Host: "smtp", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
-		{Type: "A", Host: "pop", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
-		{Type: "A", Host: "", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
-		{Type: "MX", Host: "", Value: fmt.Sprintf("smtp.%s", configData.Domain), TTL: 3600},
-		{Type: "TXT", Host: "", Value: "v=spf1 a mx ~all", TTL: 3600},
-		{Type: "TXT", Host: "default._domainkey", Value: auth.DkimGen(), TTL: 3600},
+	ret := make(map[string][]*DNSItem)
+
+	for _, domain := range configData.Domains {
+		ret[domain] = []*DNSItem{
+			{Type: "A", Host: "smtp", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
+			{Type: "A", Host: "pop", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
+			{Type: "A", Host: "@", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
+			{Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s", domain), TTL: 3600},
+			{Type: "TXT", Host: "@", Value: "v=spf1 a mx ~all", TTL: 3600},
+			{Type: "TXT", Host: "default._domainkey", Value: auth.DkimGen(), TTL: 3600},
+		}
 	}
+
 	return ret, nil
 }

+ 17 - 4
server/services/setup/domain.go

@@ -1,19 +1,21 @@
 package setup
 
 import (
+	"pmail/utils/array"
 	"pmail/utils/errors"
+	"strings"
 )
 
-func GetDomainSettings() (string, string, error) {
+func GetDomainSettings() (string, string, []string, error) {
 	configData, err := ReadConfig()
 	if err != nil {
-		return "", "", errors.Wrap(err)
+		return "", "", []string{}, errors.Wrap(err)
 	}
 
-	return configData.Domain, configData.WebDomain, nil
+	return configData.Domain, configData.WebDomain, array.Difference(configData.Domains, []string{configData.Domain}), nil
 }
 
-func SetDomainSettings(smtpDomain, webDomain string) error {
+func SetDomainSettings(smtpDomain, webDomain, multiDomains string) error {
 	configData, err := ReadConfig()
 	if err != nil {
 		return errors.Wrap(err)
@@ -27,6 +29,17 @@ func SetDomainSettings(smtpDomain, webDomain string) error {
 		return errors.New("web domain must not empty!")
 	}
 
+	configData.Domains = []string{}
+
+	if multiDomains != "" {
+		domains := strings.Split(multiDomains, ",")
+		configData.Domains = domains
+	}
+
+	if !array.InArray(smtpDomain, configData.Domains) {
+		configData.Domains = append(configData.Domains, smtpDomain)
+	}
+
 	configData.Domain = smtpDomain
 	configData.WebDomain = webDomain
 

+ 14 - 2
server/services/setup/ssl/ssl.go

@@ -102,8 +102,14 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error {
 
 	myUser.Registration = reg
 
+	domains := []string{cfg.WebDomain}
+	for _, domain := range cfg.Domains {
+		domains = append(domains, "smtp."+domain)
+		domains = append(domains, "pop."+domain)
+	}
+
 	request := certificate.ObtainRequest{
-		Domains: []string{"smtp." + cfg.Domain, cfg.WebDomain, "pop." + cfg.Domain},
+		Domains: domains,
 		Bundle:  true,
 	}
 
@@ -175,8 +181,14 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc
 
 	myUser.Registration = reg
 
+	domains := []string{cfg.WebDomain}
+	for _, domain := range cfg.Domains {
+		domains = append(domains, "smtp."+domain)
+		domains = append(domains, "pop."+domain)
+	}
+
 	request := certificate.ObtainRequest{
-		Domains: []string{"smtp." + cfg.Domain, cfg.WebDomain, "pop." + cfg.Domain},
+		Domains: domains,
 		Bundle:  true,
 	}
 

+ 10 - 2
server/session/init.go

@@ -2,6 +2,7 @@ package session
 
 import (
 	"github.com/alexedwards/scs/mysqlstore"
+	"github.com/alexedwards/scs/postgresstore"
 	"github.com/alexedwards/scs/sqlite3store"
 	"github.com/alexedwards/scs/v2"
 	"pmail/config"
@@ -17,9 +18,16 @@ func Init() {
 	Instance.Lifetime = 7 * 24 * time.Hour
 	// 使用db存储session数据,目前为了架构简单,
 	// 暂不引入redis存储,如果日后性能存在瓶颈,可以将session迁移到redis
-	if config.Instance.DbType == "mysql" {
+
+	switch config.Instance.DbType {
+	case config.DBTypeMySQL:
 		Instance.Store = mysqlstore.New(db.Instance.DB().DB)
-	} else {
+	case config.DBTypeSQLite:
 		Instance.Store = sqlite3store.New(db.Instance.DB().DB)
+	case config.DBTypePostgres:
+		Instance.Store = postgresstore.New(db.Instance.DB().DB)
+	default:
+		panic("Unsupported database type: " + config.Instance.DbType)
 	}
+
 }

+ 13 - 10
server/utils/send/send.go

@@ -23,6 +23,7 @@ type mxDomain struct {
 
 // Forward 转发邮件
 func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) error {
+	_, fromDomain := e.From.GetDomainAccount()
 
 	log.WithContext(ctx).Debugf("开始转发邮件")
 	b := e.ForwardBuildBytes(ctx, forwardAddress)
@@ -75,16 +76,16 @@ func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) er
 		domain := domain
 		tos := tos
 		as.WaitProcess(func(p any) {
-			err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+			err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 
 			// 使用其他方式发送
 			if err != nil {
 				// EOF 表示未知错误,此时降级为非tls连接发送(目前仅139邮箱有这个问题)
 				if errors.Is(err, smtp.NoSupportSTARTTLSError) || err.Error() == "EOF" {
-					err = smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
+					err = smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					if err != nil {
 						log.WithContext(ctx).Warnf("Unsafe! %s Server Not Support SMTPS & STARTTLS", domain.domain)
-						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					}
 				}
 
@@ -92,12 +93,12 @@ func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) er
 				if certificateErr, ok := err.(*tls.CertificateVerificationError); ok {
 					// 单测使用
 					if domain.domain == "localhost" {
-						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					} else if hostnameErr, is := certificateErr.Err.(x509.HostnameError); is {
 						if hostnameErr.Certificate != nil {
 							certificateHostName := hostnameErr.Certificate.DNSNames
 							// 重新选取证书发送
-							err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+							err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 						}
 					}
 				}
@@ -121,6 +122,8 @@ func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) er
 
 func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
 
+	_, fromDomain := e.From.GetDomainAccount()
+
 	b := e.BuildBytes(ctx, true)
 
 	log.WithContext(ctx).Debugf("Message Infos : %s", string(b))
@@ -174,16 +177,16 @@ func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
 		tos := tos
 		as.WaitProcess(func(p any) {
 
-			err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+			err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 
 			// 使用其他方式发送
 			if err != nil {
 				// EOF 表示未知错误,此时降级为非tls连接发送(目前仅139邮箱有这个问题)
 				if errors.Is(err, smtp.NoSupportSTARTTLSError) || err.Error() == "EOF" {
-					err = smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
+					err = smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					if err != nil {
 						log.WithContext(ctx).Warnf("Unsafe! %s Server Not Support SMTPS & STARTTLS", domain.domain)
-						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					}
 				}
 
@@ -191,12 +194,12 @@ func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
 				if certificateErr, ok := err.(*tls.CertificateVerificationError); ok {
 					// 单测使用
 					if domain.domain == "localhost" {
-						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+						err = smtp.SendMailUnsafe("", domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 					} else if hostnameErr, is := certificateErr.Err.(x509.HostnameError); is {
 						if hostnameErr.Certificate != nil {
 							certificateHostName := hostnameErr.Certificate.DNSNames
 							// 重新选取证书发送
-							err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
+							err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, fromDomain, buildAddress(tos), b)
 						}
 					}
 				}

+ 17 - 13
server/utils/smtp/smtp.go

@@ -16,7 +16,7 @@
 //
 //	https://godoc.org/?q=smtp
 //
-// 在go原始SMTP协议的基础上修复了TLS验证错误、支持了SMTPS协议
+// 在go原始SMTP协议的基础上修复了TLS验证错误、支持了SMTPS协议、 支持自定义HELLO命令的域名信息
 package smtp
 
 import (
@@ -58,17 +58,17 @@ type Client struct {
 
 // Dial returns a new Client connected to an SMTP server at addr.
 // The addr must include a port, as in "mail.example.com:smtp".
-func Dial(addr string) (*Client, error) {
+func Dial(addr, fromDomain string) (*Client, error) {
 	conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
 	if err != nil {
 		return nil, err
 	}
 	host, _, _ := net.SplitHostPort(addr)
-	return NewClient(conn, host)
+	return NewClient(conn, host, fromDomain)
 }
 
 // with tls
-func DialTls(addr, domain string) (*Client, error) {
+func DialTls(addr, domain, fromDomain string) (*Client, error) {
 	// TLS config
 	tlsconfig := &tls.Config{
 		InsecureSkipVerify: true,
@@ -80,20 +80,24 @@ func DialTls(addr, domain string) (*Client, error) {
 		return nil, err
 	}
 	host, _, _ := net.SplitHostPort(addr)
-	return NewClient(conn, host)
+	return NewClient(conn, host, fromDomain)
 }
 
 // NewClient returns a new Client using an existing connection and host as a
 // server name to be used when authenticating.
-func NewClient(conn net.Conn, host string) (*Client, error) {
+func NewClient(conn net.Conn, host, fromDomain string) (*Client, error) {
 	text := textproto.NewConn(conn)
 	_, _, err := text.ReadResponse(220)
 	if err != nil {
 		text.Close()
 		return nil, err
 	}
+
 	localName := "domain.com"
-	if config.Instance != nil && config.Instance.Domain != "" {
+
+	if fromDomain != "" {
+		localName = fromDomain
+	} else if config.Instance != nil && config.Instance.Domain != "" {
 		localName = config.Instance.Domain
 	}
 
@@ -333,7 +337,7 @@ func (c *Client) Data() (io.WriteCloser, error) {
 	return &dataCloser{c, c.Text.DotWriter()}, nil
 }
 
-func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, to []string, msg []byte) error {
+func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error {
 	if err := validateLine(from); err != nil {
 		return err
 	}
@@ -342,7 +346,7 @@ func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, to []
 			return err
 		}
 	}
-	c, err := DialTls(addr, domain)
+	c, err := DialTls(addr, domain, fromDomain)
 	if err != nil {
 		return err
 	}
@@ -402,7 +406,7 @@ func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, to []
 // functionality. Higher-level packages exist outside of the standard
 // library.
 // 修复TSL验证问题
-func SendMail(domain string, addr string, a smtp.Auth, from string, to []string, msg []byte) error {
+func SendMail(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error {
 	if err := validateLine(from); err != nil {
 		return err
 	}
@@ -411,7 +415,7 @@ func SendMail(domain string, addr string, a smtp.Auth, from string, to []string,
 			return err
 		}
 	}
-	c, err := Dial(addr)
+	c, err := Dial(addr, fromDomain)
 	if err != nil {
 		return err
 	}
@@ -465,7 +469,7 @@ func SendMail(domain string, addr string, a smtp.Auth, from string, to []string,
 }
 
 // SendMailUnsafe 无TLS加密的邮件发送方式
-func SendMailUnsafe(domain string, addr string, a smtp.Auth, from string, to []string, msg []byte) error {
+func SendMailUnsafe(domain string, addr string, a smtp.Auth, from string, fromDomain string, to []string, msg []byte) error {
 	if err := validateLine(from); err != nil {
 		return err
 	}
@@ -474,7 +478,7 @@ func SendMailUnsafe(domain string, addr string, a smtp.Auth, from string, to []s
 			return err
 		}
 	}
-	c, err := Dial(addr)
+	c, err := Dial(addr, fromDomain)
 	if err != nil {
 		return err
 	}