mirror of
				https://github.com/Jinnrry/PMail.git
				synced 2025-10-31 20:02:36 +08:00 
			
		
		
		
	first commit
This commit is contained in:
		
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| .idea | ||||
| .DS_Store | ||||
| dist | ||||
| output | ||||
							
								
								
									
										36
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| FROM node:lts-alpine as febuild | ||||
| WORKDIR /work | ||||
|  | ||||
| COPY fe . | ||||
|  | ||||
| RUN yarn && yarn build | ||||
|  | ||||
|  | ||||
| FROM golang:alpine as serverbuild | ||||
|  | ||||
| WORKDIR /work | ||||
|  | ||||
| COPY server . | ||||
| COPY --from=febuild /work/dist /work/http_server/dist | ||||
|  | ||||
| RUN apk update && apk add git | ||||
| RUN go build -ldflags "-X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail main.go | ||||
|  | ||||
|  | ||||
| FROM alpine | ||||
|  | ||||
| WORKDIR /work | ||||
|  | ||||
| # 设置时区 | ||||
| RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories | ||||
| RUN apk add --no-cache tzdata \ | ||||
|     && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ | ||||
|     && echo "Asia/Shanghai" > /etc/timezone \ | ||||
|     &&rm -rf /var/cache/apk/* /tmp/* /var/tmp/* $HOME/.cache | ||||
|  | ||||
|  | ||||
| COPY --from=serverbuild /work/pmail . | ||||
| COPY server/config/dkim ./config/dkim/ | ||||
| COPY server/config/config.json ./config/ | ||||
|  | ||||
| CMD /work/pmail | ||||
							
								
								
									
										110
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| # PMail  | ||||
|  | ||||
| > The current code is not stable, be sure to record the log! Lost letters or letters parsed wrong can find out the original content of the mail from the log! | ||||
|  | ||||
| ## [中文文档](./README_CN.md) | ||||
|  | ||||
| ## Introduction | ||||
|  | ||||
| An extremely lightweight mailbox server designed for personal use scenarios.  | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| * Single file operation and easy deployment. | ||||
|  | ||||
| * The binary file is only 15MB and takes up less than 10M of memory during the run. | ||||
|  | ||||
| * Support dkim, spf checksum, [Email Test](https://www.mail-tester.com/) score 10 points if correctly configured. | ||||
|  | ||||
| ## Disadvantages | ||||
|  | ||||
| * At present, only the core function of sending and receiving emails has been completed. Basically, it can only be used by a single person, and does not deal with issues related to permission management in the process of multiple users. | ||||
|  | ||||
| * The UI is ugly | ||||
|  | ||||
| # How to run | ||||
|  | ||||
| ## 1、Generate DKIM secret key | ||||
|  | ||||
| Generate public and private keys by the dkim-keygen tool of the [go-msgauth](https://github.com/emersion/go-msgauth) project | ||||
|  | ||||
| Put the key in the `config/dkim` directory. | ||||
|  | ||||
| ## 2、Set DNS | ||||
|  | ||||
| Add the following records to your domain DNS settings | ||||
|  | ||||
| | type | hostname             | address / value      | | ||||
| |------|----------------------|----------------------| | ||||
| | A    | smtp                 | server ip            | | ||||
| | MX   | _                    | smtp.YourDomain      | | ||||
| | TXT  | _                    | v=spf1 a mx ~all     | | ||||
| | TXT  | default._domainkey	  | Your DKIM public key | | ||||
|  | ||||
| ## 3、Domain SSL Key | ||||
|  | ||||
| Prepare the certificate of `smtp.YourDomain`, the private key in ".key" format and the public key in ".crt" format | ||||
|  | ||||
| Put the certificate in the `config/ssl` directory. | ||||
|  | ||||
| ## 4、Build(or download) | ||||
|  | ||||
| 1、installed `nodejs` and `yarn` | ||||
|  | ||||
| 2、installed `golang` | ||||
|  | ||||
| 3、exec `./build.sh` | ||||
|  | ||||
| ## 5、Config | ||||
|  | ||||
| Modify the `config.json` file in the config directory and fill in your secret key and domain information. | ||||
|  | ||||
| Tips: | ||||
|  | ||||
| MySQL database name must is `pmail`, and charset must is `utf8_general_ci`. | ||||
|  | ||||
| Configuration file description : | ||||
| ```json | ||||
| { | ||||
|   "domain": "demo.com", // Your domain | ||||
|   "dkimPrivateKeyPath": "config/dkim/dkim.priv",  // dkim private key | ||||
|   "SSLPrivateKeyPath": "config/ssl/private.key",  // ssl private key | ||||
|   "SSLPublicKeyPath": "config/ssl/public.crt",    // ssl public key | ||||
|   "mysqlDSN": "username:password@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local", // mysql connect infonation | ||||
|   "weChatPushAppId": "",  // WeChat public account appid (for new email message push) . If you don't need it, you can make it empty. | ||||
|   "weChatPushSecret": "",   // WeChat api secret | ||||
|   "weChatPushTemplateId": "",  // push template id | ||||
|   "weChatPushUserId": "" // wechat user id | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 6、Run | ||||
|  | ||||
| exec `pmail` and check port of 25、80. | ||||
|  | ||||
| The webmail service address is http://yourip. Default account is `admin` and password is `admin` | ||||
|  | ||||
| ## 7、Email Test | ||||
|  | ||||
| Check if your mailbox has completed all the security configuration. It is recommended to use [https://www.mail-tester.com/](https://www.mail-tester.com/) for checking.  | ||||
|  | ||||
|  | ||||
| # For Developer | ||||
|  | ||||
| ## Project Framework | ||||
|  | ||||
| 1、 FE: vue3+element-plus | ||||
|  | ||||
| The code is in `fe` folder. | ||||
|  | ||||
| 2、Server: golang + mysql | ||||
|  | ||||
| The code is in `server` folder. | ||||
|  | ||||
| ## Plugin Development | ||||
|  | ||||
| Reference this file. `server/hooks/wechat_push/wechat_push.go` | ||||
|  | ||||
| # What's More | ||||
|  | ||||
| Welcome PR! Welcome Issues! The project need a Logo ! | ||||
							
								
								
									
										125
									
								
								README_CN.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								README_CN.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| # PMail  | ||||
|  | ||||
| > Welcome PR! Welcome Issues! 目前代码并不稳定,一定记录好日志!丢信或者信件解析错误可以从日志中找出邮件原始内容! | ||||
|  | ||||
| ## 为什么写这个项目 | ||||
|  | ||||
| 迫于越来越多的邮件服务商暂停了针对个人的域名邮箱服务(比如QQ邮箱、微软Outlook邮箱),因此考虑自建域名邮箱服务。 | ||||
| 但是自建域名邮箱可选的程序并不多,且目标都不是针对个人使用场景设计的。个人服务器一般内存、CPU、硬盘配置都不高,针对公司场景使用的邮箱程序过于臃肿, | ||||
| 白白浪费资源。就拿我自己的服务器来说,我服务器配置为1核512M 10G硬盘,市面上绝大多数邮箱服务器安装上就把磁盘占满了,根本没法正常使用 | ||||
|  | ||||
| ## 项目优势 | ||||
|  | ||||
| ### 1、部署简单 | ||||
|  | ||||
| 使用Go语言编写,支持跨平台,编译后单文件运行,单文件包含完整的前后端代码。修改配置文件,运行即可。 | ||||
|  | ||||
| ### 2、资源占用极小 | ||||
|  | ||||
| 编译后二进制文件仅15MB,运行过程中占用内存10M以内。 | ||||
|  | ||||
| ### 3、安全方面 | ||||
|  | ||||
| 支持dkim、spf校验。正确配置的情况下,Email Test得分10分。 | ||||
|  | ||||
| ## 其他 | ||||
|  | ||||
| ### 不足 | ||||
|  | ||||
| 1、目前只完成了最核心的收发邮件功能。基本上仅针对单人使用,没有处理多人使用、权限管理相关问题。 | ||||
|  | ||||
| 2、目前代码并不稳定,可能存在BUG | ||||
|  | ||||
| 3、前端UI交互较差 | ||||
|  | ||||
|  | ||||
| # 如何部署 | ||||
|  | ||||
| ## 1、生成DKIM 秘钥 | ||||
|  | ||||
| 使用[go-msgauth](https://github.com/emersion/go-msgauth)项目的dkim-keygen工具生成公钥和私钥 | ||||
|  | ||||
| 生成以后将密钥放到`config/dkim`目录中 | ||||
|  | ||||
| ## 2、设置域名DNS | ||||
|  | ||||
| 添加以下记录到你到域名解析中 | ||||
|  | ||||
| | 类型  | 主机记录                | 记录值              | | ||||
| |-----|---------------------|------------------| | ||||
| | A   | smtp                | 服务器IP            | | ||||
| | MX  | _                   | smtp.你的域名        | | ||||
| | TXT | _                   | v=spf1 a mx ~all | | ||||
| | TXT | default._domainkey	 | 你生成的DKIM公钥       | | ||||
|  | ||||
| ## 3、申请域名证书 | ||||
|  | ||||
| 准备好 `smtp.你的域名` 的证书,key格式的私钥和crt格式的公钥 | ||||
|  | ||||
| 放到`config/ssl`目录中 | ||||
|  | ||||
| ## 4、编译程序(或者直接下载编译好的二进制文件) | ||||
|  | ||||
| 1、前端环境:安装好node环境,配置好yarn | ||||
|  | ||||
| 2、后端环境:安装最新的golang | ||||
|  | ||||
| 3、执行`./build.sh` | ||||
|  | ||||
| ## 5、修改配置文件 | ||||
|  | ||||
| 修改config目录中的`config.json`文件,填入你的秘钥与域名信息 | ||||
|  | ||||
| Tips: | ||||
|  | ||||
| MySQL库名必须叫pmail,另外,数据库必须使用utf8_general_ci字符集 | ||||
|  | ||||
| 配置文件说明: | ||||
| ```json | ||||
| { | ||||
|   "domain": "demo.com", // 你的域名 | ||||
|   "dkimPrivateKeyPath": "config/dkim/dkim.priv",  // dkim私钥 | ||||
|   "SSLPrivateKeyPath": "config/ssl/private.key",  // ssl证书私钥 | ||||
|   "SSLPublicKeyPath": "config/ssl/public.crt",    // ssl证书公钥 | ||||
|   "mysqlDSN": "username:password@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local", // mysql连接信息 | ||||
|   "weChatPushAppId": "",  //微信公众号id(用于新消息提醒),没有留空即可 | ||||
|   "weChatPushSecret": "",   // 微信公众号api秘钥 | ||||
|   "weChatPushTemplateId": "",  // 微信公众号推送模板id | ||||
|   "weChatPushUserId": "" // 微信推送用户id | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 6、启动 | ||||
|  | ||||
| 运行`PMail`程序,检查服务器25、80端口正常即可 | ||||
|  | ||||
| 邮箱后台, http://yourip,默认账号admin,默认密码admin | ||||
|  | ||||
| ## 7、邮箱得分测试 | ||||
|  | ||||
| 建议找一下邮箱测试服务(比如[https://www.mail-tester.com/](https://www.mail-tester.com/))进行邮件得分检测,避免自己某些步骤漏配,导致发件进对方垃圾箱。 | ||||
|  | ||||
| ## 8、其他说明 | ||||
|  | ||||
| 邮件是否进对方垃圾箱与程序无关、与你的服务器IP、服务器域名有关。我自己搭建的服务,测试了收发QQ、Gmail、Outlook、163、126均正常,无任何拦截,且不会进垃圾箱。 | ||||
|  | ||||
|  | ||||
| # 参与开发 | ||||
|  | ||||
| ## 项目架构 | ||||
|  | ||||
| 1、前端: vue3+element-plus | ||||
|  | ||||
| 前端代码位于`fe`目录中,运行参考`fe`目录中的README文件 | ||||
|  | ||||
| 2、后端: golang + mysql | ||||
|  | ||||
| 后端代码进入`server`文件夹,运行`main.go`文件 | ||||
|  | ||||
| ## 插件开发 | ||||
|  | ||||
| 参考微信推送插件`server/hooks/wechat_push/wechat_push.go` | ||||
|  | ||||
| # 最后 | ||||
|  | ||||
| 欢迎PR! 欢迎Issue!求个Logo! | ||||
							
								
								
									
										29
									
								
								build.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								build.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| # 编译前端代码 | ||||
| cd fe && yarn && yarn build | ||||
|  | ||||
| # 编译后端代码 | ||||
| cd ../server && cp -rf ../fe/dist http_server | ||||
|  | ||||
| CGO_ENABLED=0 GOOS=linux GOARCH=amd64 | ||||
| go build -ldflags "-X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_linux_amd64  main.go | ||||
|  | ||||
| CGO_ENABLED=0 GOOS=windows GOARCH=amd64 | ||||
| go build -ldflags "-X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_windows_amd64  main.go | ||||
|  | ||||
| CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 | ||||
| go build -ldflags "-X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_mac_amd64  main.go | ||||
|  | ||||
| CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 | ||||
| go build -ldflags "-X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_mac_arm64  main.go | ||||
|  | ||||
| # 整理输出文件 | ||||
| cd .. | ||||
| rm -rf output | ||||
| mkdir output | ||||
| cd output | ||||
| mv ../server/pmail* . | ||||
| mkdir config | ||||
| cp -r ../server/config/dkim config/ | ||||
| cp -r ../server/config/ssl config/ | ||||
| cp -r ../server/config/config.json config/ | ||||
| cp ../README.md . | ||||
							
								
								
									
										11
									
								
								fe/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								fe/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| /* eslint-env node */ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   'extends': [ | ||||
|     'plugin:vue/vue3-essential', | ||||
|     'eslint:recommended' | ||||
|   ], | ||||
|   parserOptions: { | ||||
|     ecmaVersion: 'latest' | ||||
|   } | ||||
| } | ||||
							
								
								
									
										28
									
								
								fe/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								fe/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| node_modules | ||||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| coverage | ||||
| *.local | ||||
|  | ||||
| /cypress/videos/ | ||||
| /cypress/screenshots/ | ||||
|  | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
							
								
								
									
										3
									
								
								fe/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								fe/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|   "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] | ||||
| } | ||||
							
								
								
									
										25
									
								
								fe/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								fe/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| # fe | ||||
|  | ||||
| 前端代码库 | ||||
|  | ||||
| ```sh | ||||
| yarn | ||||
| ``` | ||||
|  | ||||
| ### Compile and Hot-Reload for Development | ||||
|  | ||||
| ```sh | ||||
| yarn dev | ||||
| ``` | ||||
|  | ||||
| ### Compile and Minify for Production | ||||
|  | ||||
| ```sh | ||||
| yarn build | ||||
| ``` | ||||
|  | ||||
| ### Lint with [ESLint](https://eslint.org/) | ||||
|  | ||||
| ```sh | ||||
| yarn lint | ||||
| ``` | ||||
							
								
								
									
										13
									
								
								fe/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								fe/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <link rel="icon" href="/favicon.ico"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>PMail</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.js"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										2505
									
								
								fe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2505
									
								
								fe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										28
									
								
								fe/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								fe/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| { | ||||
|   "name": "fe", | ||||
|   "version": "0.0.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "preview": "vite preview", | ||||
|     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@wangeditor/editor": "^5.1.23", | ||||
|     "@wangeditor/editor-for-vue": "^5.1.12", | ||||
|     "axios": "^1.4.0", | ||||
|     "element-plus": "^2.3.6", | ||||
|     "pinia": "^2.0.36", | ||||
|     "vue": "^3.3.2", | ||||
|     "vue-router": "^4.2.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@vitejs/plugin-vue": "^4.2.3", | ||||
|     "eslint": "^8.39.0", | ||||
|     "eslint-plugin-vue": "^9.11.0", | ||||
|     "unplugin-auto-import": "^0.16.4", | ||||
|     "unplugin-vue-components": "^0.25.0", | ||||
|     "vite": "^4.3.5" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								fe/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								fe/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										55
									
								
								fe/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								fe/src/App.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| <script setup> | ||||
| import { RouterView } from 'vue-router' | ||||
| import HomeHeader from '@/components/HomeHeader.vue' | ||||
| import HomeAside from '@/components/HomeAside.vue'; | ||||
| import { watch,ref } from 'vue' | ||||
| import { useRoute, useRouter } from 'vue-router' | ||||
| const route = useRoute() | ||||
|  | ||||
| const pageName = ref(route.name)  | ||||
|  | ||||
| watch( | ||||
|   () => route.fullPath, | ||||
|   (n, o) => { | ||||
|     pageName.value = route.name | ||||
|   } | ||||
| ) | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div id="main"> | ||||
|     <HomeHeader /> | ||||
|     <div id="content"> | ||||
|       <div id="aside" v-if="pageName != 'login'"> | ||||
|         <HomeAside /> | ||||
|       </div> | ||||
|       <div id="body"> | ||||
|         <RouterView /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <style scoped> | ||||
| #aside { | ||||
|   background-color: #F1F1F1; | ||||
| } | ||||
|  | ||||
| #body { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| #content { | ||||
|   display: flex; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| #main { | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										78
									
								
								fe/src/assets/base.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								fe/src/assets/base.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| /* color palette from <https://github.com/vuejs/theme> */ | ||||
| :root { | ||||
|   --vt-c-white: #ffffff; | ||||
|   --vt-c-white-soft: #f8f8f8; | ||||
|   --vt-c-white-mute: #f2f2f2; | ||||
|  | ||||
|   --vt-c-black: #181818; | ||||
|   --vt-c-black-soft: #222222; | ||||
|   --vt-c-black-mute: #282828; | ||||
|  | ||||
|   --vt-c-indigo: #2c3e50; | ||||
|  | ||||
|   --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); | ||||
|   --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); | ||||
|   --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); | ||||
|   --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); | ||||
|  | ||||
|   --vt-c-text-light-1: var(--vt-c-indigo); | ||||
|   --vt-c-text-light-2: rgba(60, 60, 60, 0.66); | ||||
|   --vt-c-text-dark-1: var(--vt-c-white); | ||||
|   --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); | ||||
| } | ||||
|  | ||||
| /* semantic color variables for this project */ | ||||
| :root { | ||||
|   --color-background: var(--vt-c-white); | ||||
|   --color-background-soft: var(--vt-c-white-soft); | ||||
|   --color-background-mute: var(--vt-c-white-mute); | ||||
|  | ||||
|   --color-border: var(--vt-c-divider-light-2); | ||||
|   --color-border-hover: var(--vt-c-divider-light-1); | ||||
|  | ||||
|   --color-heading: var(--vt-c-text-light-1); | ||||
|   --color-text: var(--vt-c-text-light-1); | ||||
|  | ||||
|   --section-gap: 160px; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --color-background: var(--vt-c-black); | ||||
|     --color-background-soft: var(--vt-c-black-soft); | ||||
|     --color-background-mute: var(--vt-c-black-mute); | ||||
|  | ||||
|     --color-border: var(--vt-c-divider-dark-2); | ||||
|     --color-border-hover: var(--vt-c-divider-dark-1); | ||||
|  | ||||
|     --color-heading: var(--vt-c-text-dark-1); | ||||
|     --color-text: var(--vt-c-text-dark-2); | ||||
|   } | ||||
| } | ||||
|  | ||||
| *, | ||||
| *::before, | ||||
| *::after { | ||||
|   box-sizing: border-box; | ||||
|   margin: 0; | ||||
|   font-weight: normal; | ||||
| } | ||||
|  | ||||
| html{ | ||||
|   height: 100vh; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   height: 100vh; | ||||
|   min-height: 100vh; | ||||
|   color: var(--color-text); | ||||
|   background: var(--color-background); | ||||
|   transition: color 0.5s, background-color 0.5s; | ||||
|   line-height: 1.6; | ||||
|   font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, | ||||
|     Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; | ||||
|   font-size: 15px; | ||||
|   text-rendering: optimizeLegibility; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
| } | ||||
							
								
								
									
										1
									
								
								fe/src/assets/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								fe/src/assets/logo.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg> | ||||
| After Width: | Height: | Size: 276 B | 
							
								
								
									
										13
									
								
								fe/src/assets/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								fe/src/assets/main.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| @import './base.css'; | ||||
|  | ||||
| #app { | ||||
|   margin: 0 auto; | ||||
|   padding: 0; | ||||
|   height: 100vh; | ||||
|   font-weight: normal; | ||||
|   font-family: Avenir, Helvetica, Arial, sans-serif; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
|   text-align: center; | ||||
|   color: #2c3e50; | ||||
| } | ||||
							
								
								
									
										64
									
								
								fe/src/components/HomeAside.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								fe/src/components/HomeAside.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| <template> | ||||
|   <div id="main"> | ||||
|     <input id="search" :placeholder="lang.search"> | ||||
|     <el-tree :data="data" :props="defaultProps" :defaultExpandAll="true" @node-click="handleNodeClick" :class="node" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <script setup> | ||||
| import { useRouter } from 'vue-router' | ||||
| import $http from "../http/http"; | ||||
| import { reactive, ref } from 'vue' | ||||
| import useGroupStore from '../stores/group' | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
| const groupStore = useGroupStore() | ||||
| const router = useRouter() | ||||
|  | ||||
|  | ||||
| const data = ref([]) | ||||
|  | ||||
| $http.get('/api/group').then(res => { | ||||
|   data.value = res.data | ||||
| }) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| const handleNodeClick = function (data) { | ||||
|   if (data.tag != null) { | ||||
|     groupStore.name = data.label | ||||
|     groupStore.tag = data.tag | ||||
|     router.push({ | ||||
|       name: "list", | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style scoped> | ||||
| #main { | ||||
|   width: 243px; | ||||
|   background-color: #F1F1F1; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| #search { | ||||
|   background-color: #D6E7F7; | ||||
|   width: 100%; | ||||
|   height: 40px; | ||||
|   padding-left: 10px; | ||||
|   border: none; | ||||
|   outline: none; | ||||
|   font-size: 16px; | ||||
| } | ||||
|  | ||||
| .el-tree { | ||||
|   background-color: #F1F1F1; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										67
									
								
								fe/src/components/HomeHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								fe/src/components/HomeHeader.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| <template> | ||||
|     <div id="header_main"> | ||||
|         <div id="logo"> | ||||
|             <span style="padding-left: 20px;">PMail</span> | ||||
|         </div> | ||||
|         <div id="settings" @click="settings"> | ||||
|             <el-icon style="font-size: 25px;"> | ||||
|                 <Setting style="color:#FFFFFF" /> | ||||
|             </el-icon> | ||||
|         </div> | ||||
|         <el-drawer v-model="openSettings" :title="lang.settings"> | ||||
|             <el-tabs tab-position="left" > | ||||
|                 <el-tab-pane :label="lang.security"> | ||||
|                     <SecuritySettings/> | ||||
|                 </el-tab-pane> | ||||
|             </el-tabs> | ||||
|         </el-drawer> | ||||
|  | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { Setting } from '@element-plus/icons-vue'; | ||||
| import { ref } from 'vue' | ||||
| import { ElMessageBox } from 'element-plus' | ||||
| import SecuritySettings from '@/components/SecuritySettings.vue' | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
|  | ||||
| const openSettings = ref(false) | ||||
| const settings = function () { | ||||
|     openSettings.value = true; | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style scoped> | ||||
| #header_main { | ||||
|     height: 50px; | ||||
|     background-color: #000; | ||||
|     display: flex; | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| #logo { | ||||
|     height: 3rem; | ||||
|     line-height: 3rem; | ||||
|     font-size: 2.3rem; | ||||
|     flex-grow: 1; | ||||
|     width: 200px; | ||||
|     color: #FFF; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| #search { | ||||
|     height: 3rem; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| #settings { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     padding-right: 20px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										54
									
								
								fe/src/components/SecuritySettings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								fe/src/components/SecuritySettings.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| <template> | ||||
|     <el-form :model="ruleForm" :rules="rules" status-icon> | ||||
|         <el-form-item :label="lang.modify_pwd" prop="new_pwd"> | ||||
|             <el-input type="password" v-model="ruleForm.new_pwd" /> | ||||
|         </el-form-item> | ||||
|  | ||||
|         <el-form-item :label="lang.enter_again" prop="new_pwd2"> | ||||
|             <el-input type="password" v-model="ruleForm.new_pwd2" /> | ||||
|         </el-form-item> | ||||
|  | ||||
|         <el-form-item> | ||||
|             <el-button type="primary" @click="submit"> | ||||
|                 {{ lang.submit }} | ||||
|             </el-button> | ||||
|         </el-form-item> | ||||
|     </el-form> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { reactive, ref } from 'vue' | ||||
| import { ElNotification } from 'element-plus' | ||||
| import $http from "../http/http"; | ||||
| import lang from '../i18n/i18n'; | ||||
| const ruleForm = reactive({ | ||||
|     new_pwd: "", | ||||
|     new_pwd2: "" | ||||
| }) | ||||
|  | ||||
| const rules = reactive({ | ||||
|     new_pwd: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' },], | ||||
|     new_pwd2: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' },], | ||||
|  | ||||
| }) | ||||
|  | ||||
| const submit = function () { | ||||
|     if (ruleForm.new_pwd != ruleForm.new_pwd2) { | ||||
|         ElNotification({ | ||||
|             title: 'Error', | ||||
|             message: lang.err_pwd_diff, | ||||
|             type: 'error', | ||||
|         }) | ||||
|         return | ||||
|     } | ||||
|     $http.post("/api/settings/modify_password", { password: ruleForm.new_pwd }).then(res => { | ||||
|         ElNotification({ | ||||
|             title: res.errorNo == 0 ? lang.succ : lang.fail, | ||||
|             message: res.data, | ||||
|             type: res.errorNo == 0 ? 'success' : 'error', | ||||
|         }) | ||||
|     }) | ||||
|  | ||||
|  | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										92
									
								
								fe/src/http/http.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								fe/src/http/http.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| // http/index.js | ||||
| import axios from 'axios' | ||||
| import router from "@/router";  //根路由对象 | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
| //创建axios的一个实例  | ||||
| var $http = axios.create({ | ||||
|     baseURL: import.meta.env.VITE_APP_URL, //接口统一域名 | ||||
|     timeout: 6000, //设置超时 | ||||
|     headers: { | ||||
|         'Content-Type': 'application/json;charset=UTF-8;', | ||||
|         'Lang':lang.lang | ||||
|     } | ||||
| }) | ||||
|  | ||||
| //请求拦截器  | ||||
| $http.interceptors.request.use((config) => { | ||||
|     //若请求方式为post,则将data参数转为JSON字符串 | ||||
|     if (config.method === 'POST') { | ||||
|         config.data = JSON.stringify(config.data); | ||||
|     } | ||||
|     return config; | ||||
| }, (error) => | ||||
|     // 对请求错误做些什么 | ||||
|     Promise.reject(error)); | ||||
|  | ||||
| //响应拦截器 | ||||
| $http.interceptors.response.use((response) => { | ||||
|     //响应成功 | ||||
|     if(response.data.errorNo == 403){ | ||||
|         router.replace({ | ||||
|             path: '/login', | ||||
|             query: { | ||||
|                 redirect: router.currentRoute.fullPath | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
|     return response.data; | ||||
| }, (error) => { | ||||
|     //响应错误 | ||||
|     if (error.response && error.response.status) { | ||||
|         const status = error.response.status | ||||
|         let message = "" | ||||
|         switch (status) { | ||||
|             case 400: | ||||
|                 message = '请求错误'; | ||||
|                 break; | ||||
|             case 401: | ||||
|                 message = '请求错误'; | ||||
|                 break; | ||||
|             case 403: | ||||
|                 router.replace({ | ||||
|                     path: '/login', | ||||
|                     query: { | ||||
|                         redirect: router.currentRoute.fullPath | ||||
|                     } | ||||
|                 }) | ||||
|                 break; | ||||
|             case 404: | ||||
|                 message = '请求地址出错'; | ||||
|                 break; | ||||
|             case 408: | ||||
|                 message = '请求超时'; | ||||
|                 break; | ||||
|             case 500: | ||||
|                 message = '服务器内部错误!'; | ||||
|                 break; | ||||
|             case 501: | ||||
|                 message = '服务未实现!'; | ||||
|                 break; | ||||
|             case 502: | ||||
|                 message = '网关错误!'; | ||||
|                 break; | ||||
|             case 503: | ||||
|                 message = '服务不可用!'; | ||||
|                 break; | ||||
|             case 504: | ||||
|                 message = '网关超时!'; | ||||
|                 break; | ||||
|             case 505: | ||||
|                 message = 'HTTP版本不受支持'; | ||||
|                 break; | ||||
|             default: | ||||
|                 message = '请求失败' | ||||
|         } | ||||
|         return Promise.reject(error); | ||||
|     } | ||||
|     return Promise.reject(error); | ||||
| }); | ||||
|  | ||||
|  | ||||
| export default $http; | ||||
							
								
								
									
										90
									
								
								fe/src/i18n/i18n.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								fe/src/i18n/i18n.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| var lang = { | ||||
|     "lang": "en", | ||||
|     "submit": "submit", | ||||
|     "compose": "compose", | ||||
|     "new": "new", | ||||
|     "account": "Account", | ||||
|     "password": "Password", | ||||
|     "login": "login", | ||||
|     "search": "Search Email", | ||||
|     "inbox": "Inbox", | ||||
|     "sender": "Sender", | ||||
|     "title": "Title", | ||||
|     "date": "Date", | ||||
|     "to": "To:", | ||||
|     "cc": "Cc:", | ||||
|     "sender_desc": "Only the email prefix is required", | ||||
|     "to_desc": "Recipient's e-mail address", | ||||
|     "cc_desc": "Cc's e-mail address", | ||||
|     "send": "send", | ||||
|     "add_att": "Add Attachment", | ||||
|     "attachment":"Attachment", | ||||
|     "err_sender_must": "Sender's email prefix is required!", | ||||
|     "only_prefix": "Only the email prefix is required!", | ||||
|     "err_email_format": "Incorrect e-mail address, please check the e-mail format!", | ||||
|     "err_title_must": "Title is required!", | ||||
|     "succ_send": "Send Success!", | ||||
|     "outbox": "outbox", | ||||
|     "modify_pwd": "modify password", | ||||
|     "enter_again": "enter again", | ||||
|     "err_required_pwd": "Please Input Password!", | ||||
|     "succ": "Success!", | ||||
|     "err_pwd_diff": "The passwords entered twice do not match!", | ||||
|     "fail": "Fail!", | ||||
|     "settings":"Settings", | ||||
|     "security":"Security" | ||||
| }; | ||||
|  | ||||
|  | ||||
|  | ||||
| var zhCN = { | ||||
|     "lang": "zhCn", | ||||
|     "submit": "提交", | ||||
|     "compose": "发件", | ||||
|     "new": "新", | ||||
|     "account": "用户名", | ||||
|     "password": "密码", | ||||
|     "login": "登录", | ||||
|     "search": "搜索邮件", | ||||
|     "inbox": "收件箱", | ||||
|     "sender": "发件人", | ||||
|     "title": "主题", | ||||
|     "date": "时间", | ||||
|     "to": "收件人:", | ||||
|     "cc": "抄送:", | ||||
|     "sender_desc": "只需要邮箱前缀", | ||||
|     "to_desc": "接收人邮件地址", | ||||
|     "cc_desc": "抄送人邮箱地址", | ||||
|     "send": "发送", | ||||
|     "add_att": "添加附件", | ||||
|     "attachment":"附件", | ||||
|     "err_sender_must": "发件人邮箱前缀必填", | ||||
|     "only_prefix": "只需要邮箱前缀", | ||||
|     "err_email_format": "邮箱地址错误,请检查邮箱格式!", | ||||
|     "err_title_must": "标题必填!", | ||||
|     "succ_send": "发送成功!", | ||||
|     "outbox": "发件箱", | ||||
|     "modify_pwd": "修改密码", | ||||
|     "enter_again": "确认密码", | ||||
|     "err_required_pwd": "请输入密码!", | ||||
|     "succ": "成功!", | ||||
|     "err_pwd_diff": "两次输入的密码不一致!", | ||||
|     "fail": "失败", | ||||
|     "settings":"设置", | ||||
|     "security":"安全" | ||||
| } | ||||
|  | ||||
| switch (navigator.language) { | ||||
|     case "zh": | ||||
|         lang = zhCN | ||||
|         break | ||||
|     case "zh-CN": | ||||
|         lang = zhCN | ||||
|         break | ||||
|     default: | ||||
|         break | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| export default lang; | ||||
							
								
								
									
										16
									
								
								fe/src/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								fe/src/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import './assets/main.css' | ||||
| import 'element-plus/dist/index.css' | ||||
|  | ||||
| import { createApp } from 'vue' | ||||
| import { createPinia } from 'pinia' | ||||
|  | ||||
| import App from './App.vue' | ||||
| import router from './router' | ||||
|  | ||||
| const app = createApp(App) | ||||
|  | ||||
| app.use(createPinia()) | ||||
| app.use(router) | ||||
|  | ||||
| app.mount('#app') | ||||
|  | ||||
							
								
								
									
										37
									
								
								fe/src/router/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								fe/src/router/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import { createRouter, createWebHashHistory } from 'vue-router' | ||||
| import ListView from '../views/ListView.vue' | ||||
| import EditerView from '../views/EditerView.vue' | ||||
| import LoginView from '../views/LoginView.vue' | ||||
| import EmailDetailView from '../views/EmailDetailView.vue' | ||||
| const router = createRouter({ | ||||
|   history: createWebHashHistory(import.meta.env.BASE_URL), | ||||
|   routes: [ | ||||
|     { | ||||
|       path: '/', | ||||
|       name: 'home', | ||||
|       component: ListView | ||||
|     }, | ||||
|     { | ||||
|       path: '/list', | ||||
|       name: 'list', | ||||
|       component: ListView | ||||
|     }, | ||||
|     { | ||||
|       path: '/editer', | ||||
|       name: "editer", | ||||
|       component: EditerView | ||||
|     }, | ||||
|     { | ||||
|       path: '/login', | ||||
|       name: "login", | ||||
|       component: LoginView | ||||
|     }, | ||||
|     { | ||||
|       path: '/detail/:id', | ||||
|       name: "detail", | ||||
|       component: EmailDetailView | ||||
|     } | ||||
|   ] | ||||
| }) | ||||
|  | ||||
| export default router | ||||
							
								
								
									
										11
									
								
								fe/src/stores/group.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								fe/src/stores/group.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { ref, computed } from 'vue' | ||||
| import { defineStore } from 'pinia' | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
| const useGroupStore = defineStore('group', () => { | ||||
|   const tag = ref("") | ||||
|   const name = ref(lang.inbox) | ||||
|   return { tag, name } | ||||
| }) | ||||
|  | ||||
| export default useGroupStore | ||||
							
								
								
									
										280
									
								
								fe/src/views/EditerView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								fe/src/views/EditerView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | ||||
| <template> | ||||
|     <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 v-model="ruleForm.sender" :placeholder="lang.sender_desc"></el-input> | ||||
|             </el-form-item> | ||||
|  | ||||
|  | ||||
|             <el-form-item :label="lang.to" prop="receivers"> | ||||
|                 <el-select v-model="ruleForm.receivers" style="width: 100%;" multiple filterable allow-create | ||||
|                     :reserve-keyword="false" :placeholder="lang.to_desc"></el-select> | ||||
|             </el-form-item> | ||||
|  | ||||
|  | ||||
|             <el-form-item :label="lang.cc" prop="cc"> | ||||
|                 <el-select v-model="ruleForm.cc" style="width: 100%;" multiple filterable allow-create | ||||
|                     :reserve-keyword="false" :placeholder="lang.cc_desc"></el-select> | ||||
|             </el-form-item> | ||||
|  | ||||
|  | ||||
|             <el-form-item :label="lang.title" prop="subject"> | ||||
|                 <el-input v-model="ruleForm.subject" :placeholder="lang.title"></el-input> | ||||
|             </el-form-item> | ||||
|  | ||||
|  | ||||
|             <div id="editor"> | ||||
|                 <div style="border: 1px solid #ccc"> | ||||
|                     <Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig" | ||||
|                         :mode="mode" /> | ||||
|                     <Editor style="height: 300px;" v-model="valueHtml" :defaultConfig="editorConfig" :mode="mode" | ||||
|                         @onCreated="handleCreated" /> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div id="fileList"> | ||||
|                 <ol> | ||||
|                     <li v-for="(item, index) in fileList">{{ item.name }} <el-icon @click="delFile(index)"> | ||||
|                             <Close /> | ||||
|                         </el-icon> </li> | ||||
|                 </ol> | ||||
|             </div> | ||||
|  | ||||
|             <div id="sendButton"> | ||||
|                 <el-button type="primary" @click="send(ruleFormRef)">{{ lang.send }}</el-button> | ||||
|                 <!-- <el-button>定时发送</el-button> --> | ||||
|  | ||||
|                 <div style="margin-left: 15px"> | ||||
|                     <el-button @click="upload">{{ lang.add_att }}</el-button> | ||||
|                     <input v-show="false" ref="fileRef" type="file" @change="fileChange"> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|  | ||||
|  | ||||
|         </el-form> | ||||
|  | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| #main { | ||||
|     text-align: left; | ||||
|     padding-right: 20px; | ||||
| } | ||||
|  | ||||
| #editor { | ||||
|     padding-left: 25px; | ||||
| } | ||||
|  | ||||
| #sendButton { | ||||
|     padding-left: 25px; | ||||
|     padding-top: 5px; | ||||
|     display: flex; | ||||
| } | ||||
| </style> | ||||
|  | ||||
|  | ||||
| <script setup> | ||||
| import '@wangeditor/editor/dist/css/style.css' // 引入 css | ||||
| import { ElMessage } from 'element-plus' | ||||
| import { onBeforeUnmount, ref, shallowRef, reactive, onMounted } from 'vue' | ||||
| import { Close } from '@element-plus/icons-vue'; | ||||
| import lang from '../i18n/i18n'; | ||||
| import { Editor, Toolbar } from '@wangeditor/editor-for-vue' | ||||
| import { i18nChangeLanguage } from '@wangeditor/editor' | ||||
| import $http from '../http/http'; | ||||
| import router from "@/router";  //根路由对象 | ||||
| import useGroupStore from '../stores/group' | ||||
| const groupStore = useGroupStore() | ||||
|  | ||||
|  | ||||
| if (lang.lang == "zhCn"){ | ||||
|     i18nChangeLanguage('zh-CN') | ||||
| }else{ | ||||
|     i18nChangeLanguage('en') | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| // 内容 HTML | ||||
| const valueHtml = ref('<p>hello</p>') | ||||
|  | ||||
| const toolbarConfig = {} | ||||
| const editorConfig = { | ||||
|     MENU_CONF: {}, | ||||
|     placeholder: '' | ||||
| } | ||||
|  | ||||
|  | ||||
| editorConfig.MENU_CONF['uploadImage'] = { | ||||
|     base64LimitSize: 100 * 1024 * 1024 * 1024,  // 100G以下的文件都base64传 | ||||
| } | ||||
| const mode = ref() | ||||
| const fileRef = ref(); | ||||
| const pickFile = ref(); | ||||
| const ruleFormRef = ref() | ||||
| const ruleForm = reactive({ | ||||
|     sender: '', | ||||
|     receivers: '', | ||||
|     cc: '', | ||||
|     subject: '', | ||||
| }) | ||||
| const fileList = reactive([]); | ||||
|  | ||||
|  | ||||
| const validateSender = function (rule, value, callback) { | ||||
|     if (typeof ruleForm.sender === "undefined" || ruleForm.sender === null || ruleForm.sender.trim() === "") { | ||||
|         callback(new Error(lang.err_sender_must)) | ||||
|     } else if (ruleForm.sender.includes("@")) { | ||||
|         callback(new Error(lang.only_prefix)) | ||||
|     } else { | ||||
|         callback() | ||||
|     } | ||||
| } | ||||
|  | ||||
| const checkEmail = function (str) { | ||||
|     var re = /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/ | ||||
|     if (re.test(str)) { | ||||
|         return true | ||||
|     } else { | ||||
|         return false | ||||
|     } | ||||
| } | ||||
|  | ||||
| const validateReceivers = function (rule, value, callback) { | ||||
|     for (let index = 0; index < ruleForm.receivers.length; index++) { | ||||
|         let element = ruleForm.receivers[index]; | ||||
|         if (!checkEmail(element)) { | ||||
|             callback(new Error(lang.err_email_format)) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
|     callback() | ||||
| } | ||||
|  | ||||
| const validateCc = function (rule, value, callback) { | ||||
|     for (let index = 0; index < ruleForm.cc.length; index++) { | ||||
|         let element = ruleForm.cc[index]; | ||||
|         if (!checkEmail(element)) { | ||||
|             callback(new Error(err_email_format)) | ||||
|             return | ||||
|         } | ||||
|     } | ||||
|     callback() | ||||
| } | ||||
|  | ||||
| const rules = reactive({ | ||||
|     sender: [ | ||||
|         { validator: validateSender, trigger: 'change' } | ||||
|     ], | ||||
|     receivers: [ | ||||
|         { validator: validateReceivers, trigger: 'change' } | ||||
|     ], | ||||
|     cc: [ | ||||
|         { validator: validateCc, trigger: 'change' } | ||||
|     ], | ||||
|     subject: [ | ||||
|         { required: true, message: lang.err_title_must, trigger: 'change' }, | ||||
|     ], | ||||
| }) | ||||
|  | ||||
|  | ||||
| // 编辑器实例,必须用 shallowRef | ||||
| const editorRef = shallowRef() | ||||
| // 组件销毁时,也及时销毁编辑器 | ||||
| onBeforeUnmount(() => { | ||||
|     const editor = editorRef.value | ||||
|     if (editor == null) return | ||||
|     editor.destroy() | ||||
| }) | ||||
|  | ||||
| const handleCreated = (editor) => { | ||||
|     editorRef.value = editor // 记录 editor 实例,重要! | ||||
| } | ||||
|  | ||||
| const send = function (formEl) { | ||||
|     if (!formEl) return | ||||
|     formEl.validate((valid) => { | ||||
|         if (valid) { | ||||
|             let objectTos = [] | ||||
|             for (let index = 0; index < ruleForm.receivers.length; index++) { | ||||
|                 let element = ruleForm.receivers[index]; | ||||
|                 objectTos.push({ | ||||
|                     name: "", | ||||
|                     email: element | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             let objectCcs = [] | ||||
|             for (let index = 0; index < ruleForm.cc.length; index++) { | ||||
|                 let element = ruleForm.cc[index]; | ||||
|                 objectCcs.push({ | ||||
|                     name: "", | ||||
|                     email: element | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             let text = editorRef.value.getText() | ||||
|  | ||||
|             $http.post("/api/email/send", { | ||||
|                 from: { name: ruleForm.sender, email: "" }, | ||||
|                 to: objectTos, | ||||
|                 cc: objectCcs, | ||||
|                 subject: ruleForm.subject, | ||||
|                 text: text, | ||||
|                 html: valueHtml.value, | ||||
|                 attrs: fileList | ||||
|             }).then(res => { | ||||
|                 if (res.errorNo === 0) { | ||||
|                     ElMessage({ | ||||
|                         message: lang.succ_send, | ||||
|                         type: 'success', | ||||
|                     }) | ||||
|                     groupStore.name = lang.outbox | ||||
|                     groupStore.tag = '{"type":1,"status":-1}' | ||||
|                     router.replace({ | ||||
|                         name: 'list', | ||||
|                     }) | ||||
|                 } else { | ||||
|                     ElMessage.error(res.data) | ||||
|                 } | ||||
|             }) | ||||
|         } else { | ||||
|             return false | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| const upload = function () { | ||||
|     fileRef.value.dispatchEvent(new MouseEvent('click')) | ||||
| } | ||||
|  | ||||
| const fileChange = function (e) { | ||||
|     let files = e.target.files || e.dataTransfer.files; | ||||
|     if (!files.length) | ||||
|         return; | ||||
|     for (let i = 0; i < files.length; i++) { | ||||
|         const reader = new FileReader(); | ||||
|         reader.onload = function fileReadCompleted() { | ||||
|             fileList.push({ | ||||
|                 name: files[i].name, | ||||
|                 data: this.result | ||||
|             }) | ||||
|         }; | ||||
|         reader.readAsDataURL(files[i]); | ||||
|  | ||||
|     } | ||||
| } | ||||
|  | ||||
| const delFile = function (index) { | ||||
|     fileList.splice(index, 1); | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										100
									
								
								fe/src/views/EmailDetailView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								fe/src/views/EmailDetailView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| <template> | ||||
|     <div id="main"> | ||||
|         <div id="title">{{ detailData.subject }}</div> | ||||
|         <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> | ||||
|         <el-divider /> | ||||
|         <div class="content" id="text" v-if="detailData.html == ''"> | ||||
|             {{ detailData.text }} | ||||
|         </div> | ||||
|  | ||||
|         <div class="content" id="html" v-else v-html="detailData.html"> | ||||
|  | ||||
|         </div> | ||||
|  | ||||
|         <div v-if="detailData.attachments.length > 0" style=""> | ||||
|             <el-divider /> | ||||
|             {{ lang.attachment }}: | ||||
|             <a class="att" v-for="item in detailData.attachments" | ||||
|                 :href="'/attachments/download/' + detailData.id + '/' + item.Index"> <el-icon> | ||||
|                     <Document /> | ||||
|                 </el-icon> {{ item.Filename }} </a> | ||||
|         </div> | ||||
|  | ||||
|  | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { RouterLink } from 'vue-router' | ||||
| import $http from "../http/http"; | ||||
| import { reactive, ref } from 'vue' | ||||
| import { useRoute } from 'vue-router' | ||||
| import router from "@/router";  //根路由对象 | ||||
| import { Document } from '@element-plus/icons-vue'; | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
| const route = useRoute() | ||||
| const detailData = ref({ | ||||
|     attachments:[] | ||||
| }) | ||||
|  | ||||
| const tos = ref() | ||||
| const ccs = ref() | ||||
| const showCC = ref(false) | ||||
|  | ||||
| $http.post("/api/email/detail", { id: parseInt(route.params.id) }).then(res => { | ||||
|     detailData.value = res.data | ||||
|     if (res.data.to != "" && res.data.to != null) { | ||||
|         tos.value = JSON.parse(res.data.to) | ||||
|     } | ||||
|     if (res.data.cc != "" && res.data.cc != null) { | ||||
|         ccs.value = JSON.parse(res.data.cc) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     if (ccs.value != null && ccs.value != undefined){ | ||||
|          showCC.value = ccs.value.length > 0 | ||||
|     }else{ | ||||
|         showCC.value = false | ||||
|     } | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| #main { | ||||
|     display: flex; | ||||
|     padding-left: 20px; | ||||
|     padding-right: 80px; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| #title { | ||||
|     font-size: 40px; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| #userItem {} | ||||
|  | ||||
| .content { | ||||
|     /* background-color: aliceblue; */ | ||||
| } | ||||
|  | ||||
| a,a:link,a:visited,a:hover,a:active{ | ||||
|     text-decoration: none; | ||||
|     color:inherit; | ||||
| } | ||||
|  | ||||
| .att{ | ||||
|     display:block; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										156
									
								
								fe/src/views/ListView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								fe/src/views/ListView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| <template> | ||||
|     <div style="height: 100%"> | ||||
|         <div id="operation"> | ||||
|             <div id="action"> | ||||
|                 <RouterLink to="/editer">+{{ lang.compose }}</RouterLink> | ||||
|             </div> | ||||
|             <!-- <div id="action">全部标记为已读</div> --> | ||||
|         </div> | ||||
|         <div id="title">{{ groupStore.name }}</div> | ||||
|         <div id="table"> | ||||
|             <el-table :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"> | ||||
|                     <template #default="scope"> | ||||
|                         <div> | ||||
|                             <span v-if="!scope.row.is_read"> | ||||
|                                 {{ lang.new }} | ||||
|                             </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> | ||||
|                     </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> | ||||
|                         <div v-else style="font-weight:bolder;">{{ scope.row.title }}</div> | ||||
|  | ||||
|                         <div style="font-size: 12px;height: 24px;">{{ scope.row.desc }}</div> | ||||
|  | ||||
|                     </template> | ||||
|                 </el-table-column> | ||||
|                 <el-table-column prop="datetime" :label="lang.date" width="180"> | ||||
|                     <template #default="scope"> | ||||
|                         <span v-if="scope.row.is_read">{{ scope.row.datetime }}</span> | ||||
|                         <span v-else style="font-weight:bolder;">{{ scope.row.datetime }}</span> | ||||
|                     </template> | ||||
|                 </el-table-column> | ||||
|             </el-table> | ||||
|         </div> | ||||
|         <div id="pagination"> | ||||
|             <el-pagination background layout="prev, pager, next" :page-count="totalPage" /> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
|  | ||||
|  | ||||
| <script setup> | ||||
| import { RouterLink } from 'vue-router' | ||||
| import $http from "../http/http"; | ||||
| import { reactive, ref, watch } from 'vue' | ||||
| import { useRoute } from 'vue-router' | ||||
| import router from "@/router";  //根路由对象 | ||||
| import useGroupStore from '../stores/group' | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
| const groupStore = useGroupStore() | ||||
|  | ||||
| const route = useRoute() | ||||
|  | ||||
|  | ||||
|  | ||||
| let tag = groupStore.tag; | ||||
|  | ||||
| if (tag == "") { | ||||
|     tag = '{"type":0,"status":-1}' | ||||
| } | ||||
|  | ||||
|  | ||||
| watch(groupStore, async (newV, oldV) => { | ||||
|     tag = newV.tag; | ||||
|     if (tag == "") { | ||||
|         tag = '{"type":0,"status":-1}' | ||||
|     } | ||||
|     data.value = [] | ||||
|     $http.post("/api/email/list", { tag: tag, page_size: 10 }).then(res => { | ||||
|         data.value = res.data.list | ||||
|         totalPage.value = res.data.total_page | ||||
|     }) | ||||
| }) | ||||
|  | ||||
|  | ||||
|  | ||||
| const data = ref([]) | ||||
| const totalPage = ref(0) | ||||
|  | ||||
| $http.post("/api/email/list", { tag: tag, page_size: 10 }).then(res => { | ||||
|     data.value = res.data.list | ||||
|     totalPage.value = res.data.total_page | ||||
| }) | ||||
|  | ||||
| const rowClick = function (row, column, event) { | ||||
|     router.push("/detail/" + row.id) | ||||
| } | ||||
|  | ||||
| const rowStyle = function ({ row, rowIndwx }) { | ||||
|     return { 'cursor': 'pointer' } | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style scoped> | ||||
| #action { | ||||
|     text-align: left; | ||||
|     font-size: 20px; | ||||
|     line-height: 40px; | ||||
|     padding-left: 10px; | ||||
|     margin-right: 5px; | ||||
| } | ||||
|  | ||||
|  | ||||
| #action a, | ||||
| a:visited { | ||||
|     color: #000000; | ||||
|     text-decoration: none; | ||||
| } | ||||
|  | ||||
| #operation { | ||||
|     display: flex; | ||||
|     height: 40px; | ||||
|     background-color: rgb(236, 244, 251); | ||||
| } | ||||
|  | ||||
| #title { | ||||
|     margin-top: 10px; | ||||
|     font-size: 23px; | ||||
|     text-align: left; | ||||
|     padding-left: 20px; | ||||
| } | ||||
|  | ||||
| #table { | ||||
|     text-align: left; | ||||
|     width: 100%; | ||||
|     padding-left: 20px; | ||||
| } | ||||
|  | ||||
| #pagination { | ||||
|     padding-top: 30px; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     /* 水平居中 */ | ||||
|     width: 100%; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										63
									
								
								fe/src/views/LoginView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								fe/src/views/LoginView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <template> | ||||
|     <div id="main"> | ||||
|         <div id="form"> | ||||
|             <el-form :model="form" label-width="120px"> | ||||
|                 <el-form-item :label="lang.account"> | ||||
|                     <el-input v-model="form.account" placeholder="User Name" /> | ||||
|                 </el-form-item> | ||||
|                 <el-form-item :label="lang.password"> | ||||
|                     <el-input v-model="form.password" placeholder="Password" type="password" /> | ||||
|                 </el-form-item> | ||||
|                 <el-form-item> | ||||
|                     <el-button type="primary" @click="onSubmit">{{ lang.login }}</el-button> | ||||
|                 </el-form-item> | ||||
|             </el-form> | ||||
|  | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
|  | ||||
| import { reactive } from 'vue' | ||||
| import $http from "../http/http"; | ||||
| import { ElMessage } from 'element-plus' | ||||
| import router from "@/router";  //根路由对象 | ||||
| import lang from '../i18n/i18n'; | ||||
|  | ||||
|  | ||||
| const form = reactive({ | ||||
|     account: '', | ||||
|     password: '', | ||||
| }) | ||||
|  | ||||
| const onSubmit = () => { | ||||
|     $http.post("/api/login", form).then(res => { | ||||
|         if (res.errorNo != 0) { | ||||
|             ElMessage.error(res.errorMsg) | ||||
|         } else { | ||||
|             router.replace({ | ||||
|                 path: '/', | ||||
|                 query: { | ||||
|                     redirect: router.currentRoute.fullPath | ||||
|                 } | ||||
|             }) | ||||
|         } | ||||
|     }) | ||||
|  | ||||
| } | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style scoped> | ||||
| #main { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background-color: #f1f1f1; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     /* 水平居中 */ | ||||
|     align-items: center; | ||||
|     /* 垂直居中 */ | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										32
									
								
								fe/vite.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								fe/vite.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import { fileURLToPath, URL } from 'node:url' | ||||
| import { defineConfig } from 'vite' | ||||
| import AutoImport from 'unplugin-auto-import/vite' | ||||
| import Components from 'unplugin-vue-components/vite' | ||||
| import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' | ||||
| import vue from '@vitejs/plugin-vue' | ||||
|  | ||||
| // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [ | ||||
|     vue(), | ||||
|     AutoImport({ | ||||
|       resolvers: [ElementPlusResolver()], | ||||
|     }), | ||||
|     Components({ | ||||
|       resolvers: [ElementPlusResolver()], | ||||
|     }), | ||||
|   ], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       '@': fileURLToPath(new URL('./src', import.meta.url)) | ||||
|     } | ||||
|   }, | ||||
|   server: { | ||||
|     cors: true, | ||||
|     proxy: { | ||||
|       "/api": "http://127.0.0.1/", | ||||
|       "/attachments":"http://127.0.0.1/" | ||||
|  | ||||
|     } | ||||
|   } | ||||
| }) | ||||
							
								
								
									
										1908
									
								
								fe/yarn.lock
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1908
									
								
								fe/yarn.lock
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										11
									
								
								server/config/config.dev.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								server/config/config.dev.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "domain": "jinnrry.com", | ||||
|   "dkimPrivateKeyPath": "config/dkim/dkim.priv", | ||||
|   "SSLPrivateKeyPath": "config/ssl/private.key", | ||||
|   "SSLPublicKeyPath": "config/ssl/public.crt", | ||||
|   "mysqlDSN": "", | ||||
|   "weChatPushAppId": "", | ||||
|   "weChatPushSecret": "", | ||||
|   "weChatPushTemplateId": "", | ||||
|   "weChatPushUserId": "" | ||||
| } | ||||
							
								
								
									
										79
									
								
								server/config/config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								server/config/config.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| package config | ||||
|  | ||||
| import ( | ||||
| 	"embed" | ||||
| 	"encoding/json" | ||||
| 	"io/fs" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Config struct { | ||||
| 	Domain             string `json:"domain"` | ||||
| 	DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"` | ||||
| 	SSLPrivateKeyPath  string `json:"SSLPrivateKeyPath"` | ||||
| 	SSLPublicKeyPath   string `json:"SSLPublicKeyPath"` | ||||
| 	MysqlDSN           string `json:"mysqlDSN"` | ||||
|  | ||||
| 	WeChatPushAppId      string `json:"weChatPushAppId"` | ||||
| 	WeChatPushSecret     string `json:"weChatPushSecret"` | ||||
| 	WeChatPushTemplateId string `json:"weChatPushTemplateId"` | ||||
| 	WeChatPushUserId     string `json:"weChatPushUserId"` | ||||
|  | ||||
| 	Tables         map[string]string | ||||
| 	TablesInitData map[string]string | ||||
| } | ||||
|  | ||||
| //go:embed tables/* | ||||
| var tableConfig embed.FS | ||||
|  | ||||
| var Instance *Config | ||||
|  | ||||
| func Init() { | ||||
| 	var cfgData []byte | ||||
| 	var err error | ||||
| 	args := os.Args | ||||
|  | ||||
| 	if len(args) >= 2 && args[len(args)-1] == "dev" { | ||||
| 		cfgData, err = os.ReadFile("./config/config.dev.json") | ||||
| 		if err != nil { | ||||
| 			panic("dev环境配置文件加载失败" + err.Error()) | ||||
| 		} | ||||
| 	} else { | ||||
| 		cfgData, err = os.ReadFile("./config/config.json") | ||||
| 		if err != nil { | ||||
| 			panic("配置文件加载失败" + err.Error()) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	err = json.Unmarshal(cfgData, &Instance) | ||||
| 	if err != nil { | ||||
| 		panic("配置文件加载失败" + err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// 读取表设置 | ||||
| 	Instance.Tables = map[string]string{} | ||||
| 	Instance.TablesInitData = map[string]string{} | ||||
|  | ||||
| 	err = fs.WalkDir(tableConfig, "tables", func(path string, info fs.DirEntry, err error) error { | ||||
| 		if !info.IsDir() && strings.HasSuffix(info.Name(), ".sql") { | ||||
| 			tableName := strings.ReplaceAll(info.Name(), ".sql", "") | ||||
| 			i, e := tableConfig.ReadFile(path) | ||||
| 			if e != nil { | ||||
| 				panic(e) | ||||
| 			} | ||||
| 			if strings.Contains(path, "data") { | ||||
| 				Instance.TablesInitData[tableName] = string(i) | ||||
| 			} else { | ||||
| 				Instance.Tables[tableName] = string(i) | ||||
| 			} | ||||
|  | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										11
									
								
								server/config/config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								server/config/config.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
|   "domain": "demo.com", | ||||
|   "dkimPrivateKeyPath": "config/dkim/dkim.priv", | ||||
|   "SSLPrivateKeyPath": "config/ssl/private.key", | ||||
|   "SSLPublicKeyPath": "config/ssl/public.crt", | ||||
|   "mysqlDSN": "", | ||||
|   "weChatPushAppId": "", | ||||
|   "weChatPushSecret": "", | ||||
|   "weChatPushTemplateId": "", | ||||
|   "weChatPushUserId": "" | ||||
| } | ||||
							
								
								
									
										1
									
								
								server/config/dkim/README
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/config/dkim/README
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| 使用[go-msgauth](https://github.com/emersion/go-msgauth)项目的dkim-keygen工具生成公钥和私钥 | ||||
							
								
								
									
										0
									
								
								server/config/dkim/dkim.priv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								server/config/dkim/dkim.priv
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								server/config/dkim/dkim.public
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								server/config/dkim/dkim.public
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								server/config/ssl/private.key
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								server/config/ssl/private.key
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								server/config/ssl/public.crt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								server/config/ssl/public.crt
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										2
									
								
								server/config/tables/data/user.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/config/tables/data/user.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| INSERT INTO user (account, name, password) VALUES ('admin', 'admin', 'faddb6ec2efe16116a342f5512583c48'); | ||||
|  | ||||
							
								
								
									
										2
									
								
								server/config/tables/data/user_auth.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/config/tables/data/user_auth.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| INSERT INTO pmail.user_auth (user_id, email_account) VALUES (1, '*'); | ||||
|  | ||||
							
								
								
									
										26
									
								
								server/config/tables/email.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/config/tables/email.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| CREATE table email | ||||
| ( | ||||
|     id             INT unsigned AUTO_INCREMENT PRIMARY KEY COMMENT '自增id', | ||||
|     type           tinyint(4) NOT NULL DEFAULT 0 COMMENT '邮件类型,0:收到的邮件,1:发送的邮件', | ||||
|     subject        varchar(1000) NOT NULL DEFAULT '' COMMENT '邮件标题', | ||||
|     reply_to       json COMMENT '回复人', | ||||
|     from_name      varchar(50)   NOT NULL DEFAULT '' COMMENT '发件人名称', | ||||
|     from_address   varchar(150)  NOT NULL DEFAULT '' COMMENT '发件人邮件地址', | ||||
|     `to`           json COMMENT '收件人信息', | ||||
|     bcc            json COMMENT '抄送', | ||||
|     cc             json COMMENT '抄送', | ||||
|     `text`         text COMMENT '邮件文本内容', | ||||
|     html           text COMMENT 'html格式内容', | ||||
|     sender         json COMMENT '发件人', | ||||
|     attachments    json COMMENT '附件内容', | ||||
|     spf_check      tinyint(1) DEFAULT 0 COMMENT '0未校验,1校验通过,2校验未通过', | ||||
|     dkim_check     tinyint(1) DEFAULT 0 COMMENT '0未校验,1校验通过,2校验未通过', | ||||
|     status         tinyint(4) NOT NULL DEFAULT 0 COMMENT '0未发送,1已发送,2发送失败', | ||||
|     send_user_id   int unsigned NOT NULL DEFAULT 0 COMMENT '发件人用户id', | ||||
|     is_read        tinyint(1) NOT NULL DEFAULT 0 COMMENT '未读0,已读1', | ||||
|     error          text COMMENT '错误信息记录', | ||||
|     cron_send_time datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '定时发送邮件的发送时间', | ||||
|     send_date      datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发件日期', | ||||
|     create_time    datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | ||||
|     update_time    datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' | ||||
| )COMMENT='邮件内容表' | ||||
							
								
								
									
										7
									
								
								server/config/tables/sessions.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/config/tables/sessions.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| CREATE TABLE sessions | ||||
| ( | ||||
|     token  CHAR(43) PRIMARY KEY, | ||||
|     data   BLOB         NOT NULL, | ||||
|     expiry TIMESTAMP(6) NOT NULL, | ||||
|     KEY    `sessions_expiry_idx` (`expiry`) | ||||
| )COMMENT='系统session数据表'; | ||||
							
								
								
									
										8
									
								
								server/config/tables/user.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								server/config/tables/user.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| CREATE TABLE user | ||||
| ( | ||||
|     id       INT unsigned AUTO_INCREMENT PRIMARY KEY COMMENT '自增id', | ||||
|     account  varchar(20) COMMENT '账号登陆名', | ||||
|     name     varchar(10) COMMENT '用户名', | ||||
|     password char(32) COMMENT '登陆密码,两次md5加盐,md5(md5(password+"pmail") +"pmail2023")', | ||||
|     UNIQUE INDEX udx_account ( account ) | ||||
| )COMMENT='登陆信息表' | ||||
							
								
								
									
										8
									
								
								server/config/tables/user_auth.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								server/config/tables/user_auth.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| CREATE TABLE user_auth | ||||
| ( | ||||
|     id            INT unsigned AUTO_INCREMENT PRIMARY KEY COMMENT '自增id', | ||||
|     user_id       int COMMENT '用户id', | ||||
|     email_account varchar(30) COMMENT '收件人前缀', | ||||
|     UNIQUE INDEX udx_uid_ename ( user_id, email_account), | ||||
|     UNIQUE INDEX udx_ename_uid ( email_account,user_id ) | ||||
| )COMMENT='登陆信息表' | ||||
							
								
								
									
										50
									
								
								server/controllers/attachments.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								server/controllers/attachments.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"github.com/spf13/cast" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/services/attachments" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func GetAttachments(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	urlInfos := strings.Split(req.RequestURI, "/") | ||||
| 	if len(urlInfos) != 4 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
| 	emailId := cast.ToInt(urlInfos[2]) | ||||
| 	cid := urlInfos[3] | ||||
|  | ||||
| 	contentType, content := attachments.GetAttachments(ctx, emailId, cid) | ||||
|  | ||||
| 	if len(content) == 0 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
| 	w.Header().Set("Content-Type", contentType) | ||||
| 	w.Write(content) | ||||
| } | ||||
|  | ||||
| func Download(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	urlInfos := strings.Split(req.RequestURI, "/") | ||||
| 	if len(urlInfos) != 5 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
| 	emailId := cast.ToInt(urlInfos[3]) | ||||
| 	index := cast.ToInt(urlInfos[4]) | ||||
|  | ||||
| 	fileName, content := attachments.GetAttachmentsByIndex(ctx, emailId, index) | ||||
|  | ||||
| 	if len(content) == 0 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
| 	w.Header().Set("ContentType", "application/octet-stream") | ||||
| 	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", fileName)) | ||||
| 	w.Write(content) | ||||
| } | ||||
							
								
								
									
										8
									
								
								server/controllers/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								server/controllers/base.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| ) | ||||
|  | ||||
| type HandlerFunc func(*dto.Context, http.ResponseWriter, *http.Request) | ||||
							
								
								
									
										49
									
								
								server/controllers/email/detail.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								server/controllers/email/detail.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| package email | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/services/auth" | ||||
| 	"pmail/services/detail" | ||||
| ) | ||||
|  | ||||
| type emailDetailRequest struct { | ||||
| 	ID int `json:"id"` | ||||
| } | ||||
|  | ||||
| func EmailDetail(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	reqBytes, err := io.ReadAll(req.Body) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 	} | ||||
| 	var retData emailDetailRequest | ||||
| 	err = json.Unmarshal(reqBytes, &retData) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	if retData.ID <= 0 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "ID错误", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	email, err := detail.GetEmailDetail(ctx, retData.ID, true) | ||||
| 	if err != nil { | ||||
| 		response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 检查是否有权限 | ||||
| 	hasAuth := auth.HasAuth(ctx, email) | ||||
| 	if !hasAuth { | ||||
| 		response.NewErrorResponse(response.ParamsError, "", "").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	response.NewSuccessResponse(email).FPrint(w) | ||||
|  | ||||
| } | ||||
							
								
								
									
										85
									
								
								server/controllers/email/list.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								server/controllers/email/list.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| package email | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/spf13/cast" | ||||
| 	"io" | ||||
| 	"math" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/services/list" | ||||
| ) | ||||
|  | ||||
| type emailListResponse struct { | ||||
| 	CurrentPage int         `json:"current_page"` | ||||
| 	TotalPage   int         `json:"total_page"` | ||||
| 	List        []*emilItem `json:"list"` | ||||
| } | ||||
|  | ||||
| type emilItem struct { | ||||
| 	ID       int    `json:"id"` | ||||
| 	Title    string `json:"title"` | ||||
| 	Desc     string `json:"desc"` | ||||
| 	Datetime string `json:"datetime"` | ||||
| 	IsRead   bool   `json:"is_read"` | ||||
| 	Sender   User   `json:"sender"` | ||||
| } | ||||
|  | ||||
| type User struct { | ||||
| 	Name         string `json:"Name"` | ||||
| 	EmailAddress string `json:"EmailAddress"` | ||||
| } | ||||
|  | ||||
| type emailRequest struct { | ||||
| 	Keyword     string `json:"keyword"` | ||||
| 	Tag         string `json:"tag"` | ||||
| 	CurrentPage int    `json:"current_page"` | ||||
| 	PageSize    int    `json:"page_size"` | ||||
| } | ||||
|  | ||||
| func EmailList(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	var lst []*emilItem | ||||
| 	reqBytes, err := io.ReadAll(req.Body) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 	} | ||||
| 	var retData emailRequest | ||||
| 	err = json.Unmarshal(reqBytes, &retData) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	offset := 0 | ||||
| 	if retData.CurrentPage >= 1 { | ||||
| 		offset = (retData.CurrentPage - 1) * retData.PageSize | ||||
| 	} | ||||
|  | ||||
| 	if retData.PageSize == 0 { | ||||
| 		retData.PageSize = 15 | ||||
| 	} | ||||
|  | ||||
| 	emailList, total := list.GetEmailList(ctx, retData.Tag, retData.Keyword, offset, retData.PageSize) | ||||
|  | ||||
| 	for _, email := range emailList { | ||||
| 		var sender User | ||||
| 		_ = json.Unmarshal([]byte(email.Sender), &sender) | ||||
|  | ||||
| 		lst = append(lst, &emilItem{ | ||||
| 			ID:       email.Id, | ||||
| 			Title:    email.Subject, | ||||
| 			Desc:     email.Text.String, | ||||
| 			Datetime: email.SendDate.Format("2006-01-02 15:04:05"), | ||||
| 			IsRead:   email.IsRead == 1, | ||||
| 			Sender:   sender, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	ret := emailListResponse{ | ||||
| 		CurrentPage: retData.CurrentPage, | ||||
| 		TotalPage:   cast.ToInt(math.Ceil(cast.ToFloat64(total) / cast.ToFloat64(retData.PageSize))), | ||||
| 		List:        lst, | ||||
| 	} | ||||
| 	response.NewSuccessResponse(ret).FPrint(w) | ||||
| } | ||||
							
								
								
									
										203
									
								
								server/controllers/email/send.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								server/controllers/email/send.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,203 @@ | ||||
| package email | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"pmail/config" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/hooks" | ||||
| 	"pmail/i18n" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/smtp_server" | ||||
| 	"pmail/utils/async" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type sendRequest struct { | ||||
| 	ReplyTo     []user       `json:"reply_to"` | ||||
| 	From        user         `json:"from"` | ||||
| 	To          []user       `json:"to"` | ||||
| 	Bcc         []user       `json:"bcc"` | ||||
| 	Cc          []user       `json:"cc"` | ||||
| 	Subject     string       `json:"subject"` | ||||
| 	Text        string       `json:"text"`   // Plaintext message (optional) | ||||
| 	HTML        string       `json:"html"`   // Html message (optional) | ||||
| 	Sender      user         `json:"sender"` // override From as SMTP envelope sender (optional) | ||||
| 	ReadReceipt []string     `json:"read_receipt"` | ||||
| 	Attachments []attachment `json:"attrs"` | ||||
| } | ||||
|  | ||||
| type user struct { | ||||
| 	Name  string `json:"name"` | ||||
| 	Email string `json:"email"` | ||||
| } | ||||
|  | ||||
| type attachment struct { | ||||
| 	Name string `json:"name"` | ||||
| 	Data string `json:"data"` | ||||
| } | ||||
|  | ||||
| func Send(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	reqBytes, err := io.ReadAll(req.Body) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 		response.NewErrorResponse(response.ParamsError, "params error", err.Error()).FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
| 	log.WithContext(ctx).Infof("发送邮件") | ||||
|  | ||||
| 	var reqData sendRequest | ||||
| 	err = json.Unmarshal(reqBytes, &reqData) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("%+v", err) | ||||
| 		response.NewErrorResponse(response.ParamsError, "params error", err.Error()).FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if reqData.From.Email == "" && reqData.From.Name != "" { | ||||
| 		reqData.From.Email = reqData.From.Name + "@" + config.Instance.Domain | ||||
| 	} | ||||
|  | ||||
| 	if reqData.From.Email == "" { | ||||
| 		response.NewErrorResponse(response.ParamsError, "发件人必填", "发件人必填").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if reqData.Subject == "" { | ||||
| 		response.NewErrorResponse(response.ParamsError, "邮件标题必填", "邮件标题必填").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if len(reqData.To) <= 0 { | ||||
| 		response.NewErrorResponse(response.ParamsError, "收件人必填", "收件人必填").FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	e := &parsemail.Email{} | ||||
|  | ||||
| 	for _, to := range reqData.To { | ||||
| 		e.To = append(e.To, &parsemail.User{ | ||||
| 			Name:         to.Name, | ||||
| 			EmailAddress: to.Email, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	for _, bcc := range reqData.Bcc { | ||||
| 		e.Bcc = append(e.Bcc, &parsemail.User{ | ||||
| 			Name:         bcc.Name, | ||||
| 			EmailAddress: bcc.Email, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	for _, cc := range reqData.Cc { | ||||
| 		e.Cc = append(e.Cc, &parsemail.User{ | ||||
| 			Name:         cc.Name, | ||||
| 			EmailAddress: cc.Email, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	e.From = &parsemail.User{ | ||||
| 		Name:         reqData.From.Name, | ||||
| 		EmailAddress: reqData.From.Email, | ||||
| 	} | ||||
| 	e.Text = []byte(reqData.Text) | ||||
| 	e.HTML = []byte(reqData.HTML) | ||||
| 	e.Subject = reqData.Subject | ||||
| 	for _, att := range reqData.Attachments { | ||||
| 		att.Data = strings.TrimPrefix(att.Data, "data:") | ||||
| 		infos := strings.Split(att.Data, ";") | ||||
| 		contentType := infos[0] | ||||
| 		content := strings.TrimPrefix(infos[1], "base64,") | ||||
| 		decoded, err := base64.StdEncoding.DecodeString(content) | ||||
| 		if err != nil { | ||||
| 			log.WithContext(ctx).Errorf("附件解码错误!%v", err) | ||||
| 			response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "att_err"), err.Error()).FPrint(w) | ||||
| 			return | ||||
| 		} | ||||
| 		e.Attachments = append(e.Attachments, &parsemail.Attachment{ | ||||
| 			Filename:    att.Name, | ||||
| 			ContentType: contentType, | ||||
| 			Content:     decoded, | ||||
| 		}) | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	for _, hook := range hooks.HookList { | ||||
| 		if hook == nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		async.New(ctx).Process(func() { | ||||
| 			hook.SendBefore(ctx, e) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// 邮件落库 | ||||
| 	sql := "INSERT INTO email (type,subject, reply_to, from_name, from_address, `to`, bcc, cc, text, html, sender, attachments,spf_check, dkim_check, create_time,send_user_id,error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" | ||||
| 	sqlRes, sqlerr := mysql.Instance.Exec(mysql.WithContext(ctx, sql), | ||||
| 		1, | ||||
| 		e.Subject, | ||||
| 		json2string(e.ReplyTo), | ||||
| 		e.From.Name, | ||||
| 		e.From.EmailAddress, | ||||
| 		json2string(e.To), | ||||
| 		json2string(e.Bcc), | ||||
| 		json2string(e.Cc), | ||||
| 		e.Text, | ||||
| 		e.HTML, | ||||
| 		json2string(e.Sender), | ||||
| 		json2string(e.Attachments), | ||||
| 		1, | ||||
| 		1, | ||||
| 		time.Now(), | ||||
| 		ctx.UserInfo.ID, | ||||
| 		"", | ||||
| 	) | ||||
| 	emailId, _ := sqlRes.LastInsertId() | ||||
|  | ||||
| 	if sqlerr != nil || emailId <= 0 { | ||||
| 		log.Println("mysql insert error:", err.Error()) | ||||
| 		response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "send_fail"), err.Error()).FPrint(w) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	async.New(ctx).Process(func() { | ||||
| 		errMsg := "" | ||||
| 		err, sendErr := smtp_server.Send(ctx, e) | ||||
|  | ||||
| 		for _, hook := range hooks.HookList { | ||||
| 			if hook == nil { | ||||
| 				continue | ||||
| 			} | ||||
| 			async.New(ctx).Process(func() { | ||||
| 				hook.SendAfter(ctx, e, sendErr) | ||||
| 			}) | ||||
| 		} | ||||
|  | ||||
| 		if err != nil { | ||||
| 			errMsg = err.Error() | ||||
| 			_, err := mysql.Instance.Exec(mysql.WithContext(ctx, "update email set status =2 ,error=? where id = ? "), errMsg, emailId) | ||||
| 			if err != nil { | ||||
| 				log.WithContext(ctx).Errorf("sql Error :%+v", err) | ||||
| 			} | ||||
| 		} else { | ||||
| 			_, err := mysql.Instance.Exec(mysql.WithContext(ctx, "update email set status =1  where id = ? "), emailId) | ||||
| 			if err != nil { | ||||
| 				log.WithContext(ctx).Errorf("sql Error :%+v", err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	}) | ||||
|  | ||||
| 	response.NewSuccessResponse(i18n.GetText(ctx.Lang, "succ")).FPrint(w) | ||||
| } | ||||
|  | ||||
| func json2string(d any) string { | ||||
| 	by, _ := json.Marshal(d) | ||||
| 	return string(by) | ||||
| } | ||||
							
								
								
									
										39
									
								
								server/controllers/group.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								server/controllers/group.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/i18n" | ||||
| ) | ||||
|  | ||||
| type groupItem struct { | ||||
| 	Label    string       `json:"label"` | ||||
| 	Tag      string       `json:"tag"` | ||||
| 	Children []*groupItem `json:"children"` | ||||
| } | ||||
|  | ||||
| func GetUserGroup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
|  | ||||
| 	retData := []*groupItem{ | ||||
| 		{ | ||||
| 			Label: i18n.GetText(ctx.Lang, "all_email"), | ||||
| 			Children: []*groupItem{ | ||||
| 				{ | ||||
| 					Label: i18n.GetText(ctx.Lang, "inbox"), | ||||
| 					Tag:   dto.SearchTag{Type: 0, Status: -1}.ToString(), | ||||
| 				}, | ||||
| 				{ | ||||
| 					Label: i18n.GetText(ctx.Lang, "outbox"), | ||||
| 					Tag:   dto.SearchTag{Type: 1, Status: 1}.ToString(), | ||||
| 				}, | ||||
| 				{ | ||||
| 					Label: i18n.GetText(ctx.Lang, "sketch"), | ||||
| 					Tag:   dto.SearchTag{Type: 1, Status: 0}.ToString(), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	response.NewSuccessResponse(retData).FPrint(w) | ||||
| } | ||||
							
								
								
									
										59
									
								
								server/controllers/login.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								server/controllers/login.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"crypto/md5" | ||||
| 	"database/sql" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/i18n" | ||||
| 	"pmail/models" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/session" | ||||
| ) | ||||
|  | ||||
| type loginRequest struct { | ||||
| 	Account  string `json:"account"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| func Login(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
|  | ||||
| 	reqBytes, err := io.ReadAll(req.Body) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("%+v", err) | ||||
| 	} | ||||
| 	var retData loginRequest | ||||
| 	err = json.Unmarshal(reqBytes, &retData) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	var user models.User | ||||
|  | ||||
| 	encodePwd := md5Encode(md5Encode(retData.Password+"pmail") + "pmail2023") | ||||
|  | ||||
| 	err = mysql.Instance.Get(&user, mysql.WithContext(ctx, "select * from user where account =? and password =?"), | ||||
| 		retData.Account, encodePwd) | ||||
| 	if err != nil && err != sql.ErrNoRows { | ||||
| 		log.Errorf("%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	if user.ID != 0 { | ||||
| 		userStr, _ := json.Marshal(user) | ||||
| 		session.Instance.Put(req.Context(), "user", string(userStr)) | ||||
| 		response.NewSuccessResponse("").FPrint(w) | ||||
| 	} else { | ||||
| 		response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func md5Encode(str string) string { | ||||
| 	h := md5.New() | ||||
| 	h.Write([]byte(str)) | ||||
| 	return hex.EncodeToString(h.Sum(nil)) | ||||
| } | ||||
							
								
								
									
										13
									
								
								server/controllers/ping.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/controllers/ping.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| ) | ||||
|  | ||||
| func Ping(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	response.NewSuccessResponse("pong").FPrint(w) | ||||
| 	log.WithContext(ctx).Info("pong") | ||||
| } | ||||
							
								
								
									
										41
									
								
								server/controllers/settings.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								server/controllers/settings.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| package controllers | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/i18n" | ||||
| 	"pmail/mysql" | ||||
| ) | ||||
|  | ||||
| type modifyPasswordRequest struct { | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| func ModifyPassword(ctx *dto.Context, w http.ResponseWriter, req *http.Request) { | ||||
| 	reqBytes, err := io.ReadAll(req.Body) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("%+v", err) | ||||
| 	} | ||||
| 	var retData modifyPasswordRequest | ||||
| 	err = json.Unmarshal(reqBytes, &retData) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	if retData.Password != "" { | ||||
| 		encodePwd := md5Encode(md5Encode(retData.Password+"pmail") + "pmail2023") | ||||
|  | ||||
| 		_, err := mysql.Instance.Exec(mysql.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserInfo.ID) | ||||
| 		if err != nil { | ||||
| 			response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	response.NewSuccessResponse(i18n.GetText(ctx.Lang, "succ")).FPrint(w) | ||||
| } | ||||
							
								
								
									
										32
									
								
								server/dto/context.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								server/dto/context.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| package dto | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"pmail/models" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	LogID = "LogID" | ||||
| ) | ||||
|  | ||||
| type Context struct { | ||||
| 	context.Context | ||||
| 	UserInfo *models.User | ||||
| 	values   map[string]any | ||||
| 	Lang     string | ||||
| } | ||||
|  | ||||
| func (c *Context) SetValue(key string, value any) { | ||||
| 	if c.values == nil { | ||||
| 		c.values = map[string]any{} | ||||
| 	} | ||||
| 	c.values[key] = value | ||||
|  | ||||
| } | ||||
|  | ||||
| func (c Context) GetValue(key string) any { | ||||
| 	if c.values == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return c.values[key] | ||||
| } | ||||
							
								
								
									
										97
									
								
								server/dto/parsemail/dkim.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								server/dto/parsemail/dkim.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| package parsemail | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"crypto" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"fmt" | ||||
| 	"github.com/emersion/go-msgauth/dkim" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"golang.org/x/crypto/ed25519" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"pmail/config" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Dkim struct { | ||||
| 	privateKey crypto.Signer | ||||
| } | ||||
|  | ||||
| var instance *Dkim | ||||
|  | ||||
| func Init() { | ||||
| 	privateKey, err := loadPrivateKey(config.Instance.DkimPrivateKeyPath) | ||||
| 	if err != nil { | ||||
| 		panic("DKIM load fail! Please set dkim!  dkim私钥加载失败!请先设置dkim秘钥") | ||||
| 	} | ||||
|  | ||||
| 	instance = &Dkim{ | ||||
| 		privateKey: privateKey, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func loadPrivateKey(path string) (crypto.Signer, error) { | ||||
| 	b, err := os.ReadFile(path) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	block, _ := pem.Decode(b) | ||||
| 	if block == nil { | ||||
| 		return nil, fmt.Errorf("no PEM data found") | ||||
| 	} | ||||
|  | ||||
| 	switch strings.ToUpper(block.Type) { | ||||
| 	case "PRIVATE KEY": | ||||
| 		k, err := x509.ParsePKCS8PrivateKey(block.Bytes) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		return k.(crypto.Signer), nil | ||||
| 	case "RSA PRIVATE KEY": | ||||
| 		return x509.ParsePKCS1PrivateKey(block.Bytes) | ||||
| 	case "EDDSA PRIVATE KEY": | ||||
| 		if len(block.Bytes) != ed25519.PrivateKeySize { | ||||
| 			return nil, fmt.Errorf("invalid Ed25519 private key size") | ||||
| 		} | ||||
| 		return ed25519.PrivateKey(block.Bytes), nil | ||||
| 	default: | ||||
| 		return nil, fmt.Errorf("unknown private key type: '%v'", block.Type) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (p *Dkim) Sign(msgData string) []byte { | ||||
| 	var b bytes.Buffer | ||||
| 	r := strings.NewReader(msgData) | ||||
|  | ||||
| 	options := &dkim.SignOptions{ | ||||
| 		Domain:   config.Instance.Domain, | ||||
| 		Selector: "default", | ||||
| 		Signer:   p.privateKey, | ||||
| 	} | ||||
|  | ||||
| 	if err := dkim.Sign(&b, r, options); err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| 	return b.Bytes() | ||||
| } | ||||
|  | ||||
| func Check(mail io.Reader) bool { | ||||
|  | ||||
| 	verifications, err := dkim.Verify(mail) | ||||
| 	if err != nil { | ||||
| 		log.Println(err) | ||||
| 	} | ||||
|  | ||||
| 	for _, v := range verifications { | ||||
| 		if v.Err == nil { | ||||
| 			log.Println("Valid signature for:", v.Domain) | ||||
| 		} else { | ||||
| 			log.Println("Invalid signature for:", v.Domain, v.Err) | ||||
| 			return false | ||||
| 		} | ||||
| 	} | ||||
| 	return true | ||||
| } | ||||
							
								
								
									
										247
									
								
								server/dto/parsemail/email.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								server/dto/parsemail/email.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,247 @@ | ||||
| package parsemail | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"github.com/emersion/go-message" | ||||
| 	_ "github.com/emersion/go-message/charset" | ||||
| 	"github.com/emersion/go-message/mail" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net/textproto" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/utils/array" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type User struct { | ||||
| 	EmailAddress string | ||||
| 	Name         string | ||||
| } | ||||
|  | ||||
| type Attachment struct { | ||||
| 	Filename    string | ||||
| 	ContentType string | ||||
| 	Content     []byte | ||||
| 	ContentID   string | ||||
| } | ||||
|  | ||||
| // Email is the type used for email messages | ||||
| type Email struct { | ||||
| 	ReplyTo     []*User | ||||
| 	From        *User | ||||
| 	To          []*User | ||||
| 	Bcc         []*User | ||||
| 	Cc          []*User | ||||
| 	Subject     string | ||||
| 	Text        []byte // Plaintext message (optional) | ||||
| 	HTML        []byte // Html message (optional) | ||||
| 	Sender      *User  // override From as SMTP envelope sender (optional) | ||||
| 	Headers     textproto.MIMEHeader | ||||
| 	Attachments []*Attachment | ||||
| 	ReadReceipt []string | ||||
| 	Date        string | ||||
| } | ||||
|  | ||||
| func NewEmailFromReader(r io.Reader) *Email { | ||||
| 	ret := &Email{} | ||||
| 	m, err := message.Read(r) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("email解析错误! Error %+v", err) | ||||
| 	} | ||||
|  | ||||
| 	ret.From = buildUser(m.Header.Get("From")) | ||||
| 	ret.To = buildUsers(m.Header.Values("To")) | ||||
| 	ret.Cc = buildUsers(m.Header.Values("Cc")) | ||||
| 	ret.ReplyTo = buildUsers(m.Header.Values("ReplyTo")) | ||||
| 	ret.Sender = buildUser(m.Header.Get("Sender")) | ||||
| 	if ret.Sender == nil { | ||||
| 		ret.Sender = ret.From | ||||
| 	} | ||||
|  | ||||
| 	ret.Subject, _ = m.Header.Text("Subject") | ||||
|  | ||||
| 	sendTime, err := time.Parse(time.RFC1123Z, m.Header.Get("Date")) | ||||
| 	if err != nil { | ||||
| 		sendTime = time.Now() | ||||
| 	} | ||||
| 	ret.Date = sendTime.Format(time.DateTime) | ||||
| 	m.Walk(func(path []int, entity *message.Entity, err error) error { | ||||
| 		return formatContent(entity, ret) | ||||
| 	}) | ||||
| 	return ret | ||||
| } | ||||
|  | ||||
| func formatContent(entity *message.Entity, ret *Email) error { | ||||
| 	contentType, p, err := entity.Header.ContentType() | ||||
|  | ||||
| 	if err != nil { | ||||
| 		log.Errorf("email read error! %+v", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	switch contentType { | ||||
| 	case "multipart/alternative": | ||||
| 	case "multipart/mixed": | ||||
| 	case "text/plain": | ||||
| 		ret.Text, _ = io.ReadAll(entity.Body) | ||||
| 	case "text/html": | ||||
| 		ret.HTML, _ = io.ReadAll(entity.Body) | ||||
| 	case "multipart/related": | ||||
| 		entity.Walk(func(path []int, entity *message.Entity, err error) error { | ||||
| 			if t, _, _ := entity.Header.ContentType(); t == "multipart/related" { | ||||
| 				return nil | ||||
| 			} | ||||
| 			return formatContent(entity, ret) | ||||
| 		}) | ||||
| 	default: | ||||
| 		c, _ := io.ReadAll(entity.Body) | ||||
| 		fileName := p["name"] | ||||
| 		if fileName == "" { | ||||
| 			contentDisposition := entity.Header.Get("Content-Disposition") | ||||
| 			r := regexp.MustCompile("filename=(.*)") | ||||
| 			matchs := r.FindStringSubmatch(contentDisposition) | ||||
| 			if len(matchs) == 2 { | ||||
| 				fileName = matchs[1] | ||||
| 			} else { | ||||
| 				fileName = "no_name_file" | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		ret.Attachments = append(ret.Attachments, &Attachment{ | ||||
| 			Filename:    fileName, | ||||
| 			ContentType: contentType, | ||||
| 			Content:     c, | ||||
| 			ContentID:   strings.TrimPrefix(strings.TrimSuffix(entity.Header.Get("Content-Id"), ">"), "<"), | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func buildUser(str string) *User { | ||||
| 	if str == "" { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	ret := &User{} | ||||
| 	args := strings.Split(str, " ") | ||||
| 	if len(args) == 1 { | ||||
| 		ret.EmailAddress = str | ||||
| 		return ret | ||||
| 	} | ||||
|  | ||||
| 	if len(args) > 2 { | ||||
| 		targs := []string{ | ||||
| 			array.Join(args[0:len(args)-1], " "), | ||||
| 			args[len(args)-1], | ||||
| 		} | ||||
| 		args = targs | ||||
| 	} | ||||
|  | ||||
| 	args[0] = strings.Trim(args[0], "\"") | ||||
| 	args[1] = strings.TrimPrefix(args[1], "<") | ||||
| 	args[1] = strings.TrimSuffix(args[1], ">") | ||||
|  | ||||
| 	name, err := (&WordDecoder{}).Decode(strings.ReplaceAll(args[0], "\"", "")) | ||||
| 	if err == nil { | ||||
| 		ret.Name = name | ||||
| 	} else { | ||||
| 		ret.Name = args[0] | ||||
| 	} | ||||
| 	ret.EmailAddress = args[1] | ||||
| 	return ret | ||||
| } | ||||
|  | ||||
| func buildUsers(str []string) []*User { | ||||
| 	var ret []*User | ||||
| 	for _, s1 := range str { | ||||
| 		for _, s := range strings.Split(s1, ",") { | ||||
| 			s = strings.TrimSpace(s) | ||||
| 			ret = append(ret, buildUser(s)) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return ret | ||||
| } | ||||
|  | ||||
| func (e *Email) BuildBytes(ctx *dto.Context) []byte { | ||||
| 	var b bytes.Buffer | ||||
|  | ||||
| 	from := []*mail.Address{{e.From.Name, e.From.EmailAddress}} | ||||
| 	to := []*mail.Address{} | ||||
| 	for _, user := range e.To { | ||||
| 		to = append(to, &mail.Address{ | ||||
| 			Name:    user.Name, | ||||
| 			Address: user.EmailAddress, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Create our mail header | ||||
| 	var h mail.Header | ||||
| 	h.SetDate(time.Now()) | ||||
| 	h.SetAddressList("From", from) | ||||
| 	h.SetAddressList("To", to) | ||||
| 	h.SetText("Subject", e.Subject) | ||||
| 	if len(e.Cc) != 0 { | ||||
| 		cc := []*mail.Address{} | ||||
| 		for _, user := range e.Cc { | ||||
| 			cc = append(cc, &mail.Address{ | ||||
| 				Name:    user.Name, | ||||
| 				Address: user.EmailAddress, | ||||
| 			}) | ||||
| 		} | ||||
| 		h.SetAddressList("Cc", cc) | ||||
| 	} | ||||
|  | ||||
| 	// Create a new mail writer | ||||
| 	mw, err := mail.CreateWriter(&b, h) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	// Create a text part | ||||
| 	tw, err := mw.CreateInline() | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Fatal(err) | ||||
| 	} | ||||
| 	var th mail.InlineHeader | ||||
| 	th.Set("Content-Type", "text/plain") | ||||
| 	w, err := tw.CreatePart(th) | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| 	io.WriteString(w, string(e.Text)) | ||||
| 	w.Close() | ||||
|  | ||||
| 	var html mail.InlineHeader | ||||
| 	html.Set("Content-Type", "text/html") | ||||
| 	w, err = tw.CreatePart(html) | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| 	io.WriteString(w, string(e.HTML)) | ||||
| 	w.Close() | ||||
|  | ||||
| 	tw.Close() | ||||
|  | ||||
| 	// Create an attachment | ||||
| 	for _, attachment := range e.Attachments { | ||||
| 		var ah mail.AttachmentHeader | ||||
| 		ah.Set("Content-Type", attachment.ContentType) | ||||
| 		ah.SetFilename(attachment.Filename) | ||||
| 		w, err = mw.CreateAttachment(ah) | ||||
| 		if err != nil { | ||||
| 			log.WithContext(ctx).Fatal(err) | ||||
| 			continue | ||||
| 		} | ||||
| 		w.Write(attachment.Content) | ||||
| 		w.Close() | ||||
| 	} | ||||
|  | ||||
| 	mw.Close() | ||||
|  | ||||
| 	// dkim 签名后返回 | ||||
| 	return instance.Sign(b.String()) | ||||
| } | ||||
							
								
								
									
										41
									
								
								server/dto/parsemail/email_decode_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								server/dto/parsemail/email_decode_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| package parsemail | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func TestDecodeEmailContentFromTxt(t *testing.T) { | ||||
|  | ||||
| 	c, _ := os.ReadFile("../../docs/gmail/带附件带图片.txt") | ||||
|  | ||||
| 	r := strings.NewReader(string(c)) | ||||
|  | ||||
| 	email := NewEmailFromReader(r) | ||||
|  | ||||
| 	fmt.Println(email) | ||||
| } | ||||
|  | ||||
| func TestDecodeEmailContentFromTxt3(t *testing.T) { | ||||
|  | ||||
| 	c, _ := os.ReadFile("../../docs/pmail/带附件.txt") | ||||
|  | ||||
| 	r := strings.NewReader(string(c)) | ||||
|  | ||||
| 	email := NewEmailFromReader(r) | ||||
|  | ||||
| 	fmt.Println(email) | ||||
| } | ||||
|  | ||||
| func TestDecodeEmailContentFromTxt2(t *testing.T) { | ||||
| 	c, _ := os.ReadFile("../../docs/qqemail/带图片格式排版.txt") | ||||
|  | ||||
| 	r := strings.NewReader(string(c)) | ||||
|  | ||||
| 	email := NewEmailFromReader(r) | ||||
|  | ||||
| 	fmt.Println(email) | ||||
|  | ||||
| } | ||||
							
								
								
									
										43
									
								
								server/dto/parsemail/email_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								server/dto/parsemail/email_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| package parsemail | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func TestEmail_domainMatch(t *testing.T) { | ||||
| 	//e := &Email{} | ||||
| 	//dnsNames := []string{ | ||||
| 	//	"*.mail.qq.com", | ||||
| 	//	"993.dav.qq.com", | ||||
| 	//	"993.eas.qq.com", | ||||
| 	//	"993.imap.qq.com", | ||||
| 	//	"993.pop.qq.com", | ||||
| 	//	"993.smtp.qq.com", | ||||
| 	//	"imap.qq.com", | ||||
| 	//	"mx1.qq.com", | ||||
| 	//	"mx2.qq.com", | ||||
| 	//	"mx3.qq.com", | ||||
| 	//	"pop.qq.com", | ||||
| 	//	"smtp.qq.com", | ||||
| 	//	"mail.qq.com", | ||||
| 	//} | ||||
| 	// | ||||
| 	//fmt.Println(e.domainMatch("", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("xjiangwei.cn", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("qq.com", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("test.aaa.mail.qq.com", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("smtp.qq.com", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("pop.qq.com", dnsNames)) | ||||
| 	//fmt.Println(e.domainMatch("test.mail.qq.com", dnsNames)) | ||||
|  | ||||
| } | ||||
|  | ||||
| func Test_buildUser(t *testing.T) { | ||||
| 	u := buildUser("Jinnrry N <jiangwei1995910@gmail.com>") | ||||
| 	if u.EmailAddress != "jiangwei1995910@gmail.com" { | ||||
| 		t.Error("error") | ||||
| 	} | ||||
| 	if u.Name != "Jinnrry N" { | ||||
| 		t.Error("error") | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										426
									
								
								server/dto/parsemail/encodedword.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										426
									
								
								server/dto/parsemail/encodedword.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,426 @@ | ||||
| package parsemail | ||||
|  | ||||
| // copy from https://cs.opensource.google/go/go/+/refs/tags/go1.20.4:src/mime/encodedword.go | ||||
| // Golang官方库的解码函数不支持中文编码,此处实现支持了中文gbk和gb18030编码 | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"golang.org/x/text/encoding/simplifiedchinese" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| 	"unicode" | ||||
| 	"unicode/utf8" | ||||
| ) | ||||
|  | ||||
| // A WordEncoder is an RFC 2047 encoded-word encoder. | ||||
| type WordEncoder byte | ||||
|  | ||||
| const ( | ||||
| 	// BEncoding represents Base64 encoding scheme as defined by RFC 2045. | ||||
| 	BEncoding = WordEncoder('b') | ||||
| 	// QEncoding represents the Q-encoding scheme as defined by RFC 2047. | ||||
| 	QEncoding = WordEncoder('q') | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	errInvalidWord = errors.New("mime: invalid RFC 2047 encoded-word") | ||||
| ) | ||||
|  | ||||
| // Encode returns the encoded-word form of s. If s is ASCII without special | ||||
| // characters, it is returned unchanged. The provided charset is the IANA | ||||
| // charset name of s. It is case insensitive. | ||||
| func (e WordEncoder) Encode(charset, s string) string { | ||||
| 	if !needsEncoding(s) { | ||||
| 		return s | ||||
| 	} | ||||
| 	return e.encodeWord(charset, s) | ||||
| } | ||||
|  | ||||
| func needsEncoding(s string) bool { | ||||
| 	for _, b := range s { | ||||
| 		if (b < ' ' || b > '~') && b != '\t' { | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| // encodeWord encodes a string into an encoded-word. | ||||
| func (e WordEncoder) encodeWord(charset, s string) string { | ||||
| 	var buf strings.Builder | ||||
| 	// Could use a hint like len(s)*3, but that's not enough for cases | ||||
| 	// with word splits and too much for simpler inputs. | ||||
| 	// 48 is close to maxEncodedWordLen/2, but adjusted to allocator size class. | ||||
| 	buf.Grow(48) | ||||
|  | ||||
| 	e.openWord(&buf, charset) | ||||
| 	if e == BEncoding { | ||||
| 		e.bEncode(&buf, charset, s) | ||||
| 	} else { | ||||
| 		e.qEncode(&buf, charset, s) | ||||
| 	} | ||||
| 	closeWord(&buf) | ||||
|  | ||||
| 	return buf.String() | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	// The maximum length of an encoded-word is 75 characters. | ||||
| 	// See RFC 2047, section 2. | ||||
| 	maxEncodedWordLen = 75 | ||||
| 	// maxContentLen is how much content can be encoded, ignoring the header and | ||||
| 	// 2-byte footer. | ||||
| 	maxContentLen = maxEncodedWordLen - len("=?UTF-8?q?") - len("?=") | ||||
| ) | ||||
|  | ||||
| var maxBase64Len = base64.StdEncoding.DecodedLen(maxContentLen) | ||||
|  | ||||
| // bEncode encodes s using base64 encoding and writes it to buf. | ||||
| func (e WordEncoder) bEncode(buf *strings.Builder, charset, s string) { | ||||
| 	w := base64.NewEncoder(base64.StdEncoding, buf) | ||||
| 	// If the charset is not UTF-8 or if the content is short, do not bother | ||||
| 	// splitting the encoded-word. | ||||
| 	if !isUTF8(charset) || base64.StdEncoding.EncodedLen(len(s)) <= maxContentLen { | ||||
| 		io.WriteString(w, s) | ||||
| 		w.Close() | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var currentLen, last, runeLen int | ||||
| 	for i := 0; i < len(s); i += runeLen { | ||||
| 		// Multi-byte characters must not be split across encoded-words. | ||||
| 		// See RFC 2047, section 5.3. | ||||
| 		_, runeLen = utf8.DecodeRuneInString(s[i:]) | ||||
|  | ||||
| 		if currentLen+runeLen <= maxBase64Len { | ||||
| 			currentLen += runeLen | ||||
| 		} else { | ||||
| 			io.WriteString(w, s[last:i]) | ||||
| 			w.Close() | ||||
| 			e.splitWord(buf, charset) | ||||
| 			last = i | ||||
| 			currentLen = runeLen | ||||
| 		} | ||||
| 	} | ||||
| 	io.WriteString(w, s[last:]) | ||||
| 	w.Close() | ||||
| } | ||||
|  | ||||
| // qEncode encodes s using Q encoding and writes it to buf. It splits the | ||||
| // encoded-words when necessary. | ||||
| func (e WordEncoder) qEncode(buf *strings.Builder, charset, s string) { | ||||
| 	// We only split encoded-words when the charset is UTF-8. | ||||
| 	if !isUTF8(charset) { | ||||
| 		writeQString(buf, s) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var currentLen, runeLen int | ||||
| 	for i := 0; i < len(s); i += runeLen { | ||||
| 		b := s[i] | ||||
| 		// Multi-byte characters must not be split across encoded-words. | ||||
| 		// See RFC 2047, section 5.3. | ||||
| 		var encLen int | ||||
| 		if b >= ' ' && b <= '~' && b != '=' && b != '?' && b != '_' { | ||||
| 			runeLen, encLen = 1, 1 | ||||
| 		} else { | ||||
| 			_, runeLen = utf8.DecodeRuneInString(s[i:]) | ||||
| 			encLen = 3 * runeLen | ||||
| 		} | ||||
|  | ||||
| 		if currentLen+encLen > maxContentLen { | ||||
| 			e.splitWord(buf, charset) | ||||
| 			currentLen = 0 | ||||
| 		} | ||||
| 		writeQString(buf, s[i:i+runeLen]) | ||||
| 		currentLen += encLen | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // writeQString encodes s using Q encoding and writes it to buf. | ||||
| func writeQString(buf *strings.Builder, s string) { | ||||
| 	for i := 0; i < len(s); i++ { | ||||
| 		switch b := s[i]; { | ||||
| 		case b == ' ': | ||||
| 			buf.WriteByte('_') | ||||
| 		case b >= '!' && b <= '~' && b != '=' && b != '?' && b != '_': | ||||
| 			buf.WriteByte(b) | ||||
| 		default: | ||||
| 			buf.WriteByte('=') | ||||
| 			buf.WriteByte(upperhex[b>>4]) | ||||
| 			buf.WriteByte(upperhex[b&0x0f]) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // openWord writes the beginning of an encoded-word into buf. | ||||
| func (e WordEncoder) openWord(buf *strings.Builder, charset string) { | ||||
| 	buf.WriteString("=?") | ||||
| 	buf.WriteString(charset) | ||||
| 	buf.WriteByte('?') | ||||
| 	buf.WriteByte(byte(e)) | ||||
| 	buf.WriteByte('?') | ||||
| } | ||||
|  | ||||
| // closeWord writes the end of an encoded-word into buf. | ||||
| func closeWord(buf *strings.Builder) { | ||||
| 	buf.WriteString("?=") | ||||
| } | ||||
|  | ||||
| // splitWord closes the current encoded-word and opens a new one. | ||||
| func (e WordEncoder) splitWord(buf *strings.Builder, charset string) { | ||||
| 	closeWord(buf) | ||||
| 	buf.WriteByte(' ') | ||||
| 	e.openWord(buf, charset) | ||||
| } | ||||
|  | ||||
| func isUTF8(charset string) bool { | ||||
| 	return strings.EqualFold(charset, "UTF-8") | ||||
| } | ||||
|  | ||||
| const upperhex = "0123456789ABCDEF" | ||||
|  | ||||
| // A WordDecoder decodes MIME headers containing RFC 2047 encoded-words. | ||||
| type WordDecoder struct { | ||||
| 	// CharsetReader, if non-nil, defines a function to generate | ||||
| 	// charset-conversion readers, converting from the provided | ||||
| 	// charset into UTF-8. | ||||
| 	// Charsets are always lower-case. utf-8, iso-8859-1 and us-ascii charsets | ||||
| 	// are handled by default. | ||||
| 	// One of the CharsetReader's result values must be non-nil. | ||||
| 	CharsetReader func(charset string, input io.Reader) (io.Reader, error) | ||||
| } | ||||
|  | ||||
| // Decode decodes an RFC 2047 encoded-word. | ||||
| func (d *WordDecoder) Decode(word string) (string, error) { | ||||
| 	// See https://tools.ietf.org/html/rfc2047#section-2 for details. | ||||
| 	// Our decoder is permissive, we accept empty encoded-text. | ||||
| 	if len(word) < 8 || !strings.HasPrefix(word, "=?") || !strings.HasSuffix(word, "?=") || strings.Count(word, "?") != 4 { | ||||
| 		return "", errInvalidWord | ||||
| 	} | ||||
| 	word = word[2 : len(word)-2] | ||||
|  | ||||
| 	// split word "UTF-8?q?text" into "UTF-8", 'q', and "text" | ||||
| 	charset, text, _ := strings.Cut(word, "?") | ||||
| 	if charset == "" { | ||||
| 		return "", errInvalidWord | ||||
| 	} | ||||
| 	encoding, text, _ := strings.Cut(text, "?") | ||||
| 	if len(encoding) != 1 { | ||||
| 		return "", errInvalidWord | ||||
| 	} | ||||
|  | ||||
| 	content, err := decode(encoding[0], text) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
|  | ||||
| 	var buf strings.Builder | ||||
| 	if err := d.convert(&buf, charset, content); err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	return buf.String(), nil | ||||
| } | ||||
|  | ||||
| // DecodeHeader decodes all encoded-words of the given string. It returns an | ||||
| // error if and only if CharsetReader of d returns an error. | ||||
| func (d *WordDecoder) DecodeHeader(header string) (string, error) { | ||||
| 	// If there is no encoded-word, returns before creating a buffer. | ||||
| 	i := strings.Index(header, "=?") | ||||
| 	if i == -1 { | ||||
| 		return header, nil | ||||
| 	} | ||||
|  | ||||
| 	var buf strings.Builder | ||||
|  | ||||
| 	buf.WriteString(header[:i]) | ||||
| 	header = header[i:] | ||||
|  | ||||
| 	betweenWords := false | ||||
| 	for { | ||||
| 		start := strings.Index(header, "=?") | ||||
| 		if start == -1 { | ||||
| 			break | ||||
| 		} | ||||
| 		cur := start + len("=?") | ||||
|  | ||||
| 		i := strings.Index(header[cur:], "?") | ||||
| 		if i == -1 { | ||||
| 			break | ||||
| 		} | ||||
| 		charset := header[cur : cur+i] | ||||
| 		cur += i + len("?") | ||||
|  | ||||
| 		if len(header) < cur+len("Q??=") { | ||||
| 			break | ||||
| 		} | ||||
| 		encoding := header[cur] | ||||
| 		cur++ | ||||
|  | ||||
| 		if header[cur] != '?' { | ||||
| 			break | ||||
| 		} | ||||
| 		cur++ | ||||
|  | ||||
| 		j := strings.Index(header[cur:], "?=") | ||||
| 		if j == -1 { | ||||
| 			break | ||||
| 		} | ||||
| 		text := header[cur : cur+j] | ||||
| 		end := cur + j + len("?=") | ||||
|  | ||||
| 		content, err := decode(encoding, text) | ||||
| 		if err != nil { | ||||
| 			betweenWords = false | ||||
| 			buf.WriteString(header[:start+2]) | ||||
| 			header = header[start+2:] | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Write characters before the encoded-word. White-space and newline | ||||
| 		// characters separating two encoded-words must be deleted. | ||||
| 		if start > 0 && (!betweenWords || hasNonWhitespace(header[:start])) { | ||||
| 			buf.WriteString(header[:start]) | ||||
| 		} | ||||
|  | ||||
| 		if err := d.convert(&buf, charset, content); err != nil { | ||||
| 			return "", err | ||||
| 		} | ||||
|  | ||||
| 		header = header[end:] | ||||
| 		betweenWords = true | ||||
| 	} | ||||
|  | ||||
| 	if len(header) > 0 { | ||||
| 		buf.WriteString(header) | ||||
| 	} | ||||
|  | ||||
| 	return buf.String(), nil | ||||
| } | ||||
|  | ||||
| func decode(encoding byte, text string) ([]byte, error) { | ||||
| 	switch encoding { | ||||
| 	case 'B', 'b': | ||||
| 		return base64.StdEncoding.DecodeString(text) | ||||
| 	case 'Q', 'q': | ||||
| 		return qDecode(text) | ||||
| 	default: | ||||
| 		return nil, errInvalidWord | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (d *WordDecoder) convert(buf *strings.Builder, charset string, content []byte) error { | ||||
| 	switch { | ||||
| 	case strings.EqualFold("utf-8", charset): | ||||
| 		buf.Write(content) | ||||
| 	case strings.EqualFold("iso-8859-1", charset): | ||||
| 		for _, c := range content { | ||||
| 			buf.WriteRune(rune(c)) | ||||
| 		} | ||||
| 	case strings.EqualFold("us-ascii", charset): | ||||
| 		for _, c := range content { | ||||
| 			if c >= utf8.RuneSelf { | ||||
| 				buf.WriteRune(unicode.ReplacementChar) | ||||
| 			} else { | ||||
| 				buf.WriteByte(c) | ||||
| 			} | ||||
| 		} | ||||
| 	case strings.EqualFold("gb18030", charset): | ||||
| 		decodeBytes, err := simplifiedchinese.GB18030.NewDecoder().Bytes(content) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		buf.Write(decodeBytes) | ||||
| 	case strings.EqualFold("gbk", charset): | ||||
| 		decodeBytes, err := simplifiedchinese.GBK.NewDecoder().Bytes(content) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		buf.Write(decodeBytes) | ||||
| 	default: | ||||
| 		if d.CharsetReader == nil { | ||||
| 			return fmt.Errorf("mime: unhandled charset %q", charset) | ||||
| 		} | ||||
| 		r, err := d.CharsetReader(strings.ToLower(charset), bytes.NewReader(content)) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if _, err = io.Copy(buf, r); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // hasNonWhitespace reports whether s (assumed to be ASCII) contains at least | ||||
| // one byte of non-whitespace. | ||||
| func hasNonWhitespace(s string) bool { | ||||
| 	for _, b := range s { | ||||
| 		switch b { | ||||
| 		// Encoded-words can only be separated by linear white spaces which does | ||||
| 		// not include vertical tabs (\v). | ||||
| 		case ' ', '\t', '\n', '\r': | ||||
| 		default: | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| // qDecode decodes a Q encoded string. | ||||
| func qDecode(s string) ([]byte, error) { | ||||
| 	dec := make([]byte, len(s)) | ||||
| 	n := 0 | ||||
| 	for i := 0; i < len(s); i++ { | ||||
| 		switch c := s[i]; { | ||||
| 		case c == '_': | ||||
| 			dec[n] = ' ' | ||||
| 		case c == '=': | ||||
| 			if i+2 >= len(s) { | ||||
| 				return nil, errInvalidWord | ||||
| 			} | ||||
| 			b, err := readHexByte(s[i+1], s[i+2]) | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 			} | ||||
| 			dec[n] = b | ||||
| 			i += 2 | ||||
| 		case (c <= '~' && c >= ' ') || c == '\n' || c == '\r' || c == '\t': | ||||
| 			dec[n] = c | ||||
| 		default: | ||||
| 			return nil, errInvalidWord | ||||
| 		} | ||||
| 		n++ | ||||
| 	} | ||||
|  | ||||
| 	return dec[:n], nil | ||||
| } | ||||
|  | ||||
| // readHexByte returns the byte from its quoted-printable representation. | ||||
| func readHexByte(a, b byte) (byte, error) { | ||||
| 	var hb, lb byte | ||||
| 	var err error | ||||
| 	if hb, err = fromHex(a); err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	if lb, err = fromHex(b); err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	return hb<<4 | lb, nil | ||||
| } | ||||
|  | ||||
| func fromHex(b byte) (byte, error) { | ||||
| 	switch { | ||||
| 	case b >= '0' && b <= '9': | ||||
| 		return b - '0', nil | ||||
| 	case b >= 'A' && b <= 'F': | ||||
| 		return b - 'A' + 10, nil | ||||
| 	// Accept badly encoded bytes. | ||||
| 	case b >= 'a' && b <= 'f': | ||||
| 		return b - 'a' + 10, nil | ||||
| 	} | ||||
| 	return 0, fmt.Errorf("mime: invalid hex byte %#02x", b) | ||||
| } | ||||
							
								
								
									
										37
									
								
								server/dto/response/response.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								server/dto/response/response.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| package response | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	NeedLogin   = 403 | ||||
| 	ParamsError = 100 | ||||
| 	ServerError = 500 | ||||
| ) | ||||
|  | ||||
| type Response struct { | ||||
| 	ErrorNo  int    `json:"errorNo"` | ||||
| 	ErrorMsg string `json:"errorMsg"` | ||||
| 	Data     any    `json:"data"` | ||||
| } | ||||
|  | ||||
| func (p *Response) FPrint(w http.ResponseWriter) { | ||||
| 	bytesData, _ := json.Marshal(p) | ||||
| 	w.Write(bytesData) | ||||
| } | ||||
|  | ||||
| func NewSuccessResponse(data any) *Response { | ||||
| 	return &Response{ | ||||
| 		Data: data, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func NewErrorResponse(errorNo int, errorMsg string, data any) *Response { | ||||
| 	return &Response{ | ||||
| 		ErrorNo:  errorNo, | ||||
| 		ErrorMsg: errorMsg, | ||||
| 		Data:     data, | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										13
									
								
								server/dto/tag.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/dto/tag.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| package dto | ||||
|  | ||||
| import "encoding/json" | ||||
|  | ||||
| type SearchTag struct { | ||||
| 	Type   int `json:"type"` | ||||
| 	Status int `json:"status"` | ||||
| } | ||||
|  | ||||
| func (t SearchTag) ToString() string { | ||||
| 	data, _ := json.Marshal(t) | ||||
| 	return string(data) | ||||
| } | ||||
							
								
								
									
										28
									
								
								server/go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								server/go.mod
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| module pmail | ||||
|  | ||||
| go 1.20 | ||||
|  | ||||
| require ( | ||||
| 	github.com/alexedwards/scs/mysqlstore v0.0.0-20230327161757-10d4299e3b24 | ||||
| 	github.com/alexedwards/scs/v2 v2.5.1 | ||||
| 	github.com/emersion/go-msgauth v0.6.6 | ||||
| 	github.com/emersion/go-smtp v0.16.0 | ||||
| 	github.com/go-sql-driver/mysql v1.7.1 | ||||
| 	github.com/jmoiron/sqlx v1.3.5 | ||||
| 	github.com/mileusna/spf v0.9.5 | ||||
| 	github.com/sirupsen/logrus v1.9.3 | ||||
| 	github.com/spf13/cast v1.5.1 | ||||
| 	golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 | ||||
| 	golang.org/x/text v0.3.8 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/emersion/go-message v0.16.0 // indirect | ||||
| 	github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect | ||||
| 	github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect | ||||
| 	github.com/miekg/dns v1.1.50 // indirect | ||||
| 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect | ||||
| 	golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect | ||||
| 	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect | ||||
| 	golang.org/x/tools v0.1.12 // indirect | ||||
| ) | ||||
							
								
								
									
										99
									
								
								server/go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								server/go.sum
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| github.com/alexedwards/scs/mysqlstore v0.0.0-20230327161757-10d4299e3b24 h1:1jXpX7IE/zuf9FZQJpqZNepXqW8mq6NLzplHDCA43HY= | ||||
| github.com/alexedwards/scs/mysqlstore v0.0.0-20230327161757-10d4299e3b24/go.mod h1:ShejCOaSJCEjCWjc7YBrgy2xd0Kp+wiyBdzTNQrAGn4= | ||||
| github.com/alexedwards/scs/v2 v2.5.1 h1:EhAz3Kb3OSQzD8T+Ub23fKsiuvE0GzbF5Lgn0uTwM3Y= | ||||
| github.com/alexedwards/scs/v2 v2.5.1/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= | ||||
| github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= | ||||
| github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4= | ||||
| github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ= | ||||
| github.com/emersion/go-milter v0.3.3/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= | ||||
| github.com/emersion/go-msgauth v0.6.6 h1:buv5lL8v/3v4RpHnQFS2IPhE3nxSRX+AxnrEJbDbHhA= | ||||
| github.com/emersion/go-msgauth v0.6.6/go.mod h1:A+/zaz9bzukLM6tRWRgJ3BdrBi+TFKTvQ3fGMFOI9SM= | ||||
| github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= | ||||
| github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= | ||||
| github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8= | ||||
| github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= | ||||
| github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= | ||||
| github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= | ||||
| github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= | ||||
| github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= | ||||
| github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= | ||||
| github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= | ||||
| github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= | ||||
| github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= | ||||
| github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= | ||||
| github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= | ||||
| github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= | ||||
| github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= | ||||
| github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= | ||||
| github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= | ||||
| 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= | ||||
| github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||||
| github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||||
| github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= | ||||
| github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||||
| github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= | ||||
| github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0= | ||||
| golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | ||||
| golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||
| 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= | ||||
| golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= | ||||
| golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= | ||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= | ||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= | ||||
| golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||||
| golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= | ||||
| golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| 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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
							
								
								
									
										28
									
								
								server/hooks/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								server/hooks/base.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| package hooks | ||||
|  | ||||
| import ( | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/hooks/wechat_push" | ||||
| ) | ||||
|  | ||||
| type EmailHook interface { | ||||
| 	// SendBefore 邮件发送前的数据 | ||||
| 	SendBefore(ctx *dto.Context, email *parsemail.Email) | ||||
| 	// SendAfter 邮件发送后的数据,err是每个收信服务器的错误信息 | ||||
| 	SendAfter(ctx *dto.Context, email *parsemail.Email, err map[string]error) | ||||
| 	// ReceiveParseBefore 接收到邮件,解析之前的原始数据 | ||||
| 	ReceiveParseBefore(email []byte) | ||||
| 	// ReceiveParseAfter 接收到邮件,解析之后的结构化数据 | ||||
| 	ReceiveParseAfter(email *parsemail.Email) | ||||
| } | ||||
|  | ||||
| // HookList | ||||
| var HookList []EmailHook | ||||
|  | ||||
| // Init 这里注册hook对象 | ||||
| func Init() { | ||||
| 	HookList = []EmailHook{ | ||||
| 		wechat_push.NewWechatPushHook(), | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										108
									
								
								server/hooks/wechat_push/wechat_push.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								server/hooks/wechat_push/wechat_push.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| package wechat_push | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/spf13/cast" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"pmail/config" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type accessTokenRes struct { | ||||
| 	AccessToken string `db:"access_token" json:"access_token"` | ||||
| 	ExpiresIn   int    `db:"expires_in" json:"expires_in"` | ||||
| } | ||||
|  | ||||
| type WeChatPushHook struct { | ||||
| 	appId        string | ||||
| 	secret       string | ||||
| 	token        string | ||||
| 	tokenExpires int64 | ||||
| 	templateId   string | ||||
| 	pushUser     string | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) SendBefore(ctx *dto.Context, email *parsemail.Email) { | ||||
|  | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) SendAfter(ctx *dto.Context, email *parsemail.Email, err map[string]error) { | ||||
|  | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) ReceiveParseBefore(email []byte) { | ||||
|  | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) ReceiveParseAfter(email *parsemail.Email) { | ||||
| 	w.sendUserMsg(nil, w.pushUser, string(email.Text)) | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) getWxAccessToken() string { | ||||
| 	if w.tokenExpires > time.Now().Unix() { | ||||
| 		return w.token | ||||
| 	} | ||||
| 	resp, err := http.Get(fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", w.appId, w.secret)) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	body, _ := io.ReadAll(resp.Body) | ||||
| 	var ret accessTokenRes | ||||
| 	_ = json.Unmarshal(body, &ret) | ||||
| 	if ret.AccessToken != "" { | ||||
| 		w.token = ret.AccessToken | ||||
| 		w.tokenExpires = time.Now().Unix() + cast.ToInt64(ret.ExpiresIn) | ||||
| 	} | ||||
| 	return ret.AccessToken | ||||
| } | ||||
|  | ||||
| type sendMsgRequest struct { | ||||
| 	Touser      string   `db:"touser" json:"touser"` | ||||
| 	Template_id string   `db:"template_id" json:"template_id"` | ||||
| 	Url         string   `db:"url" json:"url"` | ||||
| 	Data        SendData `db:"data" json:"data"` | ||||
| } | ||||
| type SendData struct { | ||||
| 	Content DataItem `json:"Content"` | ||||
| } | ||||
| type DataItem struct { | ||||
| 	Value string `json:"value"` | ||||
| 	Color string `json:"color"` | ||||
| } | ||||
|  | ||||
| func (w *WeChatPushHook) sendUserMsg(ctx *dto.Context, userId string, content string) { | ||||
| 	sendMsgReq, _ := json.Marshal(sendMsgRequest{ | ||||
| 		Touser:      userId, | ||||
| 		Template_id: w.templateId, | ||||
| 		Url:         "http://mail." + config.Instance.Domain, | ||||
| 		Data:        SendData{Content: DataItem{Value: content, Color: "#000000"}}, | ||||
| 	}) | ||||
|  | ||||
| 	_, err := http.Post("https://api.weixin.qq.com/cgi-bin/message/template/send?access_token="+w.getWxAccessToken(), "application/json", strings.NewReader(string(sendMsgReq))) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("wechat push error %+v", err) | ||||
| 	} | ||||
|  | ||||
| } | ||||
| func NewWechatPushHook() *WeChatPushHook { | ||||
| 	if config.Instance.WeChatPushAppId != "" && | ||||
| 		config.Instance.WeChatPushSecret != "" && | ||||
| 		config.Instance.WeChatPushTemplateId != "" && | ||||
| 		config.Instance.WeChatPushUserId != "" { | ||||
| 		ret := &WeChatPushHook{ | ||||
| 			appId:      config.Instance.WeChatPushAppId, | ||||
| 			secret:     config.Instance.WeChatPushSecret, | ||||
| 			templateId: config.Instance.WeChatPushTemplateId, | ||||
| 			pushUser:   config.Instance.WeChatPushUserId, | ||||
| 		} | ||||
| 		return ret | ||||
|  | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										19
									
								
								server/hooks/wechat_push/wechat_push_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								server/hooks/wechat_push/wechat_push_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| package wechat_push | ||||
|  | ||||
| import ( | ||||
| 	"pmail/config" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func testInit() { | ||||
|  | ||||
| 	config.Init() | ||||
|  | ||||
| } | ||||
| func TestWeChatPushHook_ReceiveParseAfter(t *testing.T) { | ||||
| 	testInit() | ||||
|  | ||||
| 	w := NewWechatPushHook() | ||||
| 	w.ReceiveParseAfter(&parsemail.Email{Subject: "标题", Text: []byte("文本内容")}) | ||||
| } | ||||
							
								
								
									
										133
									
								
								server/http_server/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								server/http_server/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| package http_server | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"embed" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/spf13/cast" | ||||
| 	"io/fs" | ||||
| 	"math/rand" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"pmail/controllers" | ||||
| 	"pmail/controllers/email" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/response" | ||||
| 	"pmail/session" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| //go:embed dist/* | ||||
| var local embed.FS | ||||
|  | ||||
| var ip string | ||||
|  | ||||
| const HttpPort = 80 | ||||
|  | ||||
| func Start() { | ||||
| 	log.Infof("Http Server Start at :%d", HttpPort) | ||||
|  | ||||
| 	mux := http.NewServeMux() | ||||
|  | ||||
| 	fe, err := fs.Sub(local, "dist") | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	mux.Handle("/", http.FileServer(http.FS(fe))) | ||||
|  | ||||
| 	mux.HandleFunc("/api/ping", contextIterceptor(controllers.Ping)) | ||||
| 	mux.HandleFunc("/api/login", contextIterceptor(controllers.Login)) | ||||
| 	mux.HandleFunc("/api/group", contextIterceptor(controllers.GetUserGroup)) | ||||
| 	mux.HandleFunc("/api/email/list", contextIterceptor(email.EmailList)) | ||||
| 	mux.HandleFunc("/api/email/detail", contextIterceptor(email.EmailDetail)) | ||||
| 	mux.HandleFunc("/api/email/send", contextIterceptor(email.Send)) | ||||
| 	mux.HandleFunc("/api/settings/modify_password", contextIterceptor(controllers.ModifyPassword)) | ||||
| 	mux.HandleFunc("/attachments/", contextIterceptor(controllers.GetAttachments)) | ||||
| 	mux.HandleFunc("/attachments/download/", contextIterceptor(controllers.Download)) | ||||
|  | ||||
| 	server := &http.Server{ | ||||
| 		Addr:         fmt.Sprintf(":%d", HttpPort), | ||||
| 		Handler:      session.Instance.LoadAndSave(mux), | ||||
| 		ReadTimeout:  time.Second * 60, | ||||
| 		WriteTimeout: time.Second * 60, | ||||
| 	} | ||||
|  | ||||
| 	//err := server.ListenAndServeTLS( "config/ssl/public.crt", "config/ssl/private.key", nil) | ||||
| 	err = server.ListenAndServe() | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func getLocalIP() string { | ||||
| 	ip := "127.0.0.1" | ||||
| 	addrs, err := net.InterfaceAddrs() | ||||
| 	if err != nil { | ||||
| 		return ip | ||||
| 	} | ||||
| 	for _, a := range addrs { | ||||
| 		if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { | ||||
| 			if ipnet.IP.To4() != nil { | ||||
| 				ip = ipnet.IP.String() | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return ip | ||||
| } | ||||
|  | ||||
| func genLogID() string { | ||||
| 	r := rand.New(rand.NewSource(time.Now().UnixMicro())) | ||||
| 	if ip == "" { | ||||
| 		ip = getLocalIP() | ||||
| 	} | ||||
| 	now := time.Now() | ||||
| 	timestamp := uint32(now.Unix()) | ||||
| 	timeNano := now.UnixNano() | ||||
| 	pid := os.Getpid() | ||||
| 	b := bytes.Buffer{} | ||||
|  | ||||
| 	b.WriteString(hex.EncodeToString(net.ParseIP(ip).To4())) | ||||
| 	b.WriteString(fmt.Sprintf("%x", timestamp&0xffffffff)) | ||||
| 	b.WriteString(fmt.Sprintf("%04x", timeNano&0xffff)) | ||||
| 	b.WriteString(fmt.Sprintf("%04x", pid&0xffff)) | ||||
| 	b.WriteString(fmt.Sprintf("%06x", r.Int31n(1<<24))) | ||||
| 	b.WriteString("b0") | ||||
|  | ||||
| 	return b.String() | ||||
| } | ||||
|  | ||||
| // 注入context | ||||
| func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		if w.Header().Get("Content-Type") == "" { | ||||
| 			w.Header().Set("Content-Type", "application/json") | ||||
| 		} | ||||
|  | ||||
| 		ctx := &dto.Context{} | ||||
| 		ctx.Context = r.Context() | ||||
| 		ctx.SetValue(dto.LogID, genLogID()) | ||||
| 		lang := r.Header.Get("Lang") | ||||
| 		if lang == "" { | ||||
| 			lang = "en" | ||||
| 		} | ||||
| 		ctx.Lang = lang | ||||
|  | ||||
| 		user := cast.ToString(session.Instance.Get(ctx, "user")) | ||||
| 		if user != "" { | ||||
| 			_ = json.Unmarshal([]byte(user), &ctx.UserInfo) | ||||
| 		} | ||||
| 		if ctx.UserInfo == nil || ctx.UserInfo.ID == 0 { | ||||
| 			if r.URL.Path != "/api/ping" && r.URL.Path != "/api/login" { | ||||
| 				response.NewErrorResponse(response.NeedLogin, "登陆已失效!", "").FPrint(w) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 		h(ctx, w, r) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										41
									
								
								server/i18n/i18n.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								server/i18n/i18n.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| package i18n | ||||
|  | ||||
| var ( | ||||
| 	cn = map[string]string{ | ||||
| 		"all_email":   "全部邮件数据", | ||||
| 		"inbox":       "收件箱", | ||||
| 		"outbox":      "发件箱", | ||||
| 		"sketch":      "草稿箱", | ||||
| 		"aperror":     "账号或密码错误", | ||||
| 		"unknowError": "未知错误", | ||||
| 		"succ":        "成功", | ||||
| 		"send_fail":   "发送失败", | ||||
| 		"att_err":     "附件解码错误", | ||||
| 	} | ||||
| 	en = map[string]string{ | ||||
| 		"all_email":   "All Email", | ||||
| 		"inbox":       "Inbox", | ||||
| 		"outbox":      "Outbox", | ||||
| 		"sketch":      "Sketch", | ||||
| 		"aperror":     "Incorrect account number or password", | ||||
| 		"unknowError": "Unknow Error", | ||||
| 		"succ":        "Success", | ||||
| 		"send_fail":   "Send Failure", | ||||
| 		"att_err":     "Attachment decoding error", | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| func GetText(lang, key string) string { | ||||
| 	if lang == "zhCn" { | ||||
| 		text, exist := cn[key] | ||||
| 		if !exist { | ||||
| 			return "" | ||||
| 		} | ||||
| 		return text | ||||
| 	} | ||||
| 	text, exist := en[key] | ||||
| 	if !exist { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return text | ||||
| } | ||||
							
								
								
									
										81
									
								
								server/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								server/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"os" | ||||
| 	"pmail/config" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/hooks" | ||||
| 	"pmail/http_server" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/session" | ||||
| 	"pmail/smtp_server" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type logFormatter struct { | ||||
| } | ||||
|  | ||||
| // Format 定义日志输出格式 | ||||
| func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) { | ||||
| 	b := bytes.Buffer{} | ||||
|  | ||||
| 	b.WriteString(fmt.Sprintf("[%s]", entry.Level.String())) | ||||
| 	b.WriteString(fmt.Sprintf("[%s]", entry.Time.Format("2006-01-02 15:04:05"))) | ||||
| 	if entry.Context != nil { | ||||
| 		b.WriteString(fmt.Sprintf("[%s]", entry.Context.(*dto.Context).GetValue(dto.LogID))) | ||||
| 	} | ||||
| 	b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line)) | ||||
| 	b.WriteString(entry.Message) | ||||
|  | ||||
| 	b.WriteString("\n") | ||||
| 	return b.Bytes(), nil | ||||
| } | ||||
|  | ||||
| var ( | ||||
| 	gitHash   string | ||||
| 	buildTime string | ||||
| 	goVersion string | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	// 设置日志格式为json格式 | ||||
| 	//log.SetFormatter(&log.JSONFormatter{}) | ||||
|  | ||||
| 	log.SetFormatter(&logFormatter{}) | ||||
| 	log.SetReportCaller(true) | ||||
|  | ||||
| 	// 设置将日志输出到标准输出(默认的输出为stderr,标准错误) | ||||
| 	// 日志消息输出可以是任意的io.writer类型 | ||||
| 	log.SetOutput(os.Stdout) | ||||
|  | ||||
| 	// 设置日志级别为warn以上 | ||||
| 	log.SetLevel(log.DebugLevel) | ||||
| 	var cst, _ = time.LoadLocation("Asia/Shanghai") | ||||
| 	time.Local = cst | ||||
|  | ||||
| 	config.Init() | ||||
| 	parsemail.Init() | ||||
| 	mysql.Init() | ||||
| 	session.Init() | ||||
| 	hooks.Init() | ||||
|  | ||||
| 	// smtp server start | ||||
| 	go smtp_server.Start() | ||||
|  | ||||
| 	// http server start | ||||
| 	go http_server.Start() | ||||
|  | ||||
| 	log.Infoln("***************************************************") | ||||
| 	log.Infoln("***\tServer Start Success Version:1.0.0") | ||||
| 	log.Infof("***\tGit Commit Hash: %s ", gitHash) | ||||
| 	log.Infof("***\tBuild TimeStamp: %s ", buildTime) | ||||
| 	log.Infof("***\tBuild GoLang Version: %s ", goVersion) | ||||
| 	log.Infoln("***************************************************") | ||||
|  | ||||
| 	s := make(chan bool) | ||||
| 	<-s | ||||
| } | ||||
							
								
								
									
										8
									
								
								server/models/User.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								server/models/User.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| package models | ||||
|  | ||||
| type User struct { | ||||
| 	ID       int    `db:"id"` | ||||
| 	Account  string `db:"account"` | ||||
| 	Name     string `db:"name"` | ||||
| 	Password string `db:"password"` | ||||
| } | ||||
							
								
								
									
										7
									
								
								server/models/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/models/auth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| package models | ||||
|  | ||||
| type UserAuth struct { | ||||
| 	ID           int    `db:"id"` | ||||
| 	UserID       int    `db:"user_id"` | ||||
| 	EmailAccount string `db:"email_account"` | ||||
| } | ||||
							
								
								
									
										79
									
								
								server/models/email.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								server/models/email.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| package models | ||||
|  | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"encoding/json" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| type Email struct { | ||||
| 	Id           int            `db:"id" json:"id"` | ||||
| 	Type         int8           `db:"type" json:"type"` | ||||
| 	Subject      string         `db:"subject" json:"subject"` | ||||
| 	ReplyTo      string         `db:"reply_to" json:"reply_to"` | ||||
| 	FromName     string         `db:"from_name" json:"from_name"` | ||||
| 	FromAddress  string         `db:"from_address" json:"from_address"` | ||||
| 	To           string         `db:"to" json:"to"` | ||||
| 	Bcc          string         `db:"bcc" json:"bcc"` | ||||
| 	Cc           string         `db:"cc" json:"cc"` | ||||
| 	Text         sql.NullString `db:"text" json:"text"` | ||||
| 	Html         sql.NullString `db:"html" json:"html"` | ||||
| 	Sender       string         `db:"sender" json:"sender"` | ||||
| 	Attachments  string         `db:"attachments" json:"attachments"` | ||||
| 	SPFCheck     int8           `db:"spf_check" json:"spf_check"` | ||||
| 	DKIMCheck    int8           `db:"dkim_check" json:"dkim_check"` | ||||
| 	Status       int8           `db:"status" json:"status"` | ||||
| 	CronSendTime time.Time      `db:"cron_send_time" json:"cron_send_time"` | ||||
| 	UpdateTime   time.Time      `db:"update_time" json:"update_time"` | ||||
| 	SendUserID   int            `db:"send_user_id" json:"send_user_id"` | ||||
| 	IsRead       int8           `db:"is_read" json:"is_read"` | ||||
| 	Error        sql.NullString `db:"error" json:"error"` | ||||
| 	SendDate     time.Time      `db:"send_date" json:"send_date"` | ||||
| 	CreateTime   time.Time      `db:"create_time" json:"create_time"` | ||||
| } | ||||
|  | ||||
| type attachments struct { | ||||
| 	Filename    string | ||||
| 	ContentType string | ||||
| 	Index       int | ||||
| 	//Content     []byte | ||||
| } | ||||
|  | ||||
| func (d Email) MarshalJSON() ([]byte, error) { | ||||
| 	type Alias Email | ||||
|  | ||||
| 	var allAtt = []attachments{} | ||||
| 	var showAtt = []attachments{} | ||||
| 	if d.Attachments != "" { | ||||
| 		_ = json.Unmarshal([]byte(d.Attachments), &allAtt) | ||||
| 		for i, att := range allAtt { | ||||
| 			att.Index = i | ||||
| 			if att.ContentType == "application/octet-stream" { | ||||
| 				showAtt = append(showAtt, att) | ||||
| 			} | ||||
|  | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return json.Marshal(&struct { | ||||
| 		Alias | ||||
| 		CronSendTime string        `json:"send_time"` | ||||
| 		SendDate     string        `json:"send_date"` | ||||
| 		UpdateTime   string        `json:"update_time"` | ||||
| 		CreateTime   string        `json:"create_time"` | ||||
| 		Text         string        `json:"text"` | ||||
| 		Html         string        `json:"html"` | ||||
| 		Error        string        `json:"error"` | ||||
| 		Attachments  []attachments `json:"attachments"` | ||||
| 	}{ | ||||
| 		Alias:        (Alias)(d), | ||||
| 		CronSendTime: d.CronSendTime.Format("2006-01-02 15:04:05"), | ||||
| 		UpdateTime:   d.UpdateTime.Format("2006-01-02 15:04:05"), | ||||
| 		CreateTime:   d.CreateTime.Format("2006-01-02 15:04:05"), | ||||
| 		SendDate:     d.SendDate.Format("2006-01-02 15:04:05"), | ||||
| 		Text:         d.Text.String, | ||||
| 		Html:         d.Html.String, | ||||
| 		Error:        d.Error.String, | ||||
| 		Attachments:  showAtt, | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										87
									
								
								server/mysql/init.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								server/mysql/init.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| package mysql | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	_ "github.com/go-sql-driver/mysql" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"pmail/config" | ||||
| 	"pmail/dto" | ||||
| ) | ||||
|  | ||||
| var Instance *sqlx.DB | ||||
|  | ||||
| func Init() { | ||||
| 	dsn := config.Instance.MysqlDSN | ||||
| 	var err error | ||||
| 	Instance, err = sqlx.Open("mysql", dsn) | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	Instance.SetMaxOpenConns(100) | ||||
| 	Instance.SetMaxIdleConns(10) | ||||
| 	showMySQLCharacterSet() | ||||
| 	checkTable() | ||||
| } | ||||
|  | ||||
| func WithContext(ctx *dto.Context, sql string) string { | ||||
| 	if ctx != nil { | ||||
| 		logId := ctx.GetValue(dto.LogID) | ||||
| 		return fmt.Sprintf("/* %s */ %s", logId, sql) | ||||
| 	} | ||||
| 	return sql | ||||
| } | ||||
|  | ||||
| type tables struct { | ||||
| 	TablesInPmail string `db:"Tables_in_pmail"` | ||||
| } | ||||
|  | ||||
| func checkTable() { | ||||
| 	var res []*tables | ||||
| 	err := Instance.Select(&res, "show tables") | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	existTable := map[string]struct{}{} | ||||
| 	for _, tableName := range res { | ||||
| 		existTable[tableName.TablesInPmail] = struct{}{} | ||||
| 	} | ||||
|  | ||||
| 	for tableName, createSQL := range config.Instance.Tables { | ||||
| 		if _, ok := existTable[tableName]; !ok { | ||||
| 			_, err = Instance.Exec(createSQL) | ||||
| 			log.Infof("Create Table: %s", createSQL) | ||||
| 			if err != nil { | ||||
| 				panic(err) | ||||
| 			} | ||||
|  | ||||
| 			if initData, ok := config.Instance.TablesInitData[tableName]; ok { | ||||
| 				_, err = Instance.Exec(initData) | ||||
| 				log.Infof("Init Table: %s", initData) | ||||
| 				if err != nil { | ||||
| 					panic(err) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func showMySQLCharacterSet() { | ||||
| 	var res []struct { | ||||
| 		Variable_name string `db:"Variable_name"` | ||||
| 		Value         string `db:"Value"` | ||||
| 	} | ||||
| 	err := Instance.Select(&res, "show variables like '%character%';") | ||||
| 	log.Debugf("%+v  %+v", res, err) | ||||
|  | ||||
| } | ||||
|  | ||||
| func testSlowLog() { | ||||
| 	var res []struct { | ||||
| 		Value string `db:"Value"` | ||||
| 	} | ||||
| 	err := Instance.Select(&res, "/* asddddasad */select /* this is test */ sleep(4) as Value") | ||||
| 	log.Debugf("%+v  %+v", res, err) | ||||
|  | ||||
| } | ||||
							
								
								
									
										60
									
								
								server/services/attachments/attachments.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								server/services/attachments/attachments.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| package attachments | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/models" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/services/auth" | ||||
| ) | ||||
|  | ||||
| func GetAttachments(ctx *dto.Context, emailId int, cid string) (string, []byte) { | ||||
|  | ||||
| 	// 获取邮件内容 | ||||
| 	var email models.Email | ||||
| 	err := mysql.Instance.Get(&email, mysql.WithContext(ctx, "select * from email where id = ?"), emailId) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("SQL error:%+v", err) | ||||
| 		return "", nil | ||||
| 	} | ||||
|  | ||||
| 	// 检查权限 | ||||
| 	if !auth.HasAuth(ctx, &email) { | ||||
| 		return "", nil | ||||
| 	} | ||||
|  | ||||
| 	var atts []parsemail.Attachment | ||||
| 	_ = json.Unmarshal([]byte(email.Attachments), &atts) | ||||
| 	for _, att := range atts { | ||||
| 		if att.ContentID == cid { | ||||
| 			return att.ContentType, att.Content | ||||
| 		} | ||||
| 	} | ||||
| 	return "", nil | ||||
| } | ||||
|  | ||||
| func GetAttachmentsByIndex(ctx *dto.Context, emailId int, index int) (string, []byte) { | ||||
|  | ||||
| 	// 获取邮件内容 | ||||
| 	var email models.Email | ||||
| 	err := mysql.Instance.Get(&email, mysql.WithContext(ctx, "select * from email where id = ?"), emailId) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("SQL error:%+v", err) | ||||
| 		return "", nil | ||||
| 	} | ||||
|  | ||||
| 	// 检查权限 | ||||
| 	if !auth.HasAuth(ctx, &email) { | ||||
| 		return "", nil | ||||
| 	} | ||||
|  | ||||
| 	var atts []parsemail.Attachment | ||||
| 	_ = json.Unmarshal([]byte(email.Attachments), &atts) | ||||
|  | ||||
| 	if len(atts) > index { | ||||
| 		return atts[index].Filename, atts[index].Content | ||||
| 	} | ||||
| 	return "", nil | ||||
| } | ||||
							
								
								
									
										33
									
								
								server/services/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								server/services/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| package auth | ||||
|  | ||||
| import ( | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/models" | ||||
| 	"pmail/mysql" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| // HasAuth 检查当前用户是否有某个邮件的auth | ||||
| func HasAuth(ctx *dto.Context, email *models.Email) bool { | ||||
| 	// 获取当前用户的auth | ||||
| 	var auth []models.UserAuth | ||||
| 	err := mysql.Instance.Select(&auth, mysql.WithContext(ctx, "select * from user_auth where user_id = ?"), ctx.UserInfo.ID) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("SQL error:%+v", err) | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	var hasAuth bool | ||||
| 	for _, userAuth := range auth { | ||||
| 		if userAuth.EmailAccount == "*" { | ||||
| 			hasAuth = true | ||||
| 			break | ||||
| 		} else if strings.Contains(email.Bcc, ctx.UserInfo.Account) || strings.Contains(email.Cc, ctx.UserInfo.Account) || strings.Contains(email.To, ctx.UserInfo.Account) { | ||||
| 			hasAuth = true | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return hasAuth | ||||
| } | ||||
							
								
								
									
										43
									
								
								server/services/detail/detail.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								server/services/detail/detail.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| package detail | ||||
|  | ||||
| import ( | ||||
| 	"database/sql" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/models" | ||||
| 	"pmail/mysql" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func GetEmailDetail(ctx *dto.Context, id int, markRead bool) (*models.Email, error) { | ||||
| 	// 获取邮件内容 | ||||
| 	var email models.Email | ||||
| 	err := mysql.Instance.Get(&email, mysql.WithContext(ctx, "select * from email where id = ?"), id) | ||||
| 	if err != nil { | ||||
| 		log.WithContext(ctx).Errorf("SQL error:%+v", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if markRead && email.IsRead == 0 { | ||||
| 		_, err = mysql.Instance.Exec(mysql.WithContext(ctx, "update email set is_read =1 where id =?"), email.Id) | ||||
| 		if err != nil { | ||||
| 			log.WithContext(ctx).Errorf("SQL error:%+v", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 将内容中的cid内容替换成url | ||||
| 	if email.Attachments != "" { | ||||
| 		var atts []parsemail.Attachment | ||||
| 		_ = json.Unmarshal([]byte(email.Attachments), &atts) | ||||
| 		for _, att := range atts { | ||||
| 			email.Html = sql.NullString{ | ||||
| 				String: strings.ReplaceAll(email.Html.String, fmt.Sprintf("cid:%s", att.ContentID), fmt.Sprintf("/attachments/%d/%s", id, att.ContentID)), | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &email, nil | ||||
| } | ||||
							
								
								
									
										60
									
								
								server/services/list/list.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								server/services/list/list.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| package list | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/models" | ||||
| 	"pmail/mysql" | ||||
| ) | ||||
|  | ||||
| func GetEmailList(ctx *dto.Context, tag string, keyword string, offset, limit int) (emailList []*models.Email, total int) { | ||||
|  | ||||
| 	querySQL, queryParams := genSQL(ctx, false, tag, keyword, offset, limit) | ||||
| 	counterSQL, counterParams := genSQL(ctx, true, tag, keyword, offset, limit) | ||||
|  | ||||
| 	err := mysql.Instance.Select(&emailList, querySQL, queryParams...) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("SQL ERROR: %s ,Error:%s", querySQL, err) | ||||
| 	} | ||||
|  | ||||
| 	err = mysql.Instance.Get(&total, counterSQL, counterParams...) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("SQL ERROR: %s ,Error:%s", querySQL, err) | ||||
| 	} | ||||
|  | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func genSQL(ctx *dto.Context, counter bool, tag, keyword string, offset, limit int) (string, []any) { | ||||
|  | ||||
| 	sql := "select * from email where 1=1 " | ||||
| 	if counter { | ||||
| 		sql = "select count(1) from email where 1=1 " | ||||
| 	} | ||||
|  | ||||
| 	sqlParams := []any{} | ||||
|  | ||||
| 	var tagInfo dto.SearchTag | ||||
| 	_ = json.Unmarshal([]byte(tag), &tagInfo) | ||||
|  | ||||
| 	if tagInfo.Type != -1 { | ||||
| 		sql += " and type =? " | ||||
| 		sqlParams = append(sqlParams, tagInfo.Type) | ||||
| 	} | ||||
|  | ||||
| 	if tagInfo.Status != -1 { | ||||
| 		sql += " and status =? " | ||||
| 		sqlParams = append(sqlParams, tagInfo.Status) | ||||
| 	} | ||||
|  | ||||
| 	if keyword != "" { | ||||
| 		sql += " and (subject like ? or text like ? )" | ||||
| 		sqlParams = append(sqlParams, "%"+keyword+"%", "%"+keyword+"%") | ||||
| 	} | ||||
|  | ||||
| 	sql += " limit ? offset ?" | ||||
| 	sqlParams = append(sqlParams, limit, offset) | ||||
|  | ||||
| 	return sql, sqlParams | ||||
| } | ||||
							
								
								
									
										19
									
								
								server/session/init.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								server/session/init.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| package session | ||||
|  | ||||
| import ( | ||||
| 	"github.com/alexedwards/scs/mysqlstore" | ||||
| 	"github.com/alexedwards/scs/v2" | ||||
| 	"pmail/mysql" | ||||
|  | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| var Instance *scs.SessionManager | ||||
|  | ||||
| func Init() { | ||||
| 	Instance = scs.New() | ||||
| 	Instance.Lifetime = 24 * time.Hour | ||||
| 	// 使用mysql存储session数据,目前为了架构简单, | ||||
| 	// 暂不引入redis存储,如果日后性能存在瓶颈,可以将session迁移到redis | ||||
| 	Instance.Store = mysqlstore.New(mysql.Instance.DB) | ||||
| } | ||||
							
								
								
									
										72
									
								
								server/smtp_server/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								server/smtp_server/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| package smtp_server | ||||
|  | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"github.com/emersion/go-smtp" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"net" | ||||
| 	"pmail/config" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // The Backend implements SMTP server methods. | ||||
| type Backend struct{} | ||||
|  | ||||
| func (bkd *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { | ||||
| 	remoteAddress := conn.Conn().RemoteAddr() | ||||
|  | ||||
| 	return &Session{ | ||||
| 		RemoteAddress: remoteAddress, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // A Session is returned after EHLO. | ||||
| type Session struct { | ||||
| 	RemoteAddress net.Addr | ||||
| } | ||||
|  | ||||
| func (s *Session) AuthPlain(username, password string) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (s *Session) Mail(from string, opts *smtp.MailOptions) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (s *Session) Rcpt(to string) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (s *Session) Reset() {} | ||||
|  | ||||
| func (s *Session) Logout() error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func Start() { | ||||
| 	be := &Backend{} | ||||
|  | ||||
| 	s := smtp.NewServer(be) | ||||
|  | ||||
| 	s.Addr = ":25" | ||||
| 	s.Domain = config.Instance.Domain | ||||
| 	s.ReadTimeout = 10 * time.Second | ||||
| 	s.WriteTimeout = 10 * time.Second | ||||
| 	s.MaxMessageBytes = 1024 * 1024 | ||||
| 	s.MaxRecipients = 50 | ||||
| 	// force TLS for auth | ||||
| 	s.AllowInsecureAuth = false | ||||
| 	// Load the certificate and key | ||||
| 	cer, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath) | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 		return | ||||
| 	} | ||||
| 	// Configure the TLS support | ||||
| 	s.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} | ||||
|  | ||||
| 	log.Println("Starting server at", s.Addr) | ||||
| 	if err := s.ListenAndServe(); err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										118
									
								
								server/smtp_server/read_content.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								server/smtp_server/read_content.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | ||||
| package smtp_server | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"github.com/mileusna/spf" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"net/netip" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/hooks" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/utils/async" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func (s *Session) Data(r io.Reader) error { | ||||
| 	emailData, err := io.ReadAll(r) | ||||
| 	if err != nil { | ||||
| 		log.Error("邮件内容无法读取", err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	for _, hook := range hooks.HookList { | ||||
| 		if hook == nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		async.New(nil).Process(func() { | ||||
| 			hook.ReceiveParseBefore(emailData) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	log.Infof("邮件原始内容: %s", emailData) | ||||
|  | ||||
| 	var dkimStatus, SPFStatus bool | ||||
|  | ||||
| 	// DKIM校验 | ||||
| 	dkimStatus = parsemail.Check(bytes.NewReader(emailData)) | ||||
|  | ||||
| 	email := parsemail.NewEmailFromReader(bytes.NewReader(emailData)) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		log.Fatalf("邮件内容解析失败! Error : %v \n", err) | ||||
| 	} | ||||
|  | ||||
| 	SPFStatus = spfCheck(s.RemoteAddress.String(), email.Sender, email.Sender.EmailAddress) | ||||
|  | ||||
| 	var dkimV, spfV int8 | ||||
| 	if dkimStatus { | ||||
| 		dkimV = 1 | ||||
| 	} | ||||
| 	if SPFStatus { | ||||
| 		spfV = 1 | ||||
| 	} | ||||
|  | ||||
| 	for _, hook := range hooks.HookList { | ||||
| 		if hook == nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		async.New(nil).Process(func() { | ||||
| 			hook.ReceiveParseAfter(email) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	sql := "INSERT INTO email (send_date, subject, reply_to, from_name, from_address, `to`, bcc, cc, text, html, sender, attachments,spf_check, dkim_check, create_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" | ||||
| 	_, err = mysql.Instance.Exec(sql, | ||||
| 		email.Date, | ||||
| 		email.Subject, | ||||
| 		json2string(email.ReplyTo), | ||||
| 		email.From.Name, | ||||
| 		email.From.EmailAddress, | ||||
| 		json2string(email.To), | ||||
| 		json2string(email.Bcc), | ||||
| 		json2string(email.Cc), | ||||
| 		email.Text, | ||||
| 		email.HTML, | ||||
| 		json2string(email.Sender), | ||||
| 		json2string(email.Attachments), | ||||
| 		spfV, | ||||
| 		dkimV, | ||||
| 		time.Now()) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		log.Println("mysql insert error:", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func json2string(d any) string { | ||||
| 	by, _ := json.Marshal(d) | ||||
| 	return string(by) | ||||
| } | ||||
|  | ||||
| func spfCheck(remoteAddress string, sender *parsemail.User, senderString string) bool { | ||||
| 	//spf校验 | ||||
| 	ipAddress, _ := netip.ParseAddrPort(remoteAddress) | ||||
|  | ||||
| 	ip := net.ParseIP(ipAddress.Addr().String()) | ||||
| 	if ip.IsPrivate() { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	tmp := strings.Split(sender.EmailAddress, "@") | ||||
| 	if len(tmp) < 2 { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	res := spf.CheckHost(ip, tmp[1], senderString, "") | ||||
|  | ||||
| 	if res == spf.None || res == spf.Pass { | ||||
| 		// spf校验通过 | ||||
| 		return true | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
							
								
								
									
										72
									
								
								server/smtp_server/read_content_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								server/smtp_server/read_content_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| package smtp_server | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"io/fs" | ||||
| 	"net" | ||||
| 	"net/netip" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"pmail/config" | ||||
| 	parsemail2 "pmail/dto/parsemail" | ||||
| 	"pmail/mysql" | ||||
| 	"pmail/session" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func testInit() { | ||||
| 	// 设置日志格式为json格式 | ||||
| 	//log.SetFormatter(&log.JSONFormatter{}) | ||||
|  | ||||
| 	log.SetReportCaller(true) | ||||
| 	log.SetFormatter(&log.TextFormatter{ | ||||
| 		//以下设置只是为了使输出更美观 | ||||
| 		DisableColors:   true, | ||||
| 		TimestampFormat: "2006-01-02 15:03:04", | ||||
| 	}) | ||||
|  | ||||
| 	// 设置将日志输出到标准输出(默认的输出为stderr,标准错误) | ||||
| 	// 日志消息输出可以是任意的io.writer类型 | ||||
| 	log.SetOutput(os.Stdout) | ||||
|  | ||||
| 	// 设置日志级别为warn以上 | ||||
| 	log.SetLevel(log.TraceLevel) | ||||
|  | ||||
| 	var cst, _ = time.LoadLocation("Asia/Shanghai") | ||||
| 	time.Local = cst | ||||
|  | ||||
| 	config.Init() | ||||
| 	parsemail2.Init() | ||||
| 	mysql.Init() | ||||
| 	session.Init() | ||||
|  | ||||
| } | ||||
|  | ||||
| func TestSession_Data(t *testing.T) { | ||||
| 	testInit() | ||||
| 	s := Session{ | ||||
| 		RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)), | ||||
| 	} | ||||
|  | ||||
| 	filepath.WalkDir("docs", func(path string, d fs.DirEntry, err error) error { | ||||
| 		if !d.IsDir() { | ||||
| 			data, _ := os.ReadFile(path) | ||||
| 			s.Data(bytes.NewReader(data)) | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| } | ||||
|  | ||||
| func TestSession_DataGmail(t *testing.T) { | ||||
| 	testInit() | ||||
| 	s := Session{ | ||||
| 		RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)), | ||||
| 	} | ||||
|  | ||||
| 	data, _ := os.ReadFile("docs/gmail/带附件带图片.txt") | ||||
| 	s.Data(bytes.NewReader(data)) | ||||
|  | ||||
| } | ||||
							
								
								
									
										160
									
								
								server/smtp_server/send.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								server/smtp_server/send.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| package smtp_server | ||||
|  | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"crypto/x509" | ||||
| 	"errors" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"net" | ||||
| 	"pmail/dto" | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"pmail/utils/array" | ||||
| 	"pmail/utils/async" | ||||
| 	"pmail/utils/smtp" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type mxDomain struct { | ||||
| 	domain string | ||||
| 	mxHost string | ||||
| } | ||||
|  | ||||
| func Send(ctx *dto.Context, e *parsemail.Email) (error, map[string]error) { | ||||
|  | ||||
| 	b := e.BuildBytes(ctx) | ||||
|  | ||||
| 	var to []*parsemail.User | ||||
| 	to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) | ||||
|  | ||||
| 	// 按域名整理 | ||||
| 	toByDomain := map[mxDomain][]*parsemail.User{} | ||||
| 	for _, s := range to { | ||||
| 		args := strings.Split(s.EmailAddress, "@") | ||||
| 		if len(args) == 2 { | ||||
| 			//查询dns mx记录 | ||||
| 			mxInfo, err := net.LookupMX(args[1]) | ||||
| 			address := mxDomain{ | ||||
| 				domain: "smtp." + args[1], | ||||
| 				mxHost: "smtp." + args[1], | ||||
| 			} | ||||
| 			if err != nil { | ||||
| 				log.WithContext(ctx).Errorf(s.EmailAddress, "域名mx记录查询失败") | ||||
| 			} | ||||
| 			if len(mxInfo) > 0 { | ||||
| 				address = mxDomain{ | ||||
| 					domain: args[1], | ||||
| 					mxHost: mxInfo[0].Host, | ||||
| 				} | ||||
| 			} | ||||
| 			toByDomain[address] = append(toByDomain[address], s) | ||||
| 		} else { | ||||
| 			log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s) | ||||
| 			continue | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	var errEmailAddress []string | ||||
|  | ||||
| 	errMap := map[string]error{} | ||||
|  | ||||
| 	as := async.New(ctx) | ||||
| 	for domain, tos := range toByDomain { | ||||
| 		domain := domain | ||||
| 		tos := tos | ||||
| 		as.WaitProcess(func() { | ||||
|  | ||||
| 			err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b) | ||||
|  | ||||
| 			// 重新选取证书域名 | ||||
| 			if err != nil { | ||||
| 				if certificateErr, ok := err.(*tls.CertificateVerificationError); ok { | ||||
| 					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) | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if err != nil { | ||||
| 				log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err) | ||||
| 				for _, user := range tos { | ||||
| 					errEmailAddress = append(errEmailAddress, user.EmailAddress) | ||||
|  | ||||
| 				} | ||||
| 			} | ||||
| 			errMap[domain.domain] = err | ||||
| 		}) | ||||
| 	} | ||||
| 	as.Wait() | ||||
|  | ||||
| 	if len(errEmailAddress) > 0 { | ||||
| 		return errors.New("以下收件人投递失败:" + array.Join(errEmailAddress, ",")), errMap | ||||
| 	} | ||||
| 	return nil, errMap | ||||
|  | ||||
| } | ||||
|  | ||||
| func buildAddress(u []*parsemail.User) []string { | ||||
| 	var ret []string | ||||
|  | ||||
| 	for _, user := range u { | ||||
| 		ret = append(ret, user.EmailAddress) | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	return ret | ||||
| } | ||||
|  | ||||
| func domainMatch(domain string, dnsNames []string) string { | ||||
| 	secondMatch := "" | ||||
|  | ||||
| 	for _, name := range dnsNames { | ||||
| 		if strings.Contains(name, "smtp") { | ||||
| 			secondMatch = name | ||||
| 		} | ||||
|  | ||||
| 		if name == domain { | ||||
| 			return name | ||||
| 		} | ||||
| 		if strings.Contains(name, "*") { | ||||
| 			nameArg := strings.Split(name, ".") | ||||
| 			domainArg := strings.Split(domain, ".") | ||||
| 			match := true | ||||
| 			for i := 0; i < len(nameArg); i++ { | ||||
| 				if nameArg[len(nameArg)-1-i] == "*" { | ||||
| 					continue | ||||
| 				} | ||||
| 				if len(domainArg) > i { | ||||
| 					if nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] { | ||||
| 						continue | ||||
| 					} | ||||
| 				} | ||||
| 				match = false | ||||
| 				break | ||||
| 			} | ||||
|  | ||||
| 			for i := 0; i < len(domainArg); i++ { | ||||
| 				if len(nameArg) > i && nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] { | ||||
| 					continue | ||||
| 				} | ||||
| 				if len(nameArg) > i && nameArg[len(nameArg)-1-i] == "*" { | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				match = false | ||||
| 				break | ||||
| 			} | ||||
| 			if match { | ||||
| 				return domain | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if secondMatch != "" { | ||||
| 		return strings.ReplaceAll(secondMatch, "*.", "") | ||||
| 	} | ||||
|  | ||||
| 	return strings.ReplaceAll(dnsNames[0], "*.", "") | ||||
| } | ||||
							
								
								
									
										24
									
								
								server/smtp_server/send_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								server/smtp_server/send_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| package smtp_server | ||||
|  | ||||
| import ( | ||||
| 	"pmail/dto/parsemail" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func TestSend(t *testing.T) { | ||||
| 	testInit() | ||||
| 	e := &parsemail.Email{ | ||||
| 		From: &parsemail.User{ | ||||
| 			Name:         "发送人", | ||||
| 			EmailAddress: "j@jinnrry.com", | ||||
| 		}, | ||||
| 		To: []*parsemail.User{ | ||||
| 			{"ok@jinnrry.com", "名"}, | ||||
| 			{"ok@xjiangwei.cn", "字"}, | ||||
| 		}, | ||||
| 		Subject: "你好", | ||||
| 		Text:    []byte("这是Text"), | ||||
| 		HTML:    []byte("<div>这是Html</div>"), | ||||
| 	} | ||||
| 	Send(nil, e) | ||||
| } | ||||
							
								
								
									
										19
									
								
								server/utils/array/array.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								server/utils/array/array.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| package array | ||||
|  | ||||
| import ( | ||||
| 	"github.com/spf13/cast" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func Join[T any](arg []T, str string) string { | ||||
| 	var ret strings.Builder | ||||
| 	for i, t := range arg { | ||||
| 		if i == 0 { | ||||
| 			ret.WriteString(cast.ToString(t)) | ||||
| 		} else { | ||||
| 			ret.WriteString(str) | ||||
| 			ret.WriteString(cast.ToString(t)) | ||||
| 		} | ||||
| 	} | ||||
| 	return ret.String() | ||||
| } | ||||
							
								
								
									
										71
									
								
								server/utils/async/async.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								server/utils/async/async.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| package async | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/spf13/cast" | ||||
| 	"pmail/dto" | ||||
| 	"runtime/debug" | ||||
| 	"sync" | ||||
| ) | ||||
|  | ||||
| type Callback func() | ||||
|  | ||||
| type Async struct { | ||||
| 	wg        *sync.WaitGroup | ||||
| 	lastError error | ||||
| 	ctx       *dto.Context | ||||
| } | ||||
|  | ||||
| func New(ctx *dto.Context) *Async { | ||||
| 	return &Async{ | ||||
| 		ctx: ctx, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (as *Async) LastError() error { | ||||
| 	return as.lastError | ||||
| } | ||||
|  | ||||
| func (as *Async) WaitProcess(callback Callback) { | ||||
| 	if as.wg == nil { | ||||
| 		as.wg = &sync.WaitGroup{} | ||||
| 	} | ||||
| 	as.wg.Add(1) | ||||
| 	as.Process(func() { | ||||
| 		defer as.wg.Done() | ||||
| 		callback() | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (as *Async) Process(callback Callback) { | ||||
| 	go func() { | ||||
| 		defer func() { | ||||
| 			if err := recover(); err != nil { | ||||
| 				as.lastError = as.HandleErrRecover(err) | ||||
| 			} | ||||
| 		}() | ||||
| 		callback() | ||||
| 	}() | ||||
| } | ||||
|  | ||||
| func (as *Async) Wait() { | ||||
| 	if as.wg == nil { | ||||
| 		return | ||||
| 	} | ||||
| 	as.wg.Wait() | ||||
| } | ||||
|  | ||||
| // HandleErrRecover panic恢复处理 | ||||
| func (as *Async) HandleErrRecover(err interface{}) (returnErr error) { | ||||
| 	switch err.(type) { | ||||
| 	case error: | ||||
| 		returnErr = err.(error) | ||||
| 	default: | ||||
| 		returnErr = errors.New(cast.ToString(err)) | ||||
| 	} | ||||
|  | ||||
| 	log.WithContext(as.ctx).Errorf("goroutine panic:%s  \n %s", err, string(debug.Stack())) | ||||
|  | ||||
| 	return | ||||
| } | ||||
							
								
								
									
										448
									
								
								server/utils/smtp/smtp.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										448
									
								
								server/utils/smtp/smtp.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,448 @@ | ||||
| // Copyright 2010 The Go Authors. All rights reserved. | ||||
| // Use of this source code is governed by a BSD-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| // Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321. | ||||
| // It also implements the following extensions: | ||||
| // | ||||
| //	8BITMIME  RFC 1652 | ||||
| //	AUTH      RFC 2554 | ||||
| //	STARTTLS  RFC 3207 | ||||
| // | ||||
| // Additional extensions may be handled by clients. | ||||
| // | ||||
| // The smtp package is frozen and is not accepting new features. | ||||
| // Some external packages provide more functionality. See: | ||||
| // | ||||
| //	https://godoc.org/?q=smtp | ||||
| package smtp | ||||
|  | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"encoding/base64" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"net/smtp" | ||||
| 	"net/textproto" | ||||
| 	"pmail/config" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| // A Client represents a client connection to an SMTP server. | ||||
| type Client struct { | ||||
| 	// Text is the textproto.Conn used by the Client. It is exported to allow for | ||||
| 	// clients to add extensions. | ||||
| 	Text *textproto.Conn | ||||
| 	// keep a reference to the connection so it can be used to create a TLS | ||||
| 	// connection later | ||||
| 	conn net.Conn | ||||
| 	// whether the Client is using TLS | ||||
| 	tls        bool | ||||
| 	serverName string | ||||
| 	// map of supported extensions | ||||
| 	ext map[string]string | ||||
| 	// supported auth mechanisms | ||||
| 	auth       []string | ||||
| 	localName  string // the name to use in HELO/EHLO | ||||
| 	didHello   bool   // whether we've said HELO/EHLO | ||||
| 	helloError error  // the error from the hello | ||||
| } | ||||
|  | ||||
| // 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) { | ||||
| 	conn, err := net.Dial("tcp", addr) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	host, _, _ := net.SplitHostPort(addr) | ||||
| 	return NewClient(conn, host) | ||||
| } | ||||
|  | ||||
| // 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) { | ||||
| 	text := textproto.NewConn(conn) | ||||
| 	_, _, err := text.ReadResponse(220) | ||||
| 	if err != nil { | ||||
| 		text.Close() | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	c := &Client{Text: text, conn: conn, serverName: host, localName: config.Instance.Domain} | ||||
| 	_, c.tls = conn.(*tls.Conn) | ||||
| 	return c, nil | ||||
| } | ||||
|  | ||||
| // Close closes the connection. | ||||
| func (c *Client) Close() error { | ||||
| 	return c.Text.Close() | ||||
| } | ||||
|  | ||||
| // hello runs a hello exchange if needed. | ||||
| func (c *Client) hello() error { | ||||
| 	if !c.didHello { | ||||
| 		c.didHello = true | ||||
| 		err := c.ehlo() | ||||
| 		if err != nil { | ||||
| 			c.helloError = c.helo() | ||||
| 		} | ||||
| 	} | ||||
| 	return c.helloError | ||||
| } | ||||
|  | ||||
| // Hello sends a HELO or EHLO to the server as the given host name. | ||||
| // Calling this method is only necessary if the client needs control | ||||
| // over the host name used. The client will introduce itself as "localhost" | ||||
| // automatically otherwise. If Hello is called, it must be called before | ||||
| // any of the other methods. | ||||
| func (c *Client) Hello(localName string) error { | ||||
| 	if err := validateLine(localName); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if c.didHello { | ||||
| 		return errors.New("smtp: Hello called after other methods") | ||||
| 	} | ||||
| 	c.localName = localName | ||||
| 	return c.hello() | ||||
| } | ||||
|  | ||||
| // cmd is a convenience function that sends a command and returns the response | ||||
| func (c *Client) cmd(expectCode int, format string, args ...any) (int, string, error) { | ||||
| 	id, err := c.Text.Cmd(format, args...) | ||||
| 	if err != nil { | ||||
| 		return 0, "", err | ||||
| 	} | ||||
| 	c.Text.StartResponse(id) | ||||
| 	defer c.Text.EndResponse(id) | ||||
| 	code, msg, err := c.Text.ReadResponse(expectCode) | ||||
| 	return code, msg, err | ||||
| } | ||||
|  | ||||
| // helo sends the HELO greeting to the server. It should be used only when the | ||||
| // server does not support ehlo. | ||||
| func (c *Client) helo() error { | ||||
| 	c.ext = nil | ||||
| 	_, _, err := c.cmd(250, "HELO %s", c.localName) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // ehlo sends the EHLO (extended hello) greeting to the server. It | ||||
| // should be the preferred greeting for servers that support it. | ||||
| func (c *Client) ehlo() error { | ||||
| 	_, msg, err := c.cmd(250, "EHLO %s", c.localName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	ext := make(map[string]string) | ||||
| 	extList := strings.Split(msg, "\n") | ||||
| 	if len(extList) > 1 { | ||||
| 		extList = extList[1:] | ||||
| 		for _, line := range extList { | ||||
| 			k, v, _ := strings.Cut(line, " ") | ||||
| 			ext[k] = v | ||||
| 		} | ||||
| 	} | ||||
| 	if mechs, ok := ext["AUTH"]; ok { | ||||
| 		c.auth = strings.Split(mechs, " ") | ||||
| 	} | ||||
| 	c.ext = ext | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // StartTLS sends the STARTTLS command and encrypts all further communication. | ||||
| // Only servers that advertise the STARTTLS extension support this function. | ||||
| func (c *Client) StartTLS(config *tls.Config) error { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(220, "STARTTLS") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if config == nil { | ||||
| 		config = &tls.Config{} | ||||
| 	} | ||||
| 	if config.ServerName == "" { | ||||
| 		// Make a copy to avoid polluting argument | ||||
| 		config = config.Clone() | ||||
| 		config.ServerName = c.serverName | ||||
| 	} | ||||
| 	c.conn = tls.Client(c.conn, config) | ||||
| 	c.Text = textproto.NewConn(c.conn) | ||||
| 	c.tls = true | ||||
| 	return c.ehlo() | ||||
| } | ||||
|  | ||||
| // TLSConnectionState returns the client's TLS connection state. | ||||
| // The return values are their zero values if StartTLS did | ||||
| // not succeed. | ||||
| func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { | ||||
| 	tc, ok := c.conn.(*tls.Conn) | ||||
| 	if !ok { | ||||
| 		return | ||||
| 	} | ||||
| 	return tc.ConnectionState(), true | ||||
| } | ||||
|  | ||||
| // Verify checks the validity of an email address on the server. | ||||
| // If Verify returns nil, the address is valid. A non-nil return | ||||
| // does not necessarily indicate an invalid address. Many servers | ||||
| // will not verify addresses for security reasons. | ||||
| func (c *Client) Verify(addr string) error { | ||||
| 	if err := validateLine(addr); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(250, "VRFY %s", addr) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Auth authenticates a client using the provided authentication mechanism. | ||||
| // A failed authentication closes the connection. | ||||
| // Only servers that advertise the AUTH extension support this function. | ||||
| func (c *Client) Auth(a smtp.Auth) error { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	encoding := base64.StdEncoding | ||||
| 	mech, resp, err := a.Start(&smtp.ServerInfo{Name: c.serverName, TLS: c.tls, Auth: c.auth}) | ||||
| 	if err != nil { | ||||
| 		c.Quit() | ||||
| 		return err | ||||
| 	} | ||||
| 	resp64 := make([]byte, encoding.EncodedLen(len(resp))) | ||||
| 	encoding.Encode(resp64, resp) | ||||
| 	code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))) | ||||
| 	for err == nil { | ||||
| 		var msg []byte | ||||
| 		switch code { | ||||
| 		case 334: | ||||
| 			msg, err = encoding.DecodeString(msg64) | ||||
| 		case 235: | ||||
| 			// the last message isn't base64 because it isn't a challenge | ||||
| 			msg = []byte(msg64) | ||||
| 		default: | ||||
| 			err = &textproto.Error{Code: code, Msg: msg64} | ||||
| 		} | ||||
| 		if err == nil { | ||||
| 			resp, err = a.Next(msg, code == 334) | ||||
| 		} | ||||
| 		if err != nil { | ||||
| 			// abort the AUTH | ||||
| 			c.cmd(501, "*") | ||||
| 			c.Quit() | ||||
| 			break | ||||
| 		} | ||||
| 		if resp == nil { | ||||
| 			break | ||||
| 		} | ||||
| 		resp64 = make([]byte, encoding.EncodedLen(len(resp))) | ||||
| 		encoding.Encode(resp64, resp) | ||||
| 		code, msg64, err = c.cmd(0, string(resp64)) | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Mail issues a MAIL command to the server using the provided email address. | ||||
| // If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME | ||||
| // parameter. If the server supports the SMTPUTF8 extension, Mail adds the | ||||
| // SMTPUTF8 parameter. | ||||
| // This initiates a mail transaction and is followed by one or more Rcpt calls. | ||||
| func (c *Client) Mail(from string) error { | ||||
| 	if err := validateLine(from); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	cmdStr := "MAIL FROM:<%s>" | ||||
| 	if c.ext != nil { | ||||
| 		if _, ok := c.ext["8BITMIME"]; ok { | ||||
| 			cmdStr += " BODY=8BITMIME" | ||||
| 		} | ||||
| 		if _, ok := c.ext["SMTPUTF8"]; ok { | ||||
| 			cmdStr += " SMTPUTF8" | ||||
| 		} | ||||
| 	} | ||||
| 	_, _, err := c.cmd(250, cmdStr, from) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Rcpt issues a RCPT command to the server using the provided email address. | ||||
| // A call to Rcpt must be preceded by a call to Mail and may be followed by | ||||
| // a Data call or another Rcpt call. | ||||
| func (c *Client) Rcpt(to string) error { | ||||
| 	if err := validateLine(to); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(25, "RCPT TO:<%s>", to) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| type dataCloser struct { | ||||
| 	c *Client | ||||
| 	io.WriteCloser | ||||
| } | ||||
|  | ||||
| func (d *dataCloser) Close() error { | ||||
| 	d.WriteCloser.Close() | ||||
| 	_, _, err := d.c.Text.ReadResponse(250) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Data issues a DATA command to the server and returns a writer that | ||||
| // can be used to write the mail headers and body. The caller should | ||||
| // close the writer before calling any more methods on c. A call to | ||||
| // Data must be preceded by one or more calls to Rcpt. | ||||
| func (c *Client) Data() (io.WriteCloser, error) { | ||||
| 	_, _, err := c.cmd(354, "DATA") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dataCloser{c, c.Text.DotWriter()}, nil | ||||
| } | ||||
|  | ||||
| var testHookStartTLS func(*tls.Config) // nil, except for tests | ||||
|  | ||||
| // SendMail connects to the server at addr, switches to TLS if | ||||
| // possible, authenticates with the optional mechanism a if possible, | ||||
| // and then sends an email from address from, to addresses to, with | ||||
| // message msg. | ||||
| // The addr must include a port, as in "mail.example.com:smtp". | ||||
| // | ||||
| // The addresses in the to parameter are the SMTP RCPT addresses. | ||||
| // | ||||
| // The msg parameter should be an RFC 822-style email with headers | ||||
| // first, a blank line, and then the message body. The lines of msg | ||||
| // should be CRLF terminated. The msg headers should usually include | ||||
| // fields such as "From", "To", "Subject", and "Cc".  Sending "Bcc" | ||||
| // messages is accomplished by including an email address in the to | ||||
| // parameter but not including it in the msg headers. | ||||
| // | ||||
| // The SendMail function and the net/smtp package are low-level | ||||
| // mechanisms and provide no support for DKIM signing, MIME | ||||
| // attachments (see the mime/multipart package), or other mail | ||||
| // 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 { | ||||
| 	if err := validateLine(from); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	for _, recp := range to { | ||||
| 		if err := validateLine(recp); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	c, err := Dial(addr) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer c.Close() | ||||
| 	if err = c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if ok, _ := c.Extension("STARTTLS"); !ok { | ||||
| 		return errors.New("smtp: server doesn't support STARTTLS") | ||||
| 	} | ||||
|  | ||||
| 	var config *tls.Config | ||||
| 	if domain != "" { | ||||
| 		config = &tls.Config{ | ||||
| 			ServerName: domain, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if err = c.StartTLS(config); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if a != nil && c.ext != nil { | ||||
| 		if _, ok := c.ext["AUTH"]; !ok { | ||||
| 			return errors.New("smtp: server doesn't support AUTH") | ||||
| 		} | ||||
| 		if err = c.Auth(a); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	if err = c.Mail(from); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	for _, addr := range to { | ||||
| 		if err = c.Rcpt(addr); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	w, err := c.Data() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, err = w.Write(msg) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	err = w.Close() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return c.Quit() | ||||
| } | ||||
|  | ||||
| // Extension reports whether an extension is support by the server. | ||||
| // The extension name is case-insensitive. If the extension is supported, | ||||
| // Extension also returns a string that contains any parameters the | ||||
| // server specifies for the extension. | ||||
| func (c *Client) Extension(ext string) (bool, string) { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return false, "" | ||||
| 	} | ||||
| 	if c.ext == nil { | ||||
| 		return false, "" | ||||
| 	} | ||||
| 	ext = strings.ToUpper(ext) | ||||
| 	param, ok := c.ext[ext] | ||||
| 	return ok, param | ||||
| } | ||||
|  | ||||
| // Reset sends the RSET command to the server, aborting the current mail | ||||
| // transaction. | ||||
| func (c *Client) Reset() error { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(250, "RSET") | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Noop sends the NOOP command to the server. It does nothing but check | ||||
| // that the connection to the server is okay. | ||||
| func (c *Client) Noop() error { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(250, "NOOP") | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // Quit sends the QUIT command and closes the connection to the server. | ||||
| func (c *Client) Quit() error { | ||||
| 	if err := c.hello(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, _, err := c.cmd(221, "QUIT") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return c.Text.Close() | ||||
| } | ||||
|  | ||||
| // validateLine checks to see if a line has CR or LF as per RFC 5321. | ||||
| func validateLine(line string) error { | ||||
| 	if strings.ContainsAny(line, "\n\r") { | ||||
| 		return errors.New("smtp: A line must not contain CR or LF") | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 jinnrry
					jinnrry