feat: add resend for send mail (#274)

This commit is contained in:
Dream Hunter
2024-05-26 12:37:11 +08:00
committed by GitHub
parent dc14338b69
commit 3b6736924b
15 changed files with 1576 additions and 65 deletions

View File

@@ -6,6 +6,7 @@
- UI lazy load
- telegram bot 添加用户全局推送功能
- 增加对 cloudflare verified 用户发送邮件
- 增加使用 `resend` 发送邮件
## v0.4.4

View File

@@ -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)

View File

@@ -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>

View File

@@ -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
View 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",
}
}
];

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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'