feat: telegram bot TelegramSettings && webhook (#244)

* feat: telegram bot TelegramSettings

* feat: webhook
This commit is contained in:
Dream Hunter
2024-05-18 14:02:18 +08:00
committed by GitHub
parent 53a06fc9d6
commit ca00a877ad
32 changed files with 777 additions and 112 deletions

View File

@@ -1,5 +1,11 @@
# CHANGE LOG
## main branch
- `telegram bot` 白名单配置
- `ENABLE_WEBHOOK` 添加 webhook
- admin 页面使用双层 tab
## v0.4.2
- 修复 smtp imap proxy sever 的一些 bug

View File

@@ -62,6 +62,7 @@
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
## Reference

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.4.2",
"version": "0.4.3",
"private": true,
"type": "module",
"scripts": {

View File

@@ -69,6 +69,7 @@ const getOpenSettings = async (message) => {
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;

View File

@@ -18,6 +18,7 @@ import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
@@ -42,12 +43,14 @@ const { t } = useI18n({
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user: 'User',
user_management: 'User Management',
user_settings: 'User Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
telegram: 'Telegram Bot',
webhook: 'Webhook',
statistics: 'Statistics',
maintenance: 'Maintenance',
appearance: 'Appearance',
@@ -61,12 +64,14 @@ const { t } = useI18n({
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user: '用户',
user_management: '用户管理',
user_settings: '用户设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
telegram: '电报机器人',
webhook: 'Webhook',
statistics: '统计',
maintenance: '维护',
appearance: '外观',
@@ -98,28 +103,43 @@ onMounted(async () => {
</n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')">
<Account />
<n-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhook')">
<Webhook />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
<n-tabs type="bar" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />

View File

@@ -10,6 +10,7 @@ import AutoReply from './index/AutoReply.vue';
import SendBox from './index/SendBox.vue';
import SendMail from './index/SendMail.vue';
import AccountSettings from './index/AccountSettings.vue';
import WenHook from './index/Webhook.vue';
import About from './common/About.vue';
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
@@ -65,6 +66,9 @@ const deleteMail = async (curMailId) => {
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
<WenHook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
<About />
</n-tab-pane>

View File

@@ -261,7 +261,7 @@ onMounted(async () => {
</script>
<template>
<div>
<div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("addressCredential") }}</div>

View File

@@ -58,7 +58,7 @@ onMounted(async () => {
</script>
<template>
<div>
<div style="margin-top: 10px;">
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />

View File

@@ -24,7 +24,7 @@ onMounted(async () => {
</script>
<template>
<div v-if="adminAuth">
<div v-if="adminAuth" style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
</div>
</template>

View File

@@ -1,10 +1,14 @@
<script setup>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
@@ -14,11 +18,19 @@ const { t } = useI18n({
init: 'Init',
successTip: 'Success',
status: 'Check Status',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
enable: 'Enable',
telegramAllowList: 'Telegram Allow List',
save: 'Save',
},
zh: {
init: '初始化',
successTip: '成功',
status: '查看状态',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID)',
enable: '启用',
telegramAllowList: 'Telegram 白名单',
save: '保存',
}
}
});
@@ -27,35 +39,86 @@ const status = ref({
fetched: false,
})
const fetchData = async () => {
const fetchStatus = async () => {
try {
const res = await api.fetch(`/admin/telegram/status`)
Object.assign(status.value, res)
status.value.fetched = true
} catch (error) {
message.error(error.message || "error");
message.error((error as Error).message || "error");
}
}
const save = async () => {
const init = async () => {
try {
await api.fetch(`/admin/telegram/init`, {
method: 'POST',
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
message.error((error as Error).message || "error");
}
}
class TelegramSettings {
enableAllowList: boolean;
allowList: string[];
constructor(enableAllowList: boolean, allowList: string[]) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
}
}
const settings = ref(new TelegramSettings(false, []))
const getSettings = async () => {
try {
const res = await api.fetch(`/admin/telegram/settings`)
Object.assign(settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/telegram/settings`, {
method: 'POST',
body: JSON.stringify(settings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await getSettings();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-button @click="save" type="primary" block>
<n-card>
<n-form-item-row :label="t('enableTelegramAllowList')">
<n-input-group>
<n-checkbox v-model:checked="settings.enableAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
:placeholder="t('telegramAllowList')" />
</n-input-group>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-button @click="init" type="primary" block>
{{ t('init') }}
</n-button>
<n-button @click="fetchData" secondary block>
<n-button @click="fetchStatus" secondary block>
{{ t('status') }}
</n-button>
<pre v-if="status.fetched">{{ JSON.stringify(status, null, 2) }}</pre>

View File

@@ -223,7 +223,7 @@ onMounted(async () => {
</script>
<template>
<div>
<div style="margin-top: 10px;">
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
<n-form>
<n-form-item-row :label="t('email')" required>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
successTip: 'Success',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
save: 'Save',
},
zh: {
successTip: '成功',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址)',
save: '保存',
}
}
});
class WebhookSettings {
allowList: string[];
constructor(allowList: string[]) {
this.allowList = allowList;
}
}
const webhookSettings = ref(new WebhookSettings([]))
const getSettings = async () => {
try {
const res = await api.fetch(`/admin/webhook/settings`)
Object.assign(webhookSettings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await getSettings();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
}
}
});
class WebhookSettings {
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({}, null, 2)
body: string = JSON.stringify({}, null, 2)
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
const fetchData = async () => {
try {
const res = await api.fetch(`/api/webhook/settings`)
Object.assign(webhookSettings.value, res)
enableWebhook.value = true
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const testSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/test`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center" v-if="settings.address">
<n-card v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>
<n-form-item-row label="METHOD">
<n-select v-model:value="webhookSettings.method" tag :options='[
{ label: "POST", value: "POST" }
]' />
</n-form-item-row>
<n-form-item-row label="HEADERS">
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-form-item-row label="BODY">
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="testSettings" secondary block strong>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": []
},
}

View File

@@ -85,6 +85,8 @@ ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Allow webhook
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0

View File

@@ -53,6 +53,8 @@ ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
# 允许自动回复邮件
ENABLE_AUTO_REPLY = false
# 是否启用 webhook
# ENABLE_WEBHOOK = true
# 前端界面页脚文本
# COPYRIGHT = "Dream Hunter"
# 默认发送邮件余额,如果不设置,将为 0

View File

@@ -7,7 +7,7 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify",
"start": "wrangler dev",
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",

View File

@@ -5,6 +5,7 @@ import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
const api = new Hono()
@@ -178,17 +179,17 @@ api.get('/admin/sendbox', async (c) => {
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM raw_mails`
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`
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
const { count: activeUserCount7days } = await c.env.DB.prepare(`
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
const { count: activeUserCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first();
const { count: sendMailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM sendbox`
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first();
return c.json({
mailCount: mailCount,
@@ -242,5 +243,7 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
export { api }

View File

@@ -0,0 +1,22 @@
import { Context } from "hono";
import { Bindings } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings } from "../models/models";
// @ts-ignore
import { getBooleanValue } from "../utils";
async function getWebhookSettings(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const settings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
return c.json(settings || new AdminWebhookSettings([]));
}
async function saveWebhookSettings(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const settings = await c.req.json<AdminWebhookSettings>();
await c.env.KV.put(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
}
export default {
getWebhookSettings,
saveWebhookSettings,
}

View File

@@ -1,9 +1,11 @@
import { Hono } from 'hono'
// @ts-ignore
import { getDomains, getPasswords, getBooleanValue } from './utils';
import { CONSTANTS } from './constants';
import { Bindings } from './types';
const api = new Hono()
const api = new Hono<{ Bindings: Bindings }>
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
@@ -24,6 +26,7 @@ api.get('/open_api/settings', async (c) => {
"enableIndexAbout": getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"version": CONSTANTS.VERSION,
});
})

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v0.4.2',
VERSION: 'v0.4.3',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
@@ -8,5 +8,8 @@ export const CONSTANTS = {
USER_SETTINGS_KEY: 'user_settings',
// KV
TG_KV_PREFIX: "temp-mail-telegram"
TG_KV_PREFIX: "temp-mail-telegram",
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
}

View File

@@ -1,44 +1,19 @@
import { createMimeMessage } from "mimetext";
import { getBooleanValue } from "./utils";
import { sendMailToTelegram } from "./telegram_api";
// @ts-ignore
import { getBooleanValue } from "../utils";
import { Bindings } from "../types";
async function email(message, env, ctx) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
const rawEmail = await new Response(message.raw).text();
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// send email to telegram
try {
await sendMailToTelegram({
env: env,
}, message.to, rawEmail);
} catch (error) {
console.log("send mail to telegram error", error);
}
// auto reply email
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) {
if (getBooleanValue(env.ENABLE_AUTO_REPLY) && message_id) {
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first();
).bind(message.to).first<Record<string, string>>();
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
const msg = createMimeMessage();
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
msg.setHeader("In-Reply-To", message_id);
msg.setSender({
name: results.name || results.address,
addr: results.address
@@ -55,6 +30,7 @@ async function email(message, env, ctx) {
message.from,
msg.asRaw()
);
// @ts-ignore
await message.reply(replyMessage);
}
} catch (error) {
@@ -62,5 +38,3 @@ async function email(message, env, ctx) {
}
}
}
export { email }

51
worker/src/email/index.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Context } from "hono";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings } from "../types";
import { auto_reply } from "./auto_reply";
import { trigerWebhook } from "../mails_api/webhook_settings";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// send email to telegram
try {
await sendMailToTelegram(
{ env: env } as Context<{ Bindings: Bindings }>,
message.to, rawEmail);
} catch (error) {
console.log("send mail to telegram error", error);
}
// send webhook
try {
await trigerWebhook(
{ env: env } as Context<{ Bindings: Bindings }>,
message.to, rawEmail
);
} catch (error) {
console.log("send webhook error", error);
}
// auto reply email
await auto_reply(message, env);
}
export { email }

View File

@@ -4,11 +4,15 @@ import { getBooleanValue, getJsonSetting, checkCfTurnstile } from '../utils';
import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings';
const api = new Hono()
api.get('/api/auto_reply', auto_reply.getAutoReply)
api.post('/api/auto_reply', auto_reply.saveAutoReply)
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
api.get('/api/mails', async (c) => {
const { address } = c.get("jwtPayload")

View File

@@ -0,0 +1,135 @@
import { Context } from "hono";
import { Bindings, Variables } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookMail } from "../models/models";
// @ts-ignore
import { getBooleanValue } from "../utils";
import PostalMime from 'postal-mime';
class WebhookSettings {
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({
"Content-Type": "application/json"
}, null, 2)
body: string = JSON.stringify({
"from": "${from}",
"to": "${to}",
"headers": "${headers}",
"subject": "${subject}",
"raw": "${raw}",
"parsedText": "${parsedText}",
}, null, 2)
}
async function getWebhookSettings(
c: Context<{ Bindings: Bindings, Variables: Variables }>
): Promise<Response> {
if (!c.env.KV) {
return c.text("KV is not available", 400);
}
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return c.text("Webhook is disabled", 403);
}
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
}
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
) || new WebhookSettings();
return c.json(settings);
}
async function saveWebhookSettings(
c: Context<{ Bindings: Bindings, Variables: Variables }>
): Promise<Response> {
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
}
const settings = await c.req.json<WebhookSettings>();
await c.env.KV.put(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`,
JSON.stringify(settings));
return c.json({ success: true })
}
async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
// send webhook
let body = settings.body;
for (const key of Object.keys(formatMap)) {
body = body.replace(new RegExp(`\\$\\{${key}\\}`, "g"), formatMap[key as keyof WebhookMail]);
}
const response = await fetch(settings.url, {
method: settings.method,
headers: JSON.parse(settings.headers),
body: body
});
if (!response.ok) {
console.log("send webhook error", response.status, response.statusText);
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
}
return { success: true }
}
export async function trigerWebhook(
c: Context<{ Bindings: Bindings }>,
address: string,
raw_mail: string
): Promise<void> {
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return
}
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
return;
}
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
);
if (!settings) {
return;
}
const parsedEmail = await PostalMime.parse(raw_mail);
const res = await sendWebhook(settings, {
from: parsedEmail.from.address || "",
to: address,
headers: JSON.stringify(parsedEmail.headers, null, 2),
subject: parsedEmail.subject || "",
raw: raw_mail,
parsedText: parsedEmail.text || parsedEmail.html || ""
});
if (!res.success) {
console.log(res.message);
}
}
async function testWebhookSettings(
c: Context<{ Bindings: Bindings, Variables: Variables }>
): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
const res = await sendWebhook(settings, {
from: "from@test.com",
to: "to@test.com",
headers: "headers",
subject: "test",
raw: "test",
parsedText: "test"
});
if (!res.success) {
return c.text(res.message || "send webhook error", 400);
}
return c.json({ success: true });
}
export default {
getWebhookSettings,
saveWebhookSettings,
testWebhookSettings,
}

View File

@@ -0,0 +1,16 @@
export class AdminWebhookSettings {
allowList: string[];
constructor(allowList: string[]) {
this.allowList = allowList;
}
}
export type WebhookMail = {
from: string;
to: string;
headers: string;
subject: string;
raw: string;
parsedText: string;
}

View File

@@ -1,12 +1,35 @@
import { Hono, Context } from 'hono'
import { Hono } from 'hono'
import { ServerResponse } from 'node:http'
import { Writable } from 'node:stream'
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
export const api = new Hono()
import { Bindings } from '../types'
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
import settings from './settings'
export const api = new Hono<{ Bindings: Bindings }>();
export { sendMailToTelegram }
api.post("/telegram/webhook", async (c: Context) => {
api.use("/telegram/*", async (c, next) => {
if (!c.env.TELEGRAM_BOT_TOKEN) {
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
}
if (!c.env.KV) {
return c.text("KV is required", 400);
}
return await next();
});
api.use("/admin/telegram/*", async (c, next) => {
if (!c.env.TELEGRAM_BOT_TOKEN) {
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
}
if (!c.env.KV) {
return c.text("KV is required", 400);
}
return await next();
});
api.post("/telegram/webhook", async (c) => {
const token = c.env.TELEGRAM_BOT_TOKEN;
const bot = newTelegramBot(c, token);
let body = null;
@@ -21,10 +44,7 @@ api.post("/telegram/webhook", async (c: Context) => {
return c.body(body);
});
api.post("/admin/telegram/init", async (c: Context) => {
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
}
api.post("/admin/telegram/init", async (c) => {
const domain = new URL(c.req.url).host;
const token = c.env.TELEGRAM_BOT_TOKEN;
const webhookUrl = `https://${domain}/telegram/webhook`;
@@ -37,13 +57,13 @@ api.post("/admin/telegram/init", async (c: Context) => {
});
});
api.get("/admin/telegram/status", async (c: Context) => {
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
}
api.get("/admin/telegram/status", async (c) => {
const token = c.env.TELEGRAM_BOT_TOKEN;
const bot = newTelegramBot(c, token);
const info = await bot.telegram.getWebhookInfo()
const commands = await bot.telegram.getMyCommands()
return c.json({ info, commands });
});
api.get("/admin/telegram/settings", settings.getTelegramSettings);
api.post("/admin/telegram/settings", settings.saveTelegramSettings);

View File

@@ -0,0 +1,30 @@
import { Context } from "hono";
import { Bindings } from "../types";
import { CONSTANTS } from "../constants";
export class TelegramSettings {
enableAllowList: boolean;
allowList: string[];
constructor(enableAllowList: boolean, allowList: string[]) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
}
}
async function getTelegramSettings(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
return c.json(settings || new TelegramSettings(false, []));
}
async function saveTelegramSettings(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const settings = await c.req.json<TelegramSettings>();
await c.env.KV.put(CONSTANTS.TG_KV_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
}
export default {
getTelegramSettings,
saveTelegramSettings,
}

View File

@@ -10,6 +10,8 @@ import { CONSTANTS } from "../constants";
import { getIntValue, getDomains, getStringValue } from '../utils';
// @ts-ignore
import { newAddress } from '../common'
import { Bindings } from "../types";
import { TelegramSettings } from "./settings";
const COMMANDS = [
{
@@ -34,12 +36,30 @@ const COMMANDS = [
},
]
export function newTelegramBot(c: Context, token: string): Telegraf {
export function newTelegramBot(c: Context<{ Bindings: Bindings }>, token: string): Telegraf {
const bot = new Telegraf(token);
bot.command("start", async (ctx: TgContext) => {
bot.use(async (ctx, next) => {
if (ctx.chat?.type !== "private") {
return await ctx.reply("请在私聊中使用");
}
const userId = ctx?.message?.from?.id || ctx.callbackQuery?.message?.chat?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
}
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
if (settings?.enableAllowList && settings?.enableAllowList
&& !settings.allowList.includes(userId.toString())
) {
return await ctx.reply("您没有权限使用此机器人");
}
await next();
})
bot.command("start", async (ctx: TgContext) => {
const prefix = getStringValue(c.env.PREFIX)
const domains = getDomains(c);
return await ctx.reply(
@@ -53,9 +73,6 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
);
});
bot.command("new", async (ctx: TgContext) => {
if (ctx.chat?.type !== "private") {
return await ctx.reply("请在私聊中使用");
}
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
@@ -72,14 +89,14 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
// @ts-ignore
const address = ctx?.message?.text.slice("/new".length).trim() || Math.random().toString(36).substring(2, 15);
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
return await ctx.reply("绑定地址数量已达上限");
}
const res = await newAddress(c, name, domain, true);
// for mail push to telegram
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId);
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n`
+ `凭证: ${res.jwt}\n`
@@ -90,9 +107,6 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
});
bot.command("bind", async (ctx: TgContext) => {
if (ctx.chat?.type !== "private") {
return await ctx.reply("请在私聊中使用");
}
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
@@ -107,13 +121,13 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
if (!address) {
return await ctx.reply("凭证无效");
}
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
return await ctx.reply("绑定地址数量已达上限");
}
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
// for mail push to telegram
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId);
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
return await ctx.reply(`绑定成功:\n`
+ `地址: ${address}`
);
@@ -123,16 +137,13 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
}
});
bot.command("address", async (ctx: TgContext) => {
if (ctx.chat?.type !== "private") {
return await ctx.reply("请在私聊中使用");
}
bot.command("address", async (ctx) => {
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
}
try {
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const addressList = [];
for (const jwt of jwtList) {
try {
@@ -156,7 +167,7 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
if (!userId) {
return await ctx.reply("无法获取用户信息");
}
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const addressList = [];
for (const jwt of jwtList) {
try {
@@ -178,7 +189,7 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
+ ` order by id desc limit 1 offset ?`
).bind(
queryAddress, mailIndex
).first("raw");
).first<string>("raw");
const { mail } = await parseMail(raw);
if (edit) {
return await ctx.editMessageText(mail || "无邮件",
@@ -234,7 +245,7 @@ export async function initTelegramBotCommands(bot: Telegraf) {
await bot.telegram.setMyCommands(COMMANDS);
}
const parseMail = async (raw_mail: string) => {
const parseMail = async (raw_mail: string | undefined | null) => {
if (!raw_mail) {
return {};
}
@@ -257,7 +268,7 @@ const parseMail = async (raw_mail: string) => {
}
export async function sendMailToTelegram(c: Context, address: string, raw_mail: string) {
export async function sendMailToTelegram(c: Context<{ Bindings: Bindings }>, address: string, raw_mail: string) {
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
return;
}

42
worker/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
export type Bindings = {
// bindings
DB: D1Database
KV: KVNamespace
RATE_LIMITER: any
// config
PREFIX: string | undefined
JWT_SECRET: string
BLACK_LIST: string | undefined
ENABLE_AUTO_REPLY: string | boolean | undefined
ENABLE_WEBHOOK: string | boolean | undefined
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined
ADMIN_CONTACT: string | undefined
COPYRIGHT: string | undefined
// cf turnstile
CF_TURNSTILE_SITE_KEY: string | undefined
// telegram config
TELEGRAM_BOT_TOKEN: string
TG_MAX_ADDRESS: number | undefined
}
type JwtPayload = {
address: string
address_id: number
}
type UserPayload = {
user_email: string
user_id: number
exp: number
iat: number
}
type Variables = {
userPayload: UserPayload,
jwtPayload: JwtPayload
}

View File

@@ -3,19 +3,28 @@ import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt'
import { Jwt } from 'hono/utils/jwt'
// @ts-ignore
import { api as commonApi } from './commom_api';
// @ts-ignore
import { api as mailsApi } from './mails_api'
// @ts-ignore
import { api as userApi } from './user_api';
// @ts-ignore
import { api as adminApi } from './admin_api';
// @ts-ignore
import { api as apiV1 } from './deprecated';
// @ts-ignore
import { api as apiSendMail } from './mails_api/send_mail_api'
import { api as telegramApi } from './telegram_api'
import { email } from './email';
// @ts-ignore
import { scheduled } from './scheduled';
import { getAdminPasswords, getPasswords } from './utils';
// @ts-ignore
import { getAdminPasswords, getPasswords, getBooleanValue } from './utils';
import { Bindings } from './types';
const app = new Hono()
const app = new Hono<{ Bindings: Bindings }>()
//cors
app.use('/*', cors());
// rate limit
@@ -37,6 +46,17 @@ app.use('/*', async (c, next) => {
}
}
}
if (
c.req.path.startsWith("/api/webhook")
|| c.req.path.startsWith("/admin/webhook")
) {
if (!c.env.KV) {
return c.text("KV is not available", 400);
}
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return c.text("Webhook is disabled", 403);
}
}
await next()
});
// api auth

View File

@@ -28,6 +28,8 @@ ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Allow webhook
# ENABLE_WEBHOOK = true
# Footer text
# COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0