Compare commits

..

12 Commits

Author SHA1 Message Date
Dream Hunter
c0e870ce54 feat: all mail use MailBox Component (#173) 2024-04-28 15:58:26 +08:00
Dream Hunter
90e80fee53 feat: admin page add account mail count && sendbox default all && send access suppory filter (#172) 2024-04-28 14:02:33 +08:00
Dream Hunter
4fd7f776f6 feat: remove PREFIX logic in db (#171) 2024-04-28 13:20:09 +08:00
Dream Hunter
c73c86e86c Update CHANGELOG.md 2024-04-27 23:19:55 +08:00
Dream Hunter
08a3d4ce0e feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval (#169) 2024-04-27 23:16:18 +08:00
Dream Hunter
1404079073 feat: docs update (#165) 2024-04-27 11:31:25 +08:00
Dream Hunter
829782d0cb Update CHANGELOG.md 2024-04-26 18:08:58 +08:00
Dream Hunter
f624fe5b58 feat: add adminContact && DEFAULT_SEND_BALANCE (#162) 2024-04-26 00:21:43 +08:00
Dream Hunter
b058a1bd12 feat: update packages (#161) 2024-04-25 14:30:39 +08:00
Dream Hunter
0a8f50f9e0 Add issue templates 2024-04-23 23:16:27 +08:00
Dream Hunter
a3edb09305 feat: UI authTip to accessTip && worker / path return OK (#158) 2024-04-23 12:50:20 +08:00
Dream Hunter
58dcdc65f8 feat: UI use wangeditor for send mail (#157) 2024-04-23 12:13:45 +08:00
38 changed files with 5446 additions and 4234 deletions

23
.github/ISSUE_TEMPLATE/bug-反馈.md vendored Normal file
View File

@@ -0,0 +1,23 @@
---
name: Bug 反馈
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
## 复现步骤
## 预期行为
## 部署方式
- [ ] cli 部署
- [ ] 用户界面部署
## 浏览器环境

View File

@@ -0,0 +1,16 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[Feature]"
labels: enhancement, good first issue
assignees: ''
---
## 请描述您的 Feature
## 描述您想要的解决方案
## 描述您考虑过的替代方案
## 附加上下文

View File

@@ -1,5 +1,51 @@
# CHANGE LOG
## v0.3.0
Breaking Changes:
DB changes:
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
```sql
update
address
set
name = 'tmp' || name;
```
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` 是否允许用户删除账户和邮件
- `ENABLE_AUTO_REPLY` 是否启用自动回复
- fetchAddressError 提示改进
- 自动刷新显示倒计时
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- 添加富文本编辑器
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
- 默认发送邮件余额,如果不设置,将为 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8
- 允许用户删除邮件
- admin 修改发件权限时邮件通知用户
- 发件权限默认 1 条
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
- 一些 bug 修复
---
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
## v0.2.7
- Added user interface installation documentation

208
README.md
View File

@@ -2,7 +2,7 @@
## [查看部署文档](https://temp-mail-docs.awsl.uk)
## [English](https://temp-mail-docs.awsl.uk/en/)
## [English Docs](https://temp-mail-docs.awsl.uk/en/)
## [CHANGELOG](CHANGELOG.md)
@@ -21,20 +21,10 @@
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English](#english)
- [English Docs](#english-docs)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [什么是临时邮箱](#什么是临时邮箱)
- [Cloudflare 服务](#cloudflare-服务)
- [wrangler 的安装](#wrangler-的安装)
- [D1 数据库](#d1-数据库)
- [Cloudflare workers 后端](#cloudflare-workers-后端)
- [Cloudflare Email Routing](#cloudflare-email-routing)
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
- [配置发送邮件](#配置发送邮件)
- [配置 DKIM](#配置-dkim)
- [参考资料](#参考资料)
## 功能/TODO
@@ -45,201 +35,9 @@
- [x] 使用 password 重新登录之前的邮箱
- [x] 获取自定义名字的邮箱
- [x] 支持多语言
- [x] 增加访问授权,可作为私人站点
- [x] 增加访问密码,可作为私人站点
- [x] 增加自动回复功能
- [x] 增加查看附件功能
- [x] 使用 rust wasm 解析邮件
- [x] 支持发送邮件
- [x] 支持 DKIM
---
## 什么是临时邮箱
临时邮箱,也被称为一次性邮箱或临时邮件地址,是一种用于临时接收邮件的虚拟邮箱。与常规邮箱不同,临时邮箱旨在提供一种匿名且临时的邮件接收解决方案。
临时邮箱往往由网站或在线服务提供商提供,用户可以在需要注册或接收验证邮件时使用临时邮箱地址,而无需暴露自己的真实邮箱地址。这样做的好处是可以保护个人隐私
---
## Cloudflare 服务
- `D1``Cloudflare` 的原生无服务器数据库。
- `Pages``Cloudflare` 的静态网站托管服务, 速度超快,始终保持最新状态。
- `Workers``Cloudflare``serverless` 应用服务,可以在全球 300 个数据中心运行代码, 而无需配置或维护基础架构。
- `Cloudflare Email Routing` 可以处理域名的所有电子邮件流量,而无需管理电子邮件服务器。
---
## wrangler 的安装
安装 wrangler
```bash
npm install wrangler -g
```
克隆项目
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# 切换到最新 tag 或者你想部署的分支,你也可以直接使用 main 分支
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```
---
## D1 数据库
第一次执行登录 wrangler 命令时,会提示登录, 按提示操作即可
```bash
# 创建 D1 并执行 schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=db/schema.sql
# schema 更新,如果你在此日期之前初始化过数据库,可以执行此命令更新
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
```
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
![D1](vitepress-docs/docs/public/readme_assets/d1.png)
---
## Cloudflare workers 后端
初始化项目
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
```
修改 `wrangler.toml` 文件
```bash
name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-08-14"
node_compat = true
[vars]
PREFIX = "tmp" # 要处理的邮箱名称前缀
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 数据库名称
database_id = "xxx" # D1 数据库 ID
# 新建地址限流配置
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
```
部署
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
pnpm run deploy
```
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](vitepress-docs/docs/public/readme_assets/worker.png)
---
## Cloudflare Email Routing
在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址。
配置对应域名的 `电子邮件 DNS 记录`
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
![email](vitepress-docs/docs/public/readme_assets/email.png)
---
## Cloudflare Pages 前端
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
cd frontend
pnpm install
cp .env.example .env.local
```
修改 `.env.local` 文件, 将 `VITE_API_BASE` 修改为 `worker``url`, 不要在末尾加 `/`
例如: `VITE_API_BASE=https://xxx.xxx.workers.dev`
```bash
pnpm build --emptyOutDir
# 根据提示创建 pages
pnpm run deploy
```
![pages](vitepress-docs/docs/public/readme_assets/pages.png)
## 配置发送邮件
找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net`
```bash
v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all
```
新建 `_mailchannels` 记录, 类型为 `TXT`, 内容为 `v=mc1 cfid=你的worker域名`
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk/`,则填写 `v=mc1 cfid=awsl.uk`
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `v=mc1 cfid=xxx.workers.dev`
## 配置 DKIM
参考: [Adding-a-DKIM-Signature](https://support.mailchannels.com/hc/en-us/articles/7122849237389-Adding-a-DKIM-Signature)
Creating a DKIM private and public key:
Private key as PEM file and base64 encoded txt file:
```bash
openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A > priv_key.txt
```
Public key as DNS record:
```bash
echo -n "v=DKIM1;p=" > pub_key_record.txt && \
openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> pub_key_record.txt
```
`Cloudflare``DNS` 记录中添加 `TXT` 记录
- `_dmarc`: `v=DMARC1; p=none; adkim=r; aspf=r;`
- `mailchannels._domainkey`: `v=DKIM1; p=<content of the file pub_key_record.txt>`
## 参考资料
- https://developers.cloudflare.com/d1/
- https://developers.cloudflare.com/pages/
- https://developers.cloudflare.com/workers/
- https://developers.cloudflare.com/email-routing/

1
frontend/.gitignore vendored
View File

@@ -29,3 +29,4 @@ coverage
.env.*
*-dist/
components.d.ts

View File

@@ -14,26 +14,28 @@
"dependencies": {
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.8",
"mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.1",
"postal-mime": "^2.2.1",
"postal-mime": "^2.2.5",
"vooks": "^0.2.12",
"vue": "^3.4.21",
"vue": "^3.4.25",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0"
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^4.6.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.6",
"vite-plugin-pwa": "^0.19.7",
"vite": "^5.2.10",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.0.0",
"wrangler": "^3.50.0"
"workbox-window": "^7.1.0",
"wrangler": "^3.52.0"
}
}

7089
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
<script setup>
import { darkTheme, NGlobalStyle } from 'naive-ui'
import { zhCN } from 'naive-ui'
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
import { computed, onMounted } from 'vue'
import { useDark, useToggle } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
const { localeCache, themeSwitch, loading } = useGlobalState()
const theme = computed(() => themeSwitch.value ? darkTheme : null)
const { localeCache, isDark, loading } = useGlobalState()
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile()

View File

@@ -57,7 +57,10 @@ const getOpenSettings = async (message) => {
label: domain,
value: domain
}
})
}),
adminContact: res["adminContact"] || "",
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
};
if (openSettings.value.needAuth) {
showAuth.value = true;

View File

@@ -3,7 +3,6 @@ import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
@@ -11,8 +10,32 @@ import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage()
const isMobile = useIsMobile()
const { settings, themeSwitch } = useGlobalState()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
},
})
const { themeSwitch } = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const timer = ref(null)
@@ -28,7 +51,9 @@ const { t } = useI18n({
locale: 'zh',
messages: {
en: {
success: 'Success',
autoRefresh: 'Auto Refresh',
refreshAfter: 'Refresh After {msg} Seconds',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
@@ -37,7 +62,9 @@ const { t } = useI18n({
deleteMailTip: 'Are you sure you want to delete this mail?'
},
zh: {
success: '成功',
autoRefresh: '自动刷新',
refreshAfter: '{msg}秒后刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
@@ -49,10 +76,16 @@ const { t } = useI18n({
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
if (autoRefresh) {
timer.value = setInterval(async () => {
await refresh();
}, 30000)
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
}
}, 1000)
} else {
clearInterval(timer.value)
timer.value = null
@@ -71,10 +104,8 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
const refresh = async () => {
try {
const { results, count: totalCount } = await api.fetch(
`/api/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
@@ -106,9 +137,7 @@ const mailItemClass = (row) => {
const deleteMail = async () => {
try {
await api.fetch(`/api/mails/${curMail.value.id}`, {
method: 'DELETE'
});
await props.deleteMail(curMail.value.id);
message.success(t("success"));
curMail.value = null;
await refresh();
@@ -124,47 +153,129 @@ onMounted(async () => {
<template>
<div>
<n-layout v-if="settings.address">
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.25">
<template #1>
<div>
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small">
<template #checked>
{{ t('autoRefresh') }}
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.3">
<template #1>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button class="center" @click="refresh" size="small" type="primary">
{{ t('refresh') }}
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing class="center" :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
<!-- <iframe :srcdoc="curMail.message" style="width: 100%; height: 100%;"></iframe> -->
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small">
<template #checked>
{{ t('autoRefresh') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div id="drawer-target" style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
@@ -175,7 +286,10 @@ onMounted(async () => {
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-popconfirm @positive-click="deleteMail">
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
@@ -186,89 +300,16 @@ onMounted(async () => {
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div>
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small">
<template #checked>
{{ t('autoRefresh') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button class="center" @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div id="drawer-target" style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing class="center" :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<div style="word-break: break-all; font-size: small;">
FROM: {{ row.source }}
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
<n-drawer-content :title="curMail.subject" closable>
<n-card style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-popconfirm @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</n-layout>
</n-drawer-content>
</n-drawer>
</div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
@@ -292,8 +333,6 @@ onMounted(async () => {
</template>
</n-list-item>
</n-list>
<template #action>
</template>
</n-modal>
</div>
</template>
@@ -303,6 +342,10 @@ onMounted(async () => {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;

View File

@@ -1,12 +1,18 @@
import { ref } from "vue";
import { createGlobalState, useStorage } from '@vueuse/core'
import { useDark, useToggle } from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
const isDark = useDark()
const toggleDark = useToggle(isDark)
const loading = ref(false);
const openSettings = ref({
prefix: '',
needAuth: false,
adminContact: '',
enableUserDeleteEmail: false,
enableAutoReply: false,
domains: [{
label: 'test.com',
value: 'test.com'
@@ -37,6 +43,8 @@ export const useGlobalState = createGlobalState(
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
return {
isDark,
toggleDark,
loading,
settings,
openSettings,

View File

@@ -13,7 +13,7 @@ import MailsUnknow from './admin/MailsUnknow.vue';
import Maintenance from './admin/Maintenance.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab
localeCache, adminAuth, showAdminAuth, adminTab, loading
} = useGlobalState()
const message = useMessage()
@@ -29,24 +29,26 @@ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
auth: 'Admin Auth',
authTip: 'Please enter the correct auth code',
accessHeader: 'Admin Password',
accessTip: 'Please enter the admin password',
mails: 'Emails',
account: 'Account',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
maintenance: 'Maintenance',
ok: 'OK',
},
zh: {
auth: 'Admin 授权',
authTip: '请输入正确的授权码',
accessHeader: 'Admin 密码',
accessTip: '请输入 Admin 密码',
mails: '邮件',
account: '账号',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
maintenance: '维护',
ok: '确定',
}
}
});
@@ -64,13 +66,13 @@ onMounted(async () => {
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('auth') }}</div>
<div>{{ t('accessHeader') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<template #action>
<n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('auth') }}
<n-button @click="authFunc" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
</n-modal>

View File

@@ -6,19 +6,24 @@ import { useRoute, useRouter } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled, SendFilled } from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import AdminContact from './admin/AdminContact.vue'
import { useGlobalState } from '../store'
import { api } from '../api'
const { toClipboard } = useClipboard()
const message = useMessage()
const { jwt, localeCache, themeSwitch, showAuth, adminAuth, auth } = useGlobalState()
const {
jwt, localeCache, toggleDark, isDark,
showAuth, adminAuth, auth, loading
} = useGlobalState()
const { showLogin, openSettings, settings } = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false)
const showNewEmail = ref(false)
const showLogout = ref(false)
const showDelteAccount = ref(false)
@@ -67,8 +72,8 @@ const { t } = useI18n({
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
auth: 'Auth',
authTip: 'Please enter the correct auth code',
accessHeader: 'Access Password',
accessTip: 'Please enter the correct password',
settings: 'Settings',
home: 'Home',
menu: 'Menu',
@@ -82,12 +87,13 @@ const { t } = useI18n({
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
yourAddress: 'Your email address is',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.', cancel: 'Cancel',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
cancel: 'Cancel',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
showPassword: 'Show Password',
fetchAddressError: 'Fetch address error, maybe your jwt is invalid or network error.',
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
generateName: 'Generate Fake Name',
},
@@ -100,8 +106,8 @@ const { t } = useI18n({
logoutConfirm: '确定要登出吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
auth: '授权',
authTip: '请输入正确的授权码',
accessHeader: '访问密码',
accessTip: '请输入站点访问密码',
settings: '设置',
home: '主页',
menu: '菜单',
@@ -121,7 +127,7 @@ const { t } = useI18n({
copy: '复制',
copied: '已复制',
showPassword: '查看密码',
fetchAddressError: '获取地址失败, 请检查你的 jwt 是否有效 或 网络是否正常。',
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
generateName: '生成随机名字',
}
@@ -135,10 +141,10 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => router.push('/')
style: "width: 100%",
onClick: () => { router.push('/'); showMobileMenu.value = false; }
},
{
default: () => t('home'),
@@ -151,10 +157,10 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => router.push('/admin')
style: "width: 100%",
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
},
{
default: () => "Admin",
@@ -168,9 +174,9 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
style: "width: 100%",
},
{
default: () => t('user'),
@@ -184,10 +190,10 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => router.push('/sendbox')
style: "width: 100%",
onClick: () => { router.push('/sendbox'); showMobileMenu.value = false; }
},
{ default: () => t('sendbox') }
),
@@ -197,10 +203,10 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => { showPassword.value = true }
style: "width: 100%",
onClick: () => { showPassword.value = true; showMobileMenu.value = false; }
},
{ default: () => t('showPassword') }
),
@@ -210,23 +216,24 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => { router.push('/settings') }
style: "width: 100%",
onClick: () => { router.push('/settings'); showMobileMenu.value = false; }
},
{ default: () => t('settings') }
),
show: openSettings.value.enableAutoReply,
key: "settings"
},
{
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => { showLogout.value = true }
style: "width: 100%",
onClick: () => { showLogout.value = true; showMobileMenu.value = false; }
},
{ default: () => t('logout') }
),
@@ -236,13 +243,14 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => { showDelteAccount.value = true }
style: "width: 100%",
onClick: () => { showDelteAccount.value = true; showMobileMenu.value = false; }
},
{ default: () => t('delteAccount') }
),
show: openSettings.value.enableUserDeleteEmail,
key: "delte_account"
}
]
@@ -251,15 +259,15 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => { themeSwitch.value = !themeSwitch.value }
style: "width: 100%",
onClick: () => { toggleDark(); showMobileMenu.value = false; }
},
{
default: () => themeSwitch.value ? t('light') : t('dark'),
default: () => isDark.value ? t('light') : t('dark'),
icon: () => h(
NIcon, { component: themeSwitch.value ? LightModeFilled : DarkModeFilled }
NIcon, { component: isDark.value ? LightModeFilled : DarkModeFilled }
)
}
),
@@ -269,10 +277,13 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: false,
ghost: true,
text: true,
size: "small",
onClick: () => localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh')
style: "width: 100%",
onClick: () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
showMobileMenu.value = false;
}
},
{
default: () => localeCache.value == 'zh' ? "English" : "中文",
@@ -287,9 +298,9 @@ const menuOptions = computed(() => [
label: () => h(
NButton,
{
bordered: !isMobile.value,
ghost: true,
text: true,
size: "small",
style: "width: 100%",
tag: "a",
target: "_blank",
href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
@@ -303,21 +314,6 @@ const menuOptions = computed(() => [
}
]);
const menuOptionsMobile = computed(() => [
{
label: t('menu'),
icon: () => h(
NIcon,
{
component: MenuFilled
}
),
key: "menu",
children: menuOptions.value
},
]);
const copy = async () => {
try {
await toClipboard(settings.value.address)
@@ -381,13 +377,30 @@ onMounted(async () => {
<template>
<div>
<n-layout-header>
<h2 style="display: inline-block; margin-left: 10px;">{{ t('title') }}</h2>
<div>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-menu v-else mode="horizontal" :options="menuOptionsMobile" />
</div>
</n-layout-header>
<n-page-header>
<template #title>
<h3>{{ t('title') }}</h3>
</template>
<template #avatar>
<n-avatar style="margin-left: 10px;" src="/logo.png" />
</template>
<template #extra>
<n-space>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
</template>
{{ t('menu') }}
</n-button>
</n-space>
</template>
</n-page-header>
<n-drawer v-model:show="showMobileMenu" placement="top" style="height: 100vh;">
<n-drawer-content :title="t('menu')" closable>
<n-menu :options="menuOptions" />
</n-drawer-content>
</n-drawer>
<div v-if="!isAdminRoute">
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
@@ -457,7 +470,7 @@ onMounted(async () => {
<n-button @click="showNewEmail = false">
{{ t('cancel') }}
</n-button>
<n-button @click="newEmail" type="primary">
<n-button @click="newEmail" type="primary" :loading="loading">
{{ t('ok') }}
</n-button>
</template>
@@ -479,11 +492,12 @@ onMounted(async () => {
<template #header>
<div>{{ t('login') }}</div>
</template>
<AdminContact />
<n-input v-model:value="password" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="login" size="small" tertiary round type="primary">
<n-button @click="login" :loading="loading" size="small" tertiary round type="primary">
{{ t('login') }}
</n-button>
</template>
@@ -494,7 +508,7 @@ onMounted(async () => {
</template>
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button @click="logout" size="small" tertiary round type="primary">
<n-button :loading="loading" @click="logout" size="small" tertiary round type="primary">
{{ t('logout') }}
</n-button>
</template>
@@ -505,7 +519,7 @@ onMounted(async () => {
</template>
<p>{{ t('delteAccountConfirm') }}</p>
<template #action>
<n-button @click="deleteAccount" size="small" tertiary round type="error">
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary round type="error">
{{ t('delteAccount') }}
</n-button>
</template>
@@ -513,15 +527,15 @@ onMounted(async () => {
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
title="Dialog">
<template #header>
<div>{{ t('auth') }}</div>
<div>{{ t('accessHeader') }}</div>
</template>
<p>{{ t('authTip') }}</p>
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="textarea" :autosize="{
minRows: 3
}" />
<template #action>
<n-button @click="authFunc" size="small" tertiary round type="primary">
{{ t('auth') }}
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
@@ -535,6 +549,11 @@ onMounted(async () => {
justify-content: space-between;
}
.mobile-menu-button {
width: 100%;
text-align: left;
}
.n-alert {
margin-top: 10px;
margin-bottom: 10px;

View File

@@ -1,11 +1,22 @@
<script setup>
import MailBox from './MailBox.vue';
import MailBox from '../components/MailBox.vue';
import { useGlobalState } from '../store'
const { settings } = useGlobalState()
import { api } from '../api'
const { settings, openSettings } = useGlobalState()
const fetchMailData = async (limit, offset) => {
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
};
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
</script>
<template>
<div>
<MailBox v-if="settings.address" />
<div v-if="settings.address">
<MailBox :showEMailTo="false" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</div>
</template>

View File

@@ -1,14 +1,15 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { NMenu } from 'naive-ui';
import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth,
localeCache, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -19,6 +20,9 @@ const { t } = useI18n({
en: {
name: 'Name',
created_at: 'Created At',
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
@@ -35,6 +39,9 @@ const { t } = useI18n({
zh: {
name: '名称',
created_at: '创建时间',
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
@@ -116,6 +123,62 @@ const columns = [
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.mail_count > 0) {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
}
}
},
{
icon: () => h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.mail_count > 0 ? t('viewMails') : ""
}
)
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NButton,
{
text: true,
onClick: () => {
if (row.send_count > 0) {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
}
}
},
{
icon: () => h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.send_count > 0 ? t('viewSendBox') : ""
}
)
}
},
{
title: t('actions'),
key: 'actions',
@@ -132,8 +195,7 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => showPassword(row.id)
},
{ default: () => t('showPass') }
@@ -142,8 +204,7 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
adminMailTabAddress.value = row.name;
adminTab.value = "mails";
@@ -155,8 +216,7 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
adminSendBoxTabAddress.value = row.name;
adminTab.value = "sendBox";
@@ -168,8 +228,7 @@ const columns = [
{
label: () => h(NButton,
{
bordered: false,
ghost: true,
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
@@ -218,7 +277,7 @@ onMounted(async () => {
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button @click="deleteEmail" size="small" tertiary round type="error">
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary round type="error">
{{ t('delteAccount') }}
</n-button>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
const { localeCache, openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
adminContact: 'If you need help, please contact the administrator ({msg})',
},
zh: {
adminContact: '如果你需要帮助,请联系管理员 ({msg})',
}
}
});
</script>
<template>
<n-alert v-if="openSettings.adminContact" type="info" show-icon>
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
</n-alert>
</template>

View File

@@ -1,63 +1,43 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
import MailBox from '../../components/MailBox.vue';
const {
localeCache, adminAuth, showAdminAuth,
adminMailTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mails: 'Emails',
itemCount: 'itemCount',
addressQueryTip: 'Leave blank to query all addresses',
query: 'Query',
},
zh: {
mails: '邮件',
itemCount: '总数',
addressQueryTip: '留空查询所有地址',
query: '查询',
}
}
});
const mailData = ref([])
const mailCount = ref(0)
const mailPage = ref(1)
const mailPageSize = ref(20)
const mailBoxKey = ref("")
watch([mailPage, mailPageSize, adminMailTabAddress], async () => {
await fetchMailData()
})
const queryAddress = () => {
mailBoxKey.value = adminMailTabAddress.value;
}
const fetchMailData = async () => {
if (!adminMailTabAddress.value) {
return
}
try {
const { results, count } = await api.fetch(
`/admin/mails`
+ `?address=${adminMailTabAddress.value}`
+ `&limit=${mailPageSize.value}`
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
);
mailData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
const fetchMailData = async (limit, offset) => {
return await api.fetch(
`/admin/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
);
}
onMounted(async () => {
@@ -65,48 +45,17 @@ onMounted(async () => {
showAdminAuth.value = true;
return;
}
await fetchMailData()
})
</script>
<template>
<div>
<n-input-group>
<n-input v-model:value="adminMailTabAddress" />
<n-button @click="fetchMailData" type="primary" ghost>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-button @click="queryAddress" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -1,56 +1,18 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { onMounted } from 'vue';
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { processItem } from '../../utils/email-parser'
import MailBox from '../../components/MailBox.vue';
const {
localeCache, adminAuth, showAdminAuth
} = useGlobalState()
const message = useMessage()
const { adminAuth, showAdminAuth } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
itemCount: 'itemCount',
refresh: 'Refresh'
},
zh: {
itemCount: '总数',
refresh: '刷新'
}
}
});
const mailUnknowData = ref([])
const mailUnknowCount = ref(0)
const mailUnknowPage = ref(1)
const mailUnknowPageSize = ref(20)
watch([mailUnknowPage, mailUnknowPageSize], async () => {
await fetchMailUnknowData()
})
const fetchMailUnknowData = async () => {
try {
const { results, count } = await api.fetch(
`/admin/mails_unknow`
+ `?limit=${mailUnknowPageSize.value}`
+ `&offset=${(mailUnknowPage.value - 1) * mailUnknowPage.value}`
);
mailUnknowData.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (count > 0) {
mailUnknowCount.value = count;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
const fetchMailUnknowData = async (limit, offset) => {
return await api.fetch(
`/admin/mails_unknow`
+ `?limit=${limit}`
+ `&offset=${offset}`
);
}
onMounted(async () => {
@@ -58,49 +20,11 @@ onMounted(async () => {
showAdminAuth.value = true;
return;
}
await fetchMailUnknowData();
})
</script>
<template>
<div>
<n-button @click="fetchMailUnknowData" type="primary" ghost>
{{ t('refresh') }}
</n-button>
<n-list hoverable clickable>
<div style="display: inline-block; margin-bottom: 10px;">
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
:item-count="mailUnknowCount" simple>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-list-item v-for="row in mailUnknowData" v-bind:key="row.id">
<n-thing class="center" :title="row.subject">
<template #description>
<n-space>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
TO: {{ row.address }}
</n-tag>
</n-space>
</template>
<div v-html="row.message"></div>
</n-thing>
</n-list-item>
</n-list>
<div v-if="adminAuth">
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -44,15 +44,12 @@ const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
if (!adminSendBoxTabAddress.value) {
return
}
try {
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?address=${adminSendBoxTabAddress.value}`
+ `&limit=${pageSize.value}`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
data.value = results.map((item) => {
try {

View File

@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache } = useGlobalState()
const { localeCache, loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
@@ -22,7 +22,7 @@ const { t } = useI18n({
itemCount: 'itemCount',
modalTip: 'Please input the sender balance',
balance: 'Balance',
refresh: 'Refresh',
query: 'Query',
ok: 'OK'
},
zh: {
@@ -36,7 +36,7 @@ const { t } = useI18n({
itemCount: '总数',
modalTip: '请输入发件额度',
balance: '余额',
refresh: '刷新',
query: '查询',
ok: '确定'
}
}
@@ -51,6 +51,7 @@ const showModal = ref(false)
const senderBalance = ref(0)
const senderEnabled = ref(false)
const addressQuery = ref('')
const updateData = async () => {
try {
@@ -77,6 +78,7 @@ const fetchData = async () => {
`/admin/address_sender`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&address=${addressQuery.value}` : '')
);
data.value = results;
if (addressCount > 0) {
@@ -161,22 +163,23 @@ onMounted(async () => {
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
</n-form-item>
<template #action>
<n-button @click="updateData()" size="small" tertiary round type="primary">
<n-button :loading="loading" @click="updateData()" size="small" tertiary round type="primary">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" />
<n-button @click="fetchData" type="primary" ghost>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" ghost>
{{ t('refresh') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />

View File

@@ -59,34 +59,36 @@ onMounted(async () => {
</script>
<template>
<n-row>
<n-col :span="6">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
<n-card>
<n-row>
<n-col :span="6">
<n-statistic :label="t('userCount')" :value="statistics.userCount">
<template #prefix>
<n-icon :component="User" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('activeUser')" :value="statistics.activeUserCount7days">
<template #prefix>
<n-icon :component="UserCheck" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('mailCount')" :value="statistics.mailCount">
<template #prefix>
<n-icon :component="MailBulk" />
</template>
</n-statistic>
</n-col>
<n-col :span="6">
<n-statistic :label="t('sendMailCount')" :value="statistics.sendMailCount">
<template #prefix>
<n-icon :component="SendOutlined" />
</template>
</n-statistic>
</n-col>
</n-row>
</n-card>
</template>

View File

@@ -1,7 +1,11 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { DomEditor } from '@wangeditor/editor'
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useStorage } from '@vueuse/core'
import AdminContact from '../admin/AdminContact.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
@@ -9,13 +13,14 @@ import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const mailModel = useStorage('mailModelCache', {
fromName: "",
toName: "",
toMail: "",
subject: "",
isHtml: false,
contentType: 'text',
content: "",
})
@@ -30,7 +35,6 @@ const { t } = useI18n({
toName: 'Recipient Name and Address, leave Name blank to use email address',
subject: 'Subject',
options: 'Options',
isHtml: 'Enable HTML',
edit: 'Edit',
preview: 'Preview',
content: 'Content',
@@ -38,6 +42,10 @@ const { t } = useI18n({
requestAccess: 'Request Access',
requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.',
send_balance: 'Send Mail Balance Left',
text: 'Text',
html: 'HTML',
'rich text': 'Rich Text',
tooLarge: 'Too large file, please upload file less than 1MB.',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查您的余额或稍后重试。',
@@ -45,7 +53,6 @@ const { t } = useI18n({
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
isHtml: '启用HTML',
edit: '编辑',
preview: '预览',
content: '内容',
@@ -53,10 +60,20 @@ const { t } = useI18n({
requestAccess: '申请权限',
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。',
send_balance: '剩余发送邮件额度',
text: '文本',
html: 'HTML',
'rich text': '富文本',
tooLarge: '文件过大, 请上传小于1MB的文件。',
}
}
});
const contentTypes = [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
const send = async () => {
try {
await api.fetch(`/api/send_mail`,
@@ -68,7 +85,7 @@ const send = async () => {
to_name: mailModel.value.toName,
to_mail: mailModel.value.toMail,
subject: mailModel.value.subject,
is_html: mailModel.value.isHtml,
is_html: mailModel.value.contentType != 'text',
content: mailModel.value.content,
})
})
@@ -77,7 +94,7 @@ const send = async () => {
toName: "",
toMail: "",
subject: "",
isHtml: false,
contentType: 'text',
content: "",
}
} catch (error) {
@@ -103,6 +120,32 @@ const requestAccess = async () => {
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
onMounted(async () => {
await api.getSettings();
})
@@ -117,6 +160,7 @@ onMounted(async () => {
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
</n-alert>
<br />
<AdminContact />
</div>
<div v-else>
<n-alert type="info" show-icon>
@@ -143,15 +187,25 @@ onMounted(async () => {
<n-input v-model:value="mailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-checkbox v-model:checked="mailModel.isHtml">
{{ t('isHtml') }}
</n-checkbox>
<n-button v-if="mailModel.isHtml" @click="isPreview = !isPreview">
<n-radio-group v-model:value="mailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="mailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<div v-if="isPreview" v-html="mailModel.content" />
<n-card v-if="isPreview">
<div v-html="mailModel.content" />
</n-card>
<div v-else-if="mailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="mailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="mailModel.content" :autosize="{
minRows: 3
}" />

View File

@@ -3,7 +3,6 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { splitVendorChunkPlugin } from 'vite';
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
@@ -14,12 +13,20 @@ import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
build: {
outDir: './dist',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('wangeditor')) {
return 'vendor-wangeditor';
}
}
}
}
},
plugins: [
vue(),
wasm(),
topLevelAwait(),
splitVendorChunkPlugin(),
AutoImport({
imports: [
'vue',

View File

@@ -68,9 +68,17 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# admin contact information. If not configured, it will not be displayed. Any string can be configured.
# ADMIN_CONTACT = "xx@xx.xxx"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # your domain name
JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# dkim config
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section

View File

@@ -20,7 +20,7 @@ features:
- title: 免费托管在 CloudFlare无需服务器
details: Cloudflare D1 数据库Cloudflare Pages 前端Cloudflare Workers 后端, Cloudflare Email Routing
- title: 仅需域名即可私有部署
details: 支持 password 登录邮箱,访问授权可作为私人站点,支持附件功能
details: 支持 password 登录邮箱,使用访问密码可作为私人站点,支持附件功能
- title: 使用 rust wasm 解析邮件
details: 使用 rust wasm 解析邮件支持邮件各种RFC标准支持附件, 速度极快
- title: 支持发送邮件

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -26,9 +26,17 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
# admin 联系方式,不配置则不显示,可配置任意字符串
# ADMIN_CONTACT = "xx@xx.xxx"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
# 允许用户删除邮件, 不配置则不允许
ENABLE_USER_DELETE_EMAIL = true
# 允许自动回复邮件
ENABLE_AUTO_REPLY = false
# 默认发送邮件余额,如果不设置,将为 0
# DEFAULT_SEND_BALANCE = 1
# dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
@@ -59,3 +67,8 @@ pnpm run deploy
部署成功之后再路由中可以看到 `worker``url`,控制台也会输出 `worker``url`
![worker](/readme_assets/worker.png)
> [!NOTE]
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
>
> 打开 `/health_check`,如果显示 `OK` 说明部署成功

View File

@@ -10,11 +10,24 @@
3. 下载 [worker.js](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker.js)
4. 回到 `Overview`,找到刚刚创建的 worker点击 `Edit Code`, 上传 `worker.js`, 删除 `index.js`,然后重命名 `worker.js``index.js`, 点击 `Deploy`
4. 回到 `Overview`,找到刚刚创建的 worker点击 `Edit Code`, 删除原来的文件,上传 `worker.js`, 点击 `Deploy`
> [!NOTE]
> 上传需要先点击左侧菜单的 Explorer,
> 在文件列表的窗口里点击鼠标右键,在右键菜单里找到 `Upload`,
> 请参考下面的截图
>
> 参考: [issues156](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/156#issuecomment-2079453822)
![worker2](/ui_install/worker-2.png)
![worker-upload](/ui_install/worker-upload.png)
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。能打开域名说明部署成功,记录下这个域名,后面部署前端会用到。
5. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
> [!NOTE]
> 打开 `worker` 的 `url`,如果显示 `OK` 说明部署成功
>
> 打开 `/health_check`,如果显示 `OK` 说明部署成功
![worker3](/ui_install/worker-3.png)

View File

@@ -10,10 +10,10 @@
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
},
"devDependencies": {
"wrangler": "^3.48.0"
"wrangler": "^3.52.0"
},
"dependencies": {
"hono": "^4.2.2",
"hono": "^4.2.7",
"mimetext": "^3.0.24"
}
}

992
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,25 +15,31 @@ api.get('/admin/address', async (c) => {
}
if (query) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address where concat('${c.env.PREFIX}', name) like ? order by id desc limit ? offset ? `
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` where name like ?`
+ ` order by id desc limit ? offset ?`
).bind(`%${query}%`, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where concat('${c.env.PREFIX}', name) like ?`
`SELECT count(*) as count FROM address where name like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address order by id desc limit ? offset ? `
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
@@ -43,10 +49,7 @@ api.get('/admin/address', async (c) => {
count = addressCount;
}
return c.json({
results: results.map((r) => {
r.name = c.env.PREFIX + r.name;
return r;
}),
results: results,
count: count
})
})
@@ -61,7 +64,7 @@ api.delete('/admin/delete_address/:id', async (c) => {
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM mails WHERE address IN
(select concat('${c.env.PREFIX}', name) from address where id = ?) `
(select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
@@ -79,10 +82,8 @@ api.get('/admin/show_password/:id', async (c) => {
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
// compute address
const emailAddress = c.env.PREFIX + name
const jwt = await Jwt.sign({
address: emailAddress,
address: name,
address_id: id
}, c.env.JWT_SECRET)
return c.json({
@@ -99,6 +100,22 @@ api.get('/admin/mails', async (c) => {
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (!address) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM raw_mails order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
@@ -125,7 +142,7 @@ api.get('/admin/mails_unknow', async (c) => {
}
const { results } = await c.env.DB.prepare(`
SELECT * FROM raw_mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
where address NOT IN (select name from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
@@ -133,7 +150,7 @@ api.get('/admin/mails_unknow', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
(select name from address)`
).first();
count = mailCount;
}
@@ -144,13 +161,29 @@ api.get('/admin/mails_unknow', async (c) => {
});
api.get('/admin/address_sender', async (c) => {
const { limit, offset } = c.req.query();
const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (address) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM address_sender where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address_sender where address = ?`
).bind(address).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address_sender order by id desc limit ? offset ? `
).bind(limit, offset).all();
@@ -190,7 +223,42 @@ api.post('/admin/address_sender', async (c) => {
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
return getSendbox(c, address, limit, offset);
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (!address) {
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox where address = ?`
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
})
api.get('/admin/statistics', async (c) => {
@@ -232,7 +300,7 @@ api.post('/admin/cleanup', async (c) => {
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select concat('${c.env.PREFIX}', name) from address) AND created_at < datetime('now', '-${cleanDays} day')`
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":

View File

@@ -37,15 +37,14 @@ api.get('/admin/v1/mails_unknow', async (c) => {
}
const { results } = await c.env.DB.prepare(`
SELECT id, source, subject, message FROM mails
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
where address NOT IN(select name from address)
order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
where address NOT IN (select name from address)`
).first();
count = mailCount;
}

View File

@@ -7,21 +7,21 @@ async function email(message, env, ctx) {
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
if (!env.PREFIX || (message.to && message.to.startsWith(env.PREFIX))) {
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// auto reply email
// auto reply email
if (env.ENABLE_AUTO_REPLY) {
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
@@ -50,9 +50,6 @@ async function email(message, env, ctx) {
} catch (error) {
console.log("reply email error", error);
}
} else {
message.setReject(`Unknown address ${message.to}`);
console.log(`Unknown address ${message.to}`);
}
}

View File

@@ -18,7 +18,7 @@ api.get('/api/mails', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, raw, created_at FROM raw_mails where address = ? order by id desc limit ? offset ?`
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
@@ -34,6 +34,9 @@ api.get('/api/mails', async (c) => {
})
api.delete('/api/mails/:id', async (c) => {
if (!c.env.ENABLE_USER_DELETE_EMAIL) {
return c.text("User delete email is disabled", 403)
}
const { address } = c.get("jwtPayload")
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
@@ -58,40 +61,40 @@ api.get('/api/settings', async (c) => {
return c.text("Invalid address", 400)
}
}
if (address.startsWith(c.env.PREFIX)) {
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address.substring(c.env.PREFIX.length)).first("id");
if (!db_address_id) {
return c.text("Invalid address", 400)
}
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address).first("id");
if (!db_address_id) {
return c.text("Invalid address", 400)
}
} catch (error) {
return c.text("Invalid address", 400)
}
// update address updated_at
try {
c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address.substring(c.env.PREFIX.length)).run();
} catch (e) {
console.warn("Failed to update address")
}
} catch (error) {
return c.text("Invalid address", 400)
}
// update address updated_at
try {
c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address).run();
} catch (e) {
console.warn("Failed to update address")
}
let auto_reply = {};
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (results) {
auto_reply = {
subject: results.subject,
message: results.message,
enabled: results.enabled == 1,
source_prefix: results.source_prefix,
name: results.name,
if (c.env.ENABLE_AUTO_REPLY) {
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (results) {
auto_reply = {
subject: results.subject,
message: results.message,
enabled: results.enabled == 1,
source_prefix: results.source_prefix,
name: results.name,
}
}
}
const { count: mailCountV1 } = await c.env.DB.prepare(
@@ -111,6 +114,9 @@ api.get('/api/settings', async (c) => {
api.post('/api/settings', async (c) => {
const { address } = c.get("jwtPayload")
if (!c.env.ENABLE_AUTO_REPLY) {
return c.text("Auto reply is disabled", 403)
}
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {
@@ -146,6 +152,9 @@ api.get('/open_api/settings', async (c) => {
"prefix": c.env.PREFIX,
"domains": getDomains(c),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserDeleteEmail": c.env.ENABLE_USER_DELETE_EMAIL,
"enableAutoReply": c.env.ENABLE_AUTO_REPLY,
});
})
@@ -170,11 +179,11 @@ api.get('/api/new_address', async (c) => {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
const emailAddress = c.env.PREFIX + name + "@" + domain
name = c.env.PREFIX + name + "@" + domain
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name + "@" + domain).run();
).bind(name).run();
if (!success) {
return c.text("Failed to create address", 500)
}
@@ -188,13 +197,13 @@ api.get('/api/new_address', async (c) => {
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name + "@" + domain).first("id");
).bind(name).first("id");
} catch (error) {
console.log(error);
}
// create jwt
const jwt = await Jwt.sign({
address: emailAddress,
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
@@ -203,11 +212,11 @@ api.get('/api/new_address', async (c) => {
})
api.delete('/api/delete_address', async (c) => {
if (!c.env.ENABLE_USER_DELETE_EMAIL) {
return c.text("User delete email is disabled", 403)
}
const { address } = c.get("jwtPayload")
let name = address;
if (address.startsWith(c.env.PREFIX)) {
name = address.substring(c.env.PREFIX.length);
}
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE name = ? `
).bind(name).run();

View File

@@ -8,9 +8,12 @@ api.post('/api/requset_send_mail_access', async (c) => {
return c.text("No address", 400)
}
try {
const default_balance = c.env.DEFAULT_SEND_BALANCE || 0;
const { success } = await c.env.DB.prepare(
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, 1, 1)`
).bind(address).run();
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)`
).bind(
address, default_balance, default_balance > 0 ? 1 : 0
).run();
if (!success) {
return c.text("Failed to request send mail access", 500)
}

View File

@@ -23,13 +23,14 @@ export const getPasswords = (c) => {
// check if PASSWORDS is an array, if not use json.parse
if (!Array.isArray(c.env.PASSWORDS)) {
try {
return JSON.parse(c.env.PASSWORDS);
let res = JSON.parse(c.env.PASSWORDS);
return res.filter((item) => item.length > 0);
} catch (e) {
console.error("Failed to parse PASSWORDS", e);
return [];
}
}
return c.env.PASSWORDS;
return c.env.PASSWORDS.filter((item) => item.length > 0);
}
export const getAdminPasswords = (c) => {

View File

@@ -57,6 +57,8 @@ app.route('/', adminApi)
app.route('/', apiV1)
app.route('/', apiSendMail)
app.get('/', async c => c.text("OK"))
app.get('/health_check', async c => c.text("OK"))
app.all('/*', async c => c.text("Not Found", 404))

View File

@@ -13,9 +13,17 @@ PREFIX = "tmp"
# PASSWORDS = ["123", "456"]
# For admin panel
# ADMIN_PASSWORDS = ["123", "456"]
# ADMIN CONTACT, CAN BE ANY STRING
# ADMIN_CONTACT = "xx@xx.xxx"
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
JWT_SECRET = "xxx"
BLACK_LIST = ""
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# dkim config
# DKIM_SELECTOR = ""
# DKIM_PRIVATE_KEY = ""