mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-12 16:02:10 +08:00
feat: init send mail (#113)
* feat: init send mail * feat: init send mail
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# CHANGE LOG
|
# CHANGE LOG
|
||||||
|
|
||||||
|
## 2024-04-12 v0.0.2
|
||||||
|
|
||||||
|
- support send email
|
||||||
|
|
||||||
|
DB changes:
|
||||||
|
|
||||||
|
- `db/2024-04-12-patch.sql`
|
||||||
|
|
||||||
## 2024-04-10 v0.0.1
|
## 2024-04-10 v0.0.1
|
||||||
|
|
||||||
Breaking changes:
|
Breaking changes:
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -44,6 +44,7 @@
|
|||||||
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
|
- [Cloudflare Workers 后端](#cloudflare-workers-后端-1)
|
||||||
- [Cloudflare Email Routing](#cloudflare-email-routing)
|
- [Cloudflare Email Routing](#cloudflare-email-routing)
|
||||||
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
|
- [Cloudflare Pages 前端](#cloudflare-pages-前端)
|
||||||
|
- [配置发送邮件](#配置发送邮件)
|
||||||
- [参考资料](#参考资料)
|
- [参考资料](#参考资料)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
- [x] 增加自动回复功能
|
- [x] 增加自动回复功能
|
||||||
- [x] 增加查看附件功能
|
- [x] 增加查看附件功能
|
||||||
- [x] 使用 rust wasm 解析邮件
|
- [x] 使用 rust wasm 解析邮件
|
||||||
|
- [x] 支持发送邮件
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -201,6 +203,19 @@ pnpm run deploy
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 配置发送邮件
|
||||||
|
|
||||||
|
找到域名 `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/
|
- https://developers.cloudflare.com/d1/
|
||||||
|
|||||||
14
README_EN.md
14
README_EN.md
@@ -23,6 +23,7 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo
|
|||||||
- [x] Add auto reply feature
|
- [x] Add auto reply feature
|
||||||
- [x] Add attachment viewing function
|
- [x] Add attachment viewing function
|
||||||
- [x] use rust wasm to parse email
|
- [x] use rust wasm to parse email
|
||||||
|
- [x] support send email
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -92,3 +93,16 @@ pnpm run deploy
|
|||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 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
14
db/2024-04-12-patch.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS mails (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS raw_mails (
|
CREATE TABLE IF NOT EXISTS raw_mails (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
message_id TEXT,
|
message_id TEXT,
|
||||||
@@ -17,6 +19,8 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS address (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT UNIQUE,
|
name TEXT UNIQUE,
|
||||||
@@ -24,6 +28,8 @@ CREATE TABLE IF NOT EXISTS address (
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
source_prefix TEXT,
|
source_prefix TEXT,
|
||||||
@@ -35,6 +41,8 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
@@ -43,3 +51,22 @@ CREATE TABLE IF NOT EXISTS attachments (
|
|||||||
data TEXT,
|
data TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const getSettings = async () => {
|
|||||||
address: res["address"],
|
address: res["address"],
|
||||||
auto_reply: res["auto_reply"],
|
auto_reply: res["auto_reply"],
|
||||||
has_v1_mails: res["has_v1_mails"],
|
has_v1_mails: res["has_v1_mails"],
|
||||||
|
send_balance: res["send_balance"],
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
settings.value.fetched = true;
|
settings.value.fetched = true;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const i18n = createI18n({
|
|||||||
'en': {
|
'en': {
|
||||||
messages: {}
|
messages: {}
|
||||||
},
|
},
|
||||||
'zhCN': {
|
'zh': {
|
||||||
messages: {}
|
messages: {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Index from '../views/Index.vue'
|
import Index from '../views/Index.vue'
|
||||||
import Settings from '../views/Settings.vue'
|
import Settings from '../views/Settings.vue'
|
||||||
|
import SendMail from '../views/send/SendMail.vue'
|
||||||
import Admin from '../views/Admin.vue'
|
import Admin from '../views/Admin.vue'
|
||||||
|
import SendBox from '../views/send/SendBox.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -14,6 +16,14 @@ const router = createRouter({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
component: Settings
|
component: Settings
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/send',
|
||||||
|
component: SendMail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sendbox',
|
||||||
|
component: SendBox
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: Admin
|
component: Admin
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const useGlobalState = createGlobalState(
|
|||||||
const settings = ref({
|
const settings = ref({
|
||||||
fetched: false,
|
fetched: false,
|
||||||
has_v1_mails: false,
|
has_v1_mails: false,
|
||||||
|
send_balance: 0,
|
||||||
address: '',
|
address: '',
|
||||||
auto_reply: {
|
auto_reply: {
|
||||||
subject: '',
|
subject: '',
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { User, UserCheck, MailBulk } from '@vicons/fa'
|
|||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
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 { localeCache, adminAuth, showAdminAuth } = useGlobalState()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -49,6 +50,7 @@ const { t } = useI18n({
|
|||||||
account: 'Account',
|
account: 'Account',
|
||||||
unknow: 'Unknow',
|
unknow: 'Unknow',
|
||||||
addressQueryTip: 'Leave blank to query all addresses',
|
addressQueryTip: 'Leave blank to query all addresses',
|
||||||
|
senderAccess: 'Sender Access Control',
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
title: '临时邮件 Admin',
|
title: '临时邮件 Admin',
|
||||||
@@ -72,6 +74,7 @@ const { t } = useI18n({
|
|||||||
account: '账号',
|
account: '账号',
|
||||||
unknow: '未知',
|
unknow: '未知',
|
||||||
addressQueryTip: '留空查询所有地址',
|
addressQueryTip: '留空查询所有地址',
|
||||||
|
senderAccess: '发件权限控制',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -396,6 +399,9 @@ const fetchMailUnknowData = async () => {
|
|||||||
</n-list-item>
|
</n-list-item>
|
||||||
</n-list>
|
</n-list>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||||
|
<SenderAccess />
|
||||||
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ref, h, computed, onMounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useIsMobile } from '../utils/composables'
|
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 { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
|
||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
@@ -73,6 +73,8 @@ const { t } = useI18n({
|
|||||||
home: 'Home',
|
home: 'Home',
|
||||||
menu: 'Menu',
|
menu: 'Menu',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
|
sendbox: 'Send Box',
|
||||||
|
sendMail: 'Send Mail',
|
||||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||||
getNewEmail: 'Get New Email',
|
getNewEmail: 'Get New Email',
|
||||||
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
|
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
|
||||||
@@ -104,6 +106,8 @@ const { t } = useI18n({
|
|||||||
home: '主页',
|
home: '主页',
|
||||||
menu: '菜单',
|
menu: '菜单',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
|
sendbox: '发件箱',
|
||||||
|
sendMail: '发送邮件',
|
||||||
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
|
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
|
||||||
getNewEmail: '获取新邮箱',
|
getNewEmail: '获取新邮箱',
|
||||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
|
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
|
||||||
@@ -176,6 +180,19 @@ const menuOptions = computed(() => [
|
|||||||
show: showUserMenu.value,
|
show: showUserMenu.value,
|
||||||
key: "user",
|
key: "user",
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: () => h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
ghost: true,
|
||||||
|
size: "small",
|
||||||
|
onClick: () => router.push('/sendbox')
|
||||||
|
},
|
||||||
|
{ default: () => t('sendbox') }
|
||||||
|
),
|
||||||
|
key: "sendbox"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: () => h(
|
label: () => h(
|
||||||
NButton,
|
NButton,
|
||||||
@@ -358,6 +375,7 @@ const deleteAccount = async () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await api.getOpenSettings(message);
|
await api.getOpenSettings(message);
|
||||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
||||||
|
await api.getSettings();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -386,6 +404,10 @@ onMounted(async () => {
|
|||||||
<n-alert type="info" show-icon>
|
<n-alert type="info" show-icon>
|
||||||
<span>
|
<span>
|
||||||
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
|
<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-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
|
||||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -1,298 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch, onMounted, ref } from "vue";
|
import MailBox from './MailBox.vue';
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
const { settings } = useGlobalState()
|
||||||
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();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<n-layout v-if="settings.address">
|
<MailBox 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>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
294
frontend/src/views/MailBox.vue
Normal file
294
frontend/src/views/MailBox.vue
Normal 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>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import Header from './Header.vue'
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ const { t } = useI18n({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getSettings = async () => {
|
const getSettings = async () => {
|
||||||
await api.getSettings()
|
|
||||||
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
|
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
|
||||||
enableAutoReply.value = settings.value.auto_reply.enabled || false
|
enableAutoReply.value = settings.value.auto_reply.enabled || false
|
||||||
name.value = settings.value.auto_reply.name || ""
|
name.value = settings.value.auto_reply.name || ""
|
||||||
|
|||||||
189
frontend/src/views/admin/SenderAccess.vue
Normal file
189
frontend/src/views/admin/SenderAccess.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<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;
|
||||||
|
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>
|
||||||
156
frontend/src/views/send/SendBox.vue
Normal file
156
frontend/src/views/send/SendBox.vue
Normal 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>
|
||||||
189
frontend/src/views/send/SendMail.vue
Normal file
189
frontend/src/views/send/SendMail.vue
Normal 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>
|
||||||
@@ -138,6 +138,47 @@ 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) => {
|
api.get('/admin/statistics', async (c) => {
|
||||||
const { count: mailCountV1 } = await c.env.DB.prepare(`
|
const { count: mailCountV1 } = await c.env.DB.prepare(`
|
||||||
SELECT count(*) as count FROM mails`
|
SELECT count(*) as count FROM mails`
|
||||||
|
|||||||
@@ -84,10 +84,15 @@ api.get('/api/settings', async (c) => {
|
|||||||
const { count: mailCountV1 } = await c.env.DB.prepare(
|
const { count: mailCountV1 } = await c.env.DB.prepare(
|
||||||
`SELECT count(*) as count FROM mails where address = ?`
|
`SELECT count(*) as count FROM mails where address = ?`
|
||||||
).bind(address).first();
|
).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({
|
return c.json({
|
||||||
auto_reply: auto_reply,
|
auto_reply: auto_reply,
|
||||||
address: address,
|
address: address,
|
||||||
has_v1_mails: mailCountV1 > 0
|
has_v1_mails: mailCountV1 && mailCountV1 > 0,
|
||||||
|
send_balance: balance || 0,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
142
worker/src/send_mail_api.js
Normal file
142
worker/src/send_mail_api.js
Normal 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 }
|
||||||
@@ -5,6 +5,7 @@ import { jwt } from 'hono/jwt'
|
|||||||
import { api } from './router';
|
import { api } from './router';
|
||||||
import { api as adminApi } from './admin_api';
|
import { api as adminApi } from './admin_api';
|
||||||
import { api as apiV1 } from './api_v1';
|
import { api as apiV1 } from './api_v1';
|
||||||
|
import { api as apiSendMail } from './send_mail_api'
|
||||||
import { email } from './email';
|
import { email } from './email';
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
@@ -40,6 +41,7 @@ app.use('/admin/*', async (c, next) => {
|
|||||||
app.route('/', api)
|
app.route('/', api)
|
||||||
app.route('/', adminApi)
|
app.route('/', adminApi)
|
||||||
app.route('/', apiV1)
|
app.route('/', apiV1)
|
||||||
|
app.route('/', apiSendMail)
|
||||||
|
|
||||||
app.all('/*', async c => c.text("Not Found", 404))
|
app.all('/*', async c => c.text("Not Found", 404))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user