mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-07 15:21:33 +08:00
feat: add resend for send mail (#274)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
- UI lazy load
|
||||
- telegram bot 添加用户全局推送功能
|
||||
- 增加对 cloudflare verified 用户发送邮件
|
||||
- 增加使用 `resend` 发送邮件
|
||||
|
||||
## v0.4.4
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
>
|
||||
> [Mail Channels 免费电子邮件发送 API 将于2024年6月30日结束](https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers)
|
||||
>
|
||||
> 正在集成 Resend 发送邮件
|
||||
|
||||
## [查看部署文档](https://temp-mail-docs.awsl.uk)
|
||||
|
||||
|
||||
@@ -62,13 +62,21 @@ const refresh = async () => {
|
||||
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.contentType = data.content[0]?.type;
|
||||
item.content = data.content[0]?.value;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
if (data.version == "v2") {
|
||||
item.to_mail = data.to_name ? `${data.to_name} <${data.to_mail}>` : data.to_mail;
|
||||
item.subject = data.subject;
|
||||
item.is_html = data.is_html;
|
||||
item.content = data.content;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
item.to_mail = data?.personalizations?.map(
|
||||
(p) => p.to?.map((t) => t.email).join(',')
|
||||
).join(';');
|
||||
item.subject = data.subject;
|
||||
item.is_html = (data.content[0]?.type != 'text/plain');
|
||||
item.content = data.content[0]?.value;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
@@ -160,7 +168,7 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="curMail.contentType == 'text/plain'" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card class="mail-item" v-else>
|
||||
@@ -219,7 +227,7 @@ onMounted(async () => {
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="curMail.contentType == 'text/plain'" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
|
||||
# 配置发送邮件
|
||||
|
||||
## 使用 Cloudflare Workers 给已认证的邮箱发送邮件
|
||||
|
||||
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
|
||||
|
||||
## 使用 resend 发送邮件
|
||||
|
||||
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
|
||||
|
||||
`API KEYS` 页面创建 `api key`
|
||||
|
||||
使用 cli 或者直接添加到 `wrangler.toml` 的 `vars`,或者在 cloudflare worker 页面的变量中添加 `RESEND_TOKEN`
|
||||
|
||||
```bash
|
||||
wrangler secret put RESEND_TOKEN
|
||||
```
|
||||
|
||||
如果你有多个域名,对应不同的 `api key`,可以在 `wrangler.toml` 中添加多个 secret, 名称为 `RESEND_TOKEN_` + `<. 换成 _ 的 大写域名>`,例如
|
||||
|
||||
```bash
|
||||
wrangler secret put RESEND_TOKEN_XXX_COM
|
||||
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
|
||||
```
|
||||
|
||||
## 使用 mailchannels 发送邮件
|
||||
|
||||
::: warning
|
||||
[Mail Channels 免费电子邮件发送 API 将于2024年6月30日结束](https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers)
|
||||
:::
|
||||
|
||||
1. 找到域名 `DNS` 记录的 `TXT` 的 `SPF` 记录, 增加 `include:relay.mailchannels.net`
|
||||
|
||||
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`
|
||||
|
||||
19
worker/eslint.config.js
Normal file
19
worker/eslint.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
export default [
|
||||
{
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -5,18 +5,24 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"lint": "eslint src",
|
||||
"deploy": "wrangler deploy --minify",
|
||||
"start": "wrangler dev",
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"@eslint/js": "8.56.0",
|
||||
"eslint": "8.56.0",
|
||||
"globals": "^15.3.0",
|
||||
"typescript-eslint": "^7.10.0",
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.3.9",
|
||||
"mimetext": "^3.0.24",
|
||||
"postal-mime": "^2.2.5",
|
||||
"resend": "^3.2.0",
|
||||
"telegraf": "4.16.3"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
1390
worker/pnpm-lock.yaml
generated
1390
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ api.get('/admin/address', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
let { name, domain, enablePrefix } = await c.req.json();
|
||||
const { name, domain, enablePrefix } = await c.req.json();
|
||||
if (!name) {
|
||||
return c.text("Please provide a name", 400)
|
||||
}
|
||||
@@ -144,7 +144,9 @@ api.get('/admin/address_sender', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/admin/address_sender', async (c) => {
|
||||
/* eslint-disable prefer-const */
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
/* eslint-enable prefer-const */
|
||||
if (!address_id) {
|
||||
return c.text("Invalid address_id", 400)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue } from './utils';
|
||||
import { HonoCustomType } from './types';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
|
||||
export const newAddress = async (
|
||||
|
||||
@@ -90,6 +90,7 @@ api.post('/api/new_address', async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||
return c.text("New address is disabled", 403)
|
||||
}
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { name, domain, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context, Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { createMimeMessage } from 'mimetext';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getJsonSetting, getDomains, getIntValue } from '../utils';
|
||||
@@ -29,7 +30,7 @@ api.post('/api/requset_send_mail_access', async (c) => {
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
if (message && message.includes("UNIQUE")) {
|
||||
throw new Error("Address already requested")
|
||||
return c.text("Already requested", 400)
|
||||
}
|
||||
return c.text("Failed to request send mail access", 500)
|
||||
}
|
||||
@@ -42,14 +43,14 @@ export const sendMailToVerifyAddress = async (
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
) => {
|
||||
): Promise<void> => {
|
||||
const {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
const msg = createMimeMessage();
|
||||
msg.setSender({ name: from_name, addr: address });
|
||||
msg.setRecipient({ name: to_name, addr: to_mail });
|
||||
msg.setSender(from_name ? { name: from_name, addr: address } : address);
|
||||
msg.setRecipient(to_name ? { name: to_name, addr: to_mail } : to_mail);
|
||||
msg.setSubject(subject);
|
||||
msg.addMessage({
|
||||
contentType: is_html ? 'text/html' : 'text/plain',
|
||||
@@ -60,59 +61,49 @@ export const sendMailToVerifyAddress = async (
|
||||
await c.env.SEND_MAIL.send(message);
|
||||
}
|
||||
|
||||
export const sendMail = async (
|
||||
const sendMailByResend = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
) => {
|
||||
if (!address) {
|
||||
throw new Error("No address")
|
||||
}
|
||||
// check domain
|
||||
): Promise<void> => {
|
||||
const mailDomain = address.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
throw new Error("Invalid domain")
|
||||
const token = c.env[
|
||||
`RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}`
|
||||
] || c.env.RESEND_TOKEN;
|
||||
const resend = new Resend(token);
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: reqJson.from_name ? `${reqJson.from_name} <${address}>` : address,
|
||||
to: reqJson.to_name ? `${reqJson.to_name} <${reqJson.to_mail}>` : reqJson.to_mail,
|
||||
subject: reqJson.subject,
|
||||
...(reqJson.is_html ? {
|
||||
html: reqJson.content,
|
||||
} : {
|
||||
text: reqJson.content,
|
||||
})
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(`Resend error: ${error.name} ${error.message}`);
|
||||
}
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first<number>("balance");
|
||||
if (!balance || balance <= 0) {
|
||||
throw new Error("No balance")
|
||||
console.log(`Resend success: ${data}`);
|
||||
}
|
||||
|
||||
const sendMailByMailChannels = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
/* eslint-disable prefer-const */
|
||||
let {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
if (!to_mail) {
|
||||
throw new Error("Invalid to mail")
|
||||
}
|
||||
// check SEND_BLOCK_LIST_KEY
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY) as string[];
|
||||
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
|
||||
throw new Error("to_mail address is blocked")
|
||||
}
|
||||
/* eslint-enable prefer-const */
|
||||
from_name = from_name || address;
|
||||
to_name = to_name || to_mail;
|
||||
if (!subject) {
|
||||
throw new Error("Invalid subject")
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error("Invalid content")
|
||||
}
|
||||
// send to verified address list, do not update balance
|
||||
if (c.env.SEND_MAIL) {
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY) || [];
|
||||
if (verifiedAddressList.includes(to_mail)) {
|
||||
return sendMailToVerifyAddress(c, address, {
|
||||
from_name, to_mail, to_name, subject, content, is_html
|
||||
});
|
||||
}
|
||||
}
|
||||
let dmikBody = {}
|
||||
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
|
||||
dmikBody = {
|
||||
@@ -141,7 +132,7 @@ export const sendMail = async (
|
||||
"value": content,
|
||||
}],
|
||||
};
|
||||
let send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
|
||||
const send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
@@ -154,6 +145,69 @@ export const sendMail = async (
|
||||
if (resp.status >= 300) {
|
||||
throw new Error(`Mailchannels error: ${resp.status} ${respText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendMail = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!address) {
|
||||
throw new Error("No address")
|
||||
}
|
||||
// check domain
|
||||
const mailDomain = address.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
throw new Error("Invalid domain")
|
||||
}
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first<number>("balance");
|
||||
if (!balance || balance <= 0) {
|
||||
throw new Error("No balance")
|
||||
}
|
||||
const {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
if (!to_mail) {
|
||||
throw new Error("Invalid to mail")
|
||||
}
|
||||
// check SEND_BLOCK_LIST_KEY
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY) as string[];
|
||||
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
|
||||
throw new Error("to_mail address is blocked")
|
||||
}
|
||||
if (!subject) {
|
||||
throw new Error("Invalid subject")
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error("Invalid content")
|
||||
}
|
||||
// send to verified address list, do not update balance
|
||||
const resendEnabled = c.env.RESEND_TOKEN || c.env[
|
||||
`RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}`
|
||||
];
|
||||
if (c.env.SEND_MAIL) {
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY) || [];
|
||||
if (verifiedAddressList.includes(to_mail)) {
|
||||
await sendMailToVerifyAddress(c, address, reqJson);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// send by resend
|
||||
else if (resendEnabled) {
|
||||
await sendMailByResend(c, address, reqJson);
|
||||
}
|
||||
// send by mailchannels
|
||||
else {
|
||||
await sendMailByMailChannels(c, address, reqJson);
|
||||
}
|
||||
// update balance
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
@@ -167,12 +221,13 @@ export const sendMail = async (
|
||||
}
|
||||
// save to sendbox
|
||||
try {
|
||||
if ((body as any)?.personalizations?.[0]?.dkim_private_key) {
|
||||
delete (body as any).personalizations[0].dkim_private_key;
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf as any);
|
||||
(body as any).geoData = geoData;
|
||||
const body = {
|
||||
version: "v2",
|
||||
...reqJson,
|
||||
geoData: geoData,
|
||||
};
|
||||
const { success: success2 } = await c.env.DB.prepare(
|
||||
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
|
||||
).bind(address, JSON.stringify(body)).run();
|
||||
|
||||
@@ -59,12 +59,14 @@ async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): P
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
/* eslint-disable no-useless-escape */
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
).replace(/^"(.*)"$/, '\$1')
|
||||
);
|
||||
/* eslint-enable no-useless-escape */
|
||||
}
|
||||
const response = await fetch(settings.url, {
|
||||
method: settings.method,
|
||||
|
||||
4
worker/src/types.d.ts
vendored
4
worker/src/types.d.ts
vendored
@@ -29,6 +29,10 @@ export type Bindings = {
|
||||
CF_TURNSTILE_SITE_KEY: string | undefined
|
||||
CF_TURNSTILE_SECRET_KEY: string | undefined
|
||||
|
||||
// resend
|
||||
RESEND_TOKEN: string | undefined
|
||||
[key: `RESEND_TOKEN_${string}`]: string | undefined;
|
||||
|
||||
// telegram config
|
||||
TELEGRAM_BOT_TOKEN: string
|
||||
TG_MAX_ADDRESS: number | undefined
|
||||
|
||||
@@ -171,7 +171,7 @@ export const checkCfTurnstile = async (
|
||||
throw new Error("Captcha token is required");
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
|
||||
let formData = new FormData();
|
||||
const formData = new FormData();
|
||||
formData.append('secret', c.env.CF_TURNSTILE_SECRET_KEY);
|
||||
formData.append('response', token);
|
||||
if (reqIp) formData.append('remoteip', reqIp);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { api as apiV1 } from './deprecated';
|
||||
import { api as commonApi } from './commom_api';
|
||||
import { api as mailsApi } from './mails_api'
|
||||
import { api as userApi } from './user_api';
|
||||
// @ts-ignore
|
||||
import { api as adminApi } from './admin_api';
|
||||
import { api as apiSendMail } from './mails_api/send_mail_api'
|
||||
import { api as telegramApi } from './telegram_api'
|
||||
|
||||
Reference in New Issue
Block a user