Compare commits

...

21 Commits

Author SHA1 Message Date
Dream Hunter
fb74504282 Update tag_build.yml 2024-04-14 15:07:45 +08:00
Dream Hunter
cb758ec012 Update tag_build.yml 2024-04-14 13:47:52 +08:00
Dream Hunter
02835c18e9 Update tag_build.yml 2024-04-14 13:38:46 +08:00
Dream Hunter
f83492e683 Update README.md 2024-04-14 00:42:57 +08:00
Dream Hunter
074a3b6f2a Update tag_build.yml 2024-04-12 23:28:41 +08:00
Dream Hunter
d738210cb5 feat: add Tag Build CI (#120) 2024-04-12 23:24:21 +08:00
Dream Hunter
cfeafb2d30 Update tag_build.yml 2024-04-12 23:17:14 +08:00
Dream Hunter
49d29ac7cc feat: add Tag Build CI (#119) 2024-04-12 23:14:25 +08:00
Dream Hunter
372b71b08b feat: add Tag Build CI (#118) 2024-04-12 23:08:51 +08:00
Dream Hunter
0c5365da1f feat: add Tag Build CI (#117) 2024-04-12 23:04:39 +08:00
Dream Hunter
58ad025e61 fix: UI senderEnabled not changed when open modal (#116) 2024-04-12 22:10:45 +08:00
Dream Hunter
6c41288a7b feat: update changelog (#114) 2024-04-12 13:28:57 +08:00
Dream Hunter
b8f0fa49cf feat: init send mail (#113)
* feat: init send mail

* feat: init send mail
2024-04-12 13:26:42 +08:00
Dream Hunter
ee2fdab279 feat: update readme (#112) 2024-04-10 13:28:44 +08:00
Dream Hunter
fbd2e0e844 fix: UI overflow (#111)
* feat: UI overflow

* feat: add domain
2024-04-10 13:25:26 +08:00
Dream Hunter
165efa69cc feat: UI humanFileSize (#110) 2024-04-09 22:42:51 +08:00
Dream Hunter
2790f65a5f Rename CHANGELOG to CHANGELOG.md (#109)
* Rename CHANGELOG to CHANGELOG.md

* Update README.md

* Update README_EN.md
2024-04-09 21:59:39 +08:00
Dream Hunter
37a9b0557a feat: UI use fakerjs cdn && overflow: auto (#108) 2024-04-09 21:58:07 +08:00
Dream Hunter
42a828e98b feat: add faker-js (#107) 2024-04-09 19:30:35 +08:00
Dream Hunter
a124e00766 feat: add readme (#106) 2024-04-09 18:55:53 +08:00
Dream Hunter
796d72badb fix: button size && admin statistics (#105) 2024-04-09 18:30:50 +08:00
29 changed files with 1362 additions and 371 deletions

46
.github/workflows/tag_build.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Tag Build CI
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Build Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip *
- name: cp wrangler.toml
run: cd worker && cp wrangler.toml.template wrangler.toml
- name: Build Backend
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: |
frontend/dist/frontend.zip
worker/dist/worker.js

View File

@@ -1,34 +0,0 @@
# CHANGE LOG
## 2024-04-10 v0.0.1
Breaking changes:
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
DB changes
- `db/2024-04-09-patch.sql`
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

76
CHANGELOG.md Normal file
View File

@@ -0,0 +1,76 @@
# CHANGE LOG
## 2024-04-12 v0.2.1
- support send email
DB changes:
- `db/2024-04-12-patch.sql`
## 2024-04-10 v0.2.0
### Breaking Changes
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
### Rust Mail Parser
由于 nodejs 解析 email 的库有些问题,此版本切换到使用 rust wasm 调用 rust 的mail 解析库
- 速度更快,附件支持好,可以显示邮件的附件图片
- 解析支持更多 rfc 规范
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
- Faster speed, good attachment support, can display attachment images of emails
- Parsing supports more rfc specifications
### DB changs
`mails` 表废弃,新的 `mail``raw` 文本将直接存入 `raw_mails` 表.
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
## Upgrade Step
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
pnpm run deploy
cd ../frontend
pnpm run deploy
```
注意:对于历史邮件,请使用部署新网页查看旧数据。
Note: For historical messages, use the Deploy New web page to view old data.
```bash
git checkout feature/backup
cd frontend
# 创建一个新的 pages, 用于访问旧数据
pnpm run deploy --project-name temp-email-v1
```
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

View File

@@ -2,9 +2,16 @@
## [English](README_EN.md)
[CHANGELOG](CHANGELOG)
## [新版部署文档](https://temp-mail-docs.awsl.uk)
[Backend](https://temp-email-api.dreamhunter2333.xyz/)
## [CHANGELOG](CHANGELOG.md)
## [在线演示](https://mail.awsl.uk/)
[https://mail.awsl.uk](https://mail.awsl.uk/)
或者 [https://temp-email.dreamhunter2333.xyz](https://temp-email.dreamhunter2333.xyz/)
[Backend](https://temp-email-api.awsl.uk/)
![](https://uptime.aks.awsl.icu/api/badge/10/status)
![](https://uptime.aks.awsl.icu/api/badge/10/uptime)
![](https://uptime.aks.awsl.icu/api/badge/10/ping)
@@ -12,7 +19,7 @@
![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp)
![](https://uptime.aks.awsl.icu/api/badge/10/response)
[Frontend](https://temp-email.dreamhunter2333.xyz/)
[Frontend](https://mail.awsl.uk/)
![](https://uptime.aks.awsl.icu/api/badge/12/status)
![](https://uptime.aks.awsl.icu/api/badge/12/uptime)
![](https://uptime.aks.awsl.icu/api/badge/12/ping)
@@ -28,6 +35,7 @@
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [English](#english)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [什么是临时邮箱](#什么是临时邮箱)
@@ -38,9 +46,9 @@
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
- [Cloudflare Email Routing](#cloudflare-email-routing)
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
- [配置发送邮件](#配置发送邮件)
- [参考资料](#参考资料)
## [在线演示](https://temp-email.dreamhunter2333.xyz/)
## 功能/TODO
@@ -55,6 +63,7 @@
- [x] 增加自动回复功能
- [x] 增加查看附件功能
- [x] 使用 rust wasm 解析邮件
- [x] 支持发送邮件
---
@@ -150,6 +159,8 @@ database_id = "xxx" # D1 数据库 ID
部署
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
pnpm run deploy
```
@@ -162,6 +173,8 @@ pnpm run deploy
## Cloudflare Email Routing
在将电子邮件地址绑定到您的 Worker 之前,您需要启用电子邮件路由并拥有至少一个经过验证的电子邮件地址。
配置对应域名的 `电子邮件 DNS 记录`
配置 `Cloudflare Email Routing` catch-all 发送到 `worker`
@@ -172,6 +185,8 @@ pnpm run deploy
## Cloudflare Pages 前端
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash
cd frontend
pnpm install
@@ -190,6 +205,19 @@ pnpm run deploy
![pages](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`
## 参考资料
- https://developers.cloudflare.com/d1/

View File

@@ -2,9 +2,11 @@
## [中文](README.md)
[CHANGELOG](CHANGELOG)
[CHANGELOG](CHANGELOG.md)
## [Live Demo](https://temp-email.dreamhunter2333.xyz/)
## [Live Demo](https://mail.awsl.uk/)
[https://mail.awsl.uk](https://mail.awsl.uk/)
This is a temporary email service that uses Cloudflare Workers to create a temporary email address and view the received email in web browser.
@@ -21,6 +23,7 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo
- [x] Add auto reply feature
- [x] Add attachment viewing function
- [x] use rust wasm to parse email
- [x] support send email
![demo](readme_assets/demo.png)
@@ -43,6 +46,8 @@ wrangler d1 execute dev --file=db/schema.sql
### Backend - Cloudflare workers
The first deployment will prompt you to create a project. Please fill in `production` for the `production` branch.
```bash
cd worker
pnpm install
@@ -65,12 +70,18 @@ you can find and test the worker's url in the workers dashboard
![worker](readme_assets/worker.png)
## Cloudflare Email Routing
Before you can bind an email address to your Worker, you need to enable Email Routing and have at least one verified email address.
enable email route and config email forward catch-all to the worker
![email](readme_assets/email.png)
### Frontend - Cloudflare pages
The first deployment will prompt you to create a project. Please fill in `production` for the `production` branch.
```bash
cd frontend
pnpm install
@@ -82,3 +93,16 @@ pnpm run deploy
```
![pages](readme_assets/pages.png)
## Configure sending emails
Find the `SPF` record of `TXT` in the domain name `DNS` record, and add `include:relay.mailchannels.net`
```bash
v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all
```
Create a new `_mailchannels` record, the type is `TXT`, the content is `v=mc1 cfid=your worker domain name`
- The worker domain name here is the domain name of the back-end api. For example, if I deploy it at `https://temp-email-api.awsl.uk/`, fill in `v=mc1 cfid=awsl.uk`
- If your domain name is `https://temp-email-api.xxx.workers.dev`, fill in `v=mc1 cfid=xxx.workers.dev`

14
db/2024-04-12-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
balance INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sendbox (
id INTEGER PRIMARY KEY,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
@@ -17,6 +19,8 @@ CREATE TABLE IF NOT EXISTS raw_mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
@@ -24,6 +28,8 @@ CREATE TABLE IF NOT EXISTS address (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -35,6 +41,8 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
source TEXT,
@@ -43,3 +51,22 @@ CREATE TABLE IF NOT EXISTS attachments (
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
balance INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_sender_address ON address_sender(address);
CREATE TABLE IF NOT EXISTS sendbox (
id INTEGER PRIMARY KEY,
address TEXT,
raw TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);

View File

@@ -6,8 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy": "npm run build && wrangler pages deploy ../dist --branch production"
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
},
"dependencies": {
"@vicons/material": "^0.12.0",

View File

@@ -77,6 +77,7 @@ const getSettings = async () => {
address: res["address"],
auto_reply: res["auto_reply"],
has_v1_mails: res["has_v1_mails"],
send_balance: res["send_balance"],
};
} finally {
settings.value.fetched = true;

View File

@@ -12,7 +12,7 @@ const i18n = createI18n({
'en': {
messages: {}
},
'zhCN': {
'zh': {
messages: {}
}
})

View File

@@ -1,7 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import Settings from '../views/Settings.vue'
import SendMail from '../views/send/SendMail.vue'
import Admin from '../views/Admin.vue'
import SendBox from '../views/send/SendBox.vue'
const router = createRouter({
history: createWebHistory(),
@@ -14,6 +16,14 @@ const router = createRouter({
path: '/settings',
component: Settings
},
{
path: '/send',
component: SendMail
},
{
path: '/sendbox',
component: SendBox
},
{
path: '/admin',
component: Admin

View File

@@ -15,6 +15,7 @@ export const useGlobalState = createGlobalState(
const settings = ref({
fetched: false,
has_v1_mails: false,
send_balance: 0,
address: '',
auto_reply: {
subject: '',

View File

@@ -1,6 +1,11 @@
import PostalMime from 'postal-mime';
import { parse_message } from 'mail-parser-wasm'
function humanFileSize(size) {
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return parseFloat((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i];
}
export async function processItem(item) {
// Try to parse the email using mail-parser-wasm
try {
@@ -20,7 +25,7 @@ export async function processItem(item) {
return {
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "",
size: a_item.content?.length || 0,
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
}
}) || [];
@@ -52,7 +57,7 @@ export async function processItem(item) {
return {
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "",
size: a_item.content?.length || 0,
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
}
}) || [];

View File

@@ -6,7 +6,8 @@ import { User, UserCheck, MailBulk } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
import { processItem } from '../utils/email-parser'
import SenderAccess from './admin/SenderAccess.vue'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
const router = useRouter()
@@ -49,6 +50,7 @@ const { t } = useI18n({
account: 'Account',
unknow: 'Unknow',
addressQueryTip: 'Leave blank to query all addresses',
senderAccess: 'Sender Access Control',
},
zh: {
title: '临时邮件 Admin',
@@ -72,6 +74,7 @@ const { t } = useI18n({
account: '账号',
unknow: '未知',
addressQueryTip: '留空查询所有地址',
senderAccess: '发件权限控制',
}
}
});
@@ -396,6 +399,9 @@ const fetchMailUnknowData = async () => {
</n-list-item>
</n-list>
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -4,7 +4,7 @@ import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled } from '@vicons/material'
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled, SendFilled } from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
@@ -73,10 +73,13 @@ const { t } = useI18n({
home: 'Home',
menu: 'Menu',
user: 'User',
sendbox: 'Send Box',
sendMail: 'Send Mail',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmailTip1: 'Please input the email you want to use.',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
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',
@@ -86,6 +89,7 @@ const { t } = useI18n({
showPassword: 'Show Password',
fetchAddressError: 'Fetch address error, maybe your jwt is invalid or network error.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
generateName: 'Generate Fake Name',
},
zh: {
title: 'Cloudflare 临时邮件',
@@ -102,10 +106,13 @@ const { t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
sendbox: '发件箱',
sendMail: '发送邮件',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '获取新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
yourAddress: '你的邮箱地址是',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
@@ -116,6 +123,7 @@ const { t } = useI18n({
showPassword: '查看密码',
fetchAddressError: '获取地址失败, 请检查你的 jwt 是否有效 或 网络是否正常。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
generateName: '生成随机名字',
}
}
});
@@ -172,6 +180,19 @@ const menuOptions = computed(() => [
show: showUserMenu.value,
key: "user",
children: [
{
label: () => h(
NButton,
{
tertiary: true,
ghost: true,
size: "small",
onClick: () => router.push('/sendbox')
},
{ default: () => t('sendbox') }
),
key: "sendbox"
},
{
label: () => h(
NButton,
@@ -306,6 +327,23 @@ const copy = async () => {
}
}
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.person
.fullName()
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(
@@ -337,6 +375,7 @@ const deleteAccount = async () => {
onMounted(async () => {
await api.getOpenSettings(message);
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
await api.getSettings();
});
</script>
@@ -357,7 +396,7 @@ onMounted(async () => {
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small"
href="https://temp-email-v1.dreamhunter2333.xyz/">
href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
@@ -365,6 +404,10 @@ onMounted(async () => {
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary round
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
@@ -391,18 +434,25 @@ onMounted(async () => {
<template #header>
<div>{{ t('getNewEmail') }}</div>
</template>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
</span>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false" :options="openSettings.domains" />
</n-input-group>
<n-spin :show="generateNameLoading">
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</n-spin>
<template #action>
<n-button @click="showNewEmail = false">
{{ t('cancel') }}

View File

@@ -1,299 +1,11 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import MailBox from './MailBox.vue';
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'
const message = useMessage()
const isMobile = useIsMobile()
const { settings, themeSwitch } = useGlobalState()
const autoRefresh = ref(false)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
autoRefresh: 'Auto Refresh',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view."
},
zh: {
autoRefresh: '自动刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。"
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
if (autoRefresh) {
timer.value = setInterval(async () => {
await refresh();
}, 30000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
if (typeof settings.value.address != 'string' || settings.value.address.trim() === '') {
return;
}
try {
const { results, count: totalCount } = await api.fetch(
`/api/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
onMounted(async () => {
await api.getSettings();
await refresh();
});
const { settings } = useGlobalState()
</script>
<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') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button class="center" @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: scroll; 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" style="overflow: scroll">
<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: scroll;">
<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-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.raw)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;max-height: 100vh;"></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: scroll; max-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" style="overflow: scroll">
<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: scroll;">
<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-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)">
{{ t('downloadMail') }}
<n-icon :component="CloudDownloadRound" />
</n-button>
</n-space>
<div v-html="curMail.message" style="max-height: 100vh;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</n-layout>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small'" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
<template #action>
</template>
</n-modal>
<MailBox v-if="settings.address" />
</div>
</template>
<style scoped>
.left {
overflow: scroll;
text-align: left;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup>
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'
const message = useMessage()
const isMobile = useIsMobile()
const { settings, themeSwitch } = useGlobalState()
const autoRefresh = ref(false)
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const curMail = ref(null);
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
autoRefresh: 'Auto Refresh',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view."
},
zh: {
autoRefresh: '自动刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。"
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
if (autoRefresh) {
timer.value = setInterval(async () => {
await refresh();
}, 30000)
} else {
clearInterval(timer.value)
timer.value = null
}
}
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await api.fetch(
`/api/mails`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = await Promise.all(results.map(async (item) => {
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
onMounted(async () => {
await refresh();
});
</script>
<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') }}
</template>
<template #unchecked>
{{ t('autoRefresh') }}
</template></n-switch>
<n-button class="center" @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 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-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-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.raw)">
<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-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-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
<template #action>
</template>
</n-modal>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
</style>

View File

@@ -2,7 +2,6 @@
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import Header from './Header.vue'
import { useGlobalState } from '../store'
import { api } from '../api'
@@ -43,7 +42,6 @@ const { t } = useI18n({
});
const getSettings = async () => {
await api.getSettings()
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
enableAutoReply.value = settings.value.auto_reply.enabled || false
name.value = settings.value.auto_reply.name || ""

View File

@@ -0,0 +1,190 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
enable: 'Enable',
disable: 'Disable',
modify: 'Modify',
created_at: 'Created At',
action: 'Action',
itemCount: 'itemCount',
modalTip: 'Please input the sender balance',
balance: 'Balance',
refresh: 'Refresh',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
enable: '启用',
disable: '禁用',
modify: '修改',
created_at: '创建时间',
action: '操作',
itemCount: '总数',
modalTip: '请输入发件额度',
balance: '余额',
refresh: '刷新',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const senderBalance = ref(0)
const senderEnabled = ref(false)
const updateData = async () => {
try {
await api.fetch(`/admin/address_sender`, {
method: 'POST',
body: JSON.stringify({
address_id: curRow.value.id,
balance: senderBalance.value,
enabled: senderEnabled.value ? 1 : 0
})
});
showModal.value = false;
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/admin/address_sender`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('balance'),
key: "balance"
},
{
title: "Enabled",
key: "enabled",
render(row) {
return h('div', [
h('span', row.enabled ? t('enable') : t('disable'))
])
}
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
senderEnabled.value = row.enabled ? true : false;
senderBalance.value = row.balance;
}
},
{ default: () => t('modify') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showModal" preset="dialog">
<p>{{ curRow.address }}</p>
<p>{{ t('modalTip') }}</p>
<n-form-item :show-label="false">
<n-checkbox v-model:checked="senderEnabled">
{{ t('enable') }}
</n-checkbox>
</n-form-item>
<n-form-item :show-label="false">
<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">
{{ t('ok') }}
</n-button>
</template>
</n-modal>
<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" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,156 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
refresh: 'Refresh',
itemCount: 'itemCount',
view: 'View',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
refresh: '刷新',
itemCount: '总数',
view: '查看',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/api/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog">
<pre>{{ curRow.raw }}</pre>
</n-modal>
<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" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { useStorage } from '@vueuse/core'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import router from '../../router'
const message = useMessage()
const isPreview = ref(false)
const mailModel = useStorage('mailModelCache', {
fromName: "",
toName: "",
toMail: "",
subject: "",
isHtml: false,
content: "",
})
const { settings } = useGlobalState()
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
successSend: 'Please check your sendbox. If failed, please check your balance or try again later.',
fromName: 'Your Name and Address, leave Name blank to use email address',
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',
send: 'Send',
requestAccess: 'Request Access',
requestAccessTip: 'You need to request access to send mail',
send_balance: 'Send Mail Balance Left',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查您的余额或稍后重试。',
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
isHtml: '启用HTML',
edit: '编辑',
preview: '预览',
content: '内容',
send: '发送',
requestAccess: '申请权限',
requestAccessTip: '您需要申请权限才能发送邮件',
send_balance: '剩余发送邮件额度',
}
}
});
const send = async () => {
try {
await api.fetch(`/api/send_mail`,
{
method: 'POST',
body:
JSON.stringify({
from_name: mailModel.value.fromName,
to_name: mailModel.value.toName,
to_mail: mailModel.value.toMail,
subject: mailModel.value.subject,
is_html: mailModel.value.isHtml,
content: mailModel.value.content,
})
})
mailModel.value = {
fromName: "",
toName: "",
toMail: "",
subject: "",
isHtml: false,
content: "",
}
} catch (error) {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
router.push('/');
}
}
const requestAccess = async () => {
try {
await api.fetch(`/api/requset_send_mail_access`,
{
method: 'POST',
body: JSON.stringify({})
}
)
message.success(t("success"))
} catch (error) {
message.error(error.message || "error");
}
}
</script>
<template>
<div class="center" v-if="settings.address">
<n-card>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" show-icon>
{{ t('requestAccessTip') }}
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
</n-alert>
<br />
</div>
<div v-else>
<n-alert type="info" show-icon>
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<div class="right">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</div>
<div class="left">
<n-form :model="mailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="mailModel.fromName" />
<n-input :value="settings.address" disabled />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="mailModel.toName" />
<n-input v-model:value="mailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<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">
{{ 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-input v-else type="textarea" v-model:value="mailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</div>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
text-align: left;
margin-right: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.left {
text-align: left;
place-items: left;
justify-content: left;
}
.right {
text-align: right;
place-items: right;
justify-content: right;
}
</style>

View File

@@ -13,7 +13,7 @@ import topLevelAwait from "vite-plugin-top-level-await";
// https://vitejs.dev/config/
export default defineConfig({
build: {
outDir: '../dist',
outDir: './dist',
},
plugins: [
vue(),

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"start": "wrangler dev"
"start": "wrangler dev",
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
},
"devDependencies": {
"wrangler": "^3.48.0"

View File

@@ -138,10 +138,54 @@ api.get('/admin/mails_unknow', async (c) => {
})
});
api.get('/admin/address_sender', async (c) => {
const { 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)
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM address_sender order by id desc limit ? offset ? `
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address_sender`
).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
})
api.post('/admin/address_sender', async (c) => {
let { address_id, balance, enabled } = await c.req.json();
if (!address_id) {
return c.text("Invalid address_id", 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text("Failed to update address sender", 500)
}
return c.json({
success: success
})
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
const { count: mailCountV1 } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails`
).first();
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails`
).first();
const { count: addressCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address`
).first();
@@ -149,7 +193,7 @@ api.get('/admin/statistics', async (c) => {
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
return c.json({
mailCount: mailCount,
mailCount: (mailCountV1 || 0) + (mailCount || 0),
userCount: addressCount,
activeUserCount7days: activeUserCount7days
})

View File

@@ -8,18 +8,8 @@ async function email(message, env, ctx) {
return;
}
if (!env.PREFIX || (message.to && message.to.startsWith(env.PREFIX))) {
const reader = message.raw.getReader();
const decoder = new TextDecoder("utf-8");
let rawEmail = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
rawEmail += decoder.decode(value);
}
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 (?, ?, ?, ?)`

View File

@@ -84,10 +84,15 @@ api.get('/api/settings', async (c) => {
const { count: mailCountV1 } = await c.env.DB.prepare(
`SELECT count(*) as count FROM mails where address = ?`
).bind(address).first();
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({
auto_reply: auto_reply,
address: address,
has_v1_mails: mailCountV1 > 0
has_v1_mails: mailCountV1 && mailCountV1 > 0,
send_balance: balance || 0,
});
})
@@ -131,11 +136,20 @@ api.get('/open_api/settings', async (c) => {
})
api.get('/api/new_address', async (c) => {
let { name, domain } = await c.req.query();
let { name, domain } = c.req.query();
// if no name, generate random name
if (!name) {
name = Math.random().toString(36).substring(2, 15);
}
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
if (!domain || !c.env.DOMAINS.includes(domain)) {
domain = c.env.DOMAINS[Math.floor(Math.random() * c.env.DOMAINS.length)];

142
worker/src/send_mail_api.js Normal file
View File

@@ -0,0 +1,142 @@
import { Hono } from 'hono'
const api = new Hono()
api.post('/api/requset_send_mail_access', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.text("No address", 400)
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address_sender (address, enabled) VALUES (?, 0)`
).bind(address).run();
if (!success) {
return c.text("Failed to request send mail access", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Already requested", 400)
}
return c.text("Failed to request send mail access", 500)
}
return c.json({ status: "ok" })
})
api.post('/api/send_mail', async (c) => {
const { address } = c.get("jwtPayload")
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first("balance");
if (!balance || balance <= 0) {
return c.text("No balance", 400);
}
let {
from_name, to_mail, to_name,
subject, content, is_html
} = await c.req.json();
if (!address) {
return c.text("No address", 400)
}
if (!to_mail) {
return c.text("Invalid to mail", 400)
}
from_name = from_name || address;
to_name = to_name || to_mail;
if (!subject) {
return c.text("Invalid subject", 400)
}
if (!content) {
return c.text("Invalid content", 400)
}
const body = JSON.stringify({
"personalizations": [
{
"to": [{
"email": to_mail,
"name": to_name,
}]
}
],
"from": {
"email": address,
"name": from_name,
},
"subject": subject,
"content": [{
"type": is_html ? "text/html" : "text/plain",
"value": content,
}],
});
let send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
"method": "POST",
"headers": {
"content-type": "application/json",
},
"body": body,
});
const resp = await fetch(send_request);
const respText = await resp.text();
console.log(resp.status + " " + resp.statusText + ": " + respText);
if (resp.status >= 300) {
return c.text("Failed to send mail", 500)
}
// update balance
try {
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1
where address = ?`
).bind(address).run();
if (!success) {
console.warn(`Failed to update balance for ${address}`);
}
} catch (e) {
console.warn(`Failed to update balance for ${address}`);
}
// save to sendbox
try {
const { success: success2 } = await c.env.DB.prepare(
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
).bind(address, body).run();
if (!success2) {
console.warn(`Failed to save to sendbox for ${address}`);
}
} catch (e) {
console.warn(`Failed to save to sendbox for ${address}`);
}
return c.json({ status: "ok" });
})
api.get('/api/sendbox', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { 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)
}
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
})
})
export { api }

View File

@@ -5,6 +5,7 @@ import { jwt } from 'hono/jwt'
import { api } from './router';
import { api as adminApi } from './admin_api';
import { api as apiV1 } from './api_v1';
import { api as apiSendMail } from './send_mail_api'
import { email } from './email';
const app = new Hono()
@@ -40,6 +41,7 @@ app.use('/admin/*', async (c, next) => {
app.route('/', api)
app.route('/', adminApi)
app.route('/', apiV1)
app.route('/', apiSendMail)
app.all('/*', async c => c.text("Not Found", 404))

View File

@@ -2,6 +2,10 @@ name = "cloudflare_temp_email"
main = "src/worker.js"
compatibility_date = "2023-12-01"
node_compat = true
# if you want use custom_domain, you need to add routes
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
[vars]
PREFIX = "tmp"