mirror of
https://github.com/Jinnrry/PMail.git
synced 2025-02-20 11:43:09 +08:00
first commit
This commit is contained in:
commit
494940afc9
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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user