first commit

This commit is contained in:
jinnrry 2023-07-30 17:59:25 +08:00
commit 494940afc9
87 changed files with 9589 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea
.DS_Store
dist
output

36
Dockerfile Normal file
View 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
View 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、Buildor 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

25
fe/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

28
fe/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

55
fe/src/App.vue Normal file
View 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
View 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
View 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
View 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;
}

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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, // 100Gbase64
}
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>

View 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
View 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>

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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": ""
}

View File

@ -0,0 +1 @@
使用[go-msgauth](https://github.com/emersion/go-msgauth)项目的dkim-keygen工具生成公钥和私钥

View File

View File

View File

View File

View File

@ -0,0 +1,2 @@
INSERT INTO user (account, name, password) VALUES ('admin', 'admin', 'faddb6ec2efe16116a342f5512583c48');

View File

@ -0,0 +1,2 @@
INSERT INTO pmail.user_auth (user_id, email_account) VALUES (1, '*');

View 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='邮件内容表'

View 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数据表';

View 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='登陆信息表'

View 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='登陆信息表'

View 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)
}

View File

@ -0,0 +1,8 @@
package controllers
import (
"net/http"
"pmail/dto"
)
type HandlerFunc func(*dto.Context, http.ResponseWriter, *http.Request)

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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))
}

View 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")
}

View 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
View 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]
}

View 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
}

View 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())
}

View 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)
}

View 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")
}
}

View 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)
}

View 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
View 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
View 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
View 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
View 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(),
}
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
}

View 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
}

View 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
}

View 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
View 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)
}

View 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)
}
}

View 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
}

View 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
View 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], "*.", "")
}

View 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)
}

View 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()
}

View 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
View 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
}