feat: support attachment push for Telegram and Webhook (#895)

* feat: support attachment push for Telegram and Webhook (#894)

- Parse email attachments via postal-mime in commonParseMail
- Send attachments via Telegram Bot API sendDocument after text message
- Include base64-encoded attachments in webhook payload
- Add e2e tests for webhook attachment push
- Add i18n messages for attachment-related notifications

Closes #894

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove user-facing error message for failed attachment send

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove unused i18n attachment messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use sendMediaGroup for batch attachment sending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove redundant commonParseMail call, use cached result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove webhook attachment support, raw already contains attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use sendDocument for single attachment, sendMediaGroup for 2+

Telegram sendMediaGroup requires 2-10 items minimum. Use sendDocument
for single attachment case. Update CHANGELOG with 50MB limit info.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: batch sendMediaGroup in groups of 9, add attachments to wasm parser

Telegram sendMediaGroup supports 2-10 items. Batch large attachment
lists into groups of 9. Also add attachments field to commented-out
wasm parser for future compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add caption to attachment messages, update wasm patch

Add email sender and subject as caption on Telegram attachment messages.
Caption is shown on the first attachment only for sendMediaGroup.
Update wasm parser patch to include attachments field mapping, and fix
wasm comment to use correct field names (content_type, content as
Uint8Array directly).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: unify attachment sending with sendMediaGroup for all cases

sendMediaGroup works with 1+ files (tested). Remove sendDocument
special case and always use sendMediaGroup with batching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: reduce sendMediaGroup batch size to 6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: change WASM parse email comment from TODO to NOTE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: regenerate wasm parser patch with attachments support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ENABLE_TG_PUSH_ATTACHMENT env var to control attachment push

Add environment variable to enable/disable Telegram attachment push
(default disabled). Update type definitions, wrangler template,
worker-vars docs (zh/en), telegram feature docs (zh/en), and
changelogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-03-14 02:10:48 +08:00
committed by GitHub
parent 5bb053fb7b
commit 9ee21da8a9
11 changed files with 103 additions and 10 deletions

View File

@@ -1,16 +1,14 @@
diff --git a/worker/src/common.ts b/worker/src/common.ts
index bd9bcc9..e7e2748 100644
index 9b758f0..e2150b5 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
// NOTE: WASM parse email
- // try {
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
+ try {
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
-
- // const parsedEmail = parse_message_wrapper(raw_mail);
- // parsedEmailContext.parsedEmail = {
- // sender: parsedEmail.sender || "",
@@ -20,11 +18,20 @@ index bd9bcc9..e7e2748 100644
- // (header) => ({ key: header.key, value: header.value })
- // ) || [],
- // html: parsedEmail.body_html || "",
- // attachments: (parsedEmail.attachments || []).map(att => ({
- // filename: att.filename || "attachment",
- // mimeType: att.content_type || "application/octet-stream",
- // content: att.content,
- // disposition: "attachment",
- // })),
- // };
- // return parsedEmailContext.parsedEmail;
- // } catch (e) {
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
- // }
+ try {
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
+
+ const parsedEmail = parse_message_wrapper(raw_mail);
+ parsedEmailContext.parsedEmail = {
+ sender: parsedEmail.sender || "",
@@ -34,6 +41,12 @@ index bd9bcc9..e7e2748 100644
+ (header) => ({ key: header.key, value: header.value })
+ ) || [],
+ html: parsedEmail.body_html || "",
+ attachments: (parsedEmail.attachments || []).map(att => ({
+ filename: att.filename || "attachment",
+ mimeType: att.content_type || "application/octet-stream",
+ content: att.content,
+ disposition: "attachment",
+ })),
+ };
+ return parsedEmailContext.parsedEmail;
+ } catch (e) {

View File

@@ -12,6 +12,7 @@
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894
### Bug Fixes

View File

@@ -12,6 +12,7 @@
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
### Bug Fixes

View File

@@ -80,6 +80,17 @@ Admins can enable **global mail push** in the admin panel under `Settings` -> `T
> [!NOTE]
> Global push and per-user push can work simultaneously. If an address is bound to a user who is also in the global push list, they will receive two notifications.
### Attachment Push
> [!NOTE]
> This feature is available since v1.5.0
Set `ENABLE_TG_PUSH_ATTACHMENT = true` to enable sending email attachments via Telegram push.
- Single file size limit is 50MB (Telegram Bot API limit), oversized attachments are skipped
- Multiple attachments are sent in batches via `sendMediaGroup`, up to 6 per batch
- The first attachment includes the sender and subject as caption
## Mini App
Can be deployed via command line or UI interface

View File

@@ -112,6 +112,7 @@
| `TG_MAX_ADDRESS` | Number | Maximum number of mailboxes that can be bound to telegram bot | `5` |
| `TG_BOT_INFO` | Text | Optional, telegram BOT_INFO, predefined BOT_INFO can reduce webhook latency | `{}` |
| `TG_ALLOW_USER_LANG` | Text/JSON | Allow users to switch language via `/lang` command, default `false` | `true` |
| `ENABLE_TG_PUSH_ATTACHMENT` | Boolean | Enable sending email attachments via Telegram push, default `false`, 50MB per file limit | `true` |
> [!NOTE]
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout

View File

@@ -80,6 +80,17 @@ Telegram Bot 支持 **每用户独立推送**,用户绑定地址后,该地
> [!NOTE]
> 全局推送和每用户推送可以同时生效。如果某地址已绑定用户,同时该用户也在全局推送列表中,则会收到两条通知。
### 附件推送
> [!NOTE]
> 此功能从 v1.5.0 版本开始支持
配置 `ENABLE_TG_PUSH_ATTACHMENT = true` 后,邮件附件会随推送一起发送到 Telegram。
- 单个附件大小限制 50MBTelegram Bot API 限制),超过的附件会被跳过
- 多附件通过 `sendMediaGroup` 批量发送,每批最多 6 个
- 第一个附件会附带邮件发件人和主题信息作为 caption
## Mini App
可以通过命令行部署,或者 UI 界面部署

View File

@@ -112,6 +112,7 @@
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
| `TG_BOT_INFO` | 文本 | 可不配置telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
| `TG_ALLOW_USER_LANG`| 文本/JSON | 是否允许用户通过 `/lang` 命令切换语言,默认 `false` | `true`|
| `ENABLE_TG_PUSH_ATTACHMENT`| 布尔值 | 是否启用 Telegram 推送邮件附件,默认 `false`,单文件限制 50MB | `true`|
> [!NOTE]
> Telegram 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时

View File

@@ -456,7 +456,8 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
subject: string,
text: string,
html: string,
headers?: Record<string, string>[]
headers?: Record<string, string>[],
attachments?: ParsedEmailAttachment[],
} | undefined> => {
// check parsed email context is valid
if (!parsedEmailContext || !parsedEmailContext.rawEmail) {
@@ -467,7 +468,7 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
return parsedEmailContext.parsedEmail;
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
// NOTE: WASM parse email
// try {
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
@@ -480,6 +481,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
// (header) => ({ key: header.key, value: header.value })
// ) || [],
// html: parsedEmail.body_html || "",
// attachments: (parsedEmail.attachments || []).map(att => ({
// filename: att.filename || "attachment",
// mimeType: att.content_type || "application/octet-stream",
// content: att.content,
// disposition: "attachment",
// })),
// };
// return parsedEmailContext.parsedEmail;
// } catch (e) {
@@ -494,6 +501,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
text: parsedEmail.text || "",
html: parsedEmail.html || "",
headers: parsedEmail.headers || [],
attachments: (parsedEmail.attachments || []).map(att => ({
filename: att.filename || "attachment",
mimeType: att.mimeType || "application/octet-stream",
content: new Uint8Array(att.content),
disposition: att.disposition || "attachment",
})),
};
return parsedEmailContext.parsedEmail;
}
@@ -605,7 +618,7 @@ export async function triggerWebhook(
subject: parsedEmail?.subject || "",
raw: parsedEmailContext.rawEmail || "",
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
parsedHtml: parsedEmail?.html || "",
}
for (const settings of webhookList) {
const res = await sendWebhook(settings, webhookMail);

View File

@@ -2,6 +2,7 @@
import { Context } from "hono";
import { Telegraf, Context as TgContext, Markup } from "telegraf";
import { callbackQuery } from "telegraf/filters";
import { InputMediaDocument } from "telegraf/types";
import { CONSTANTS } from "../constants";
import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils';
@@ -12,6 +13,8 @@ import { UserFromGetMe } from "telegraf/types";
import i18n from "../i18n";
import { LocaleMessages } from "../i18n/type";
const TG_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Telegram Bot API limit
// Helper to get messages by userId
const getTgMessages = async (
c: Context<HonoCustomType>,
@@ -424,6 +427,7 @@ export async function sendMailToTelegram(
const buildAndSend = async (targetUserId: string, msgs: LocaleMessages) => {
const { mail } = await parseMail(msgs, parsedEmailContext, address, new Date().toUTCString());
if (!mail) return;
const attachments = parsedEmailContext.parsedEmail?.attachments || [];
const buttons = [];
if (settings?.miniAppUrl && mailId) {
const url = new URL(settings.miniAppUrl);
@@ -434,6 +438,32 @@ export async function sendMailToTelegram(
await bot.telegram.sendMessage(targetUserId, mail, {
...Markup.inlineKeyboard([...buttons])
});
// send attachments
if (!getBooleanValue(c.env.ENABLE_TG_PUSH_ATTACHMENT)) return;
const validAttachments = attachments.filter(att => {
if (att.content.byteLength > TG_MAX_FILE_SIZE) {
console.log(`Skipping attachment ${att.filename}: ${(att.content.byteLength / 1024 / 1024).toFixed(1)}MB exceeds 50MB limit`);
return false;
}
return true;
});
if (validAttachments.length > 0) {
const caption = `From: ${parsedEmailContext.parsedEmail?.sender || ""}\nSubject: ${parsedEmailContext.parsedEmail?.subject || ""}`;
const batchSize = 6;
for (let i = 0; i < validAttachments.length; i += batchSize) {
const batch = validAttachments.slice(i, i + batchSize);
try {
const mediaGroup: InputMediaDocument[] = batch.map((att, idx) => ({
type: 'document',
media: { source: Buffer.from(att.content), filename: att.filename },
...(i === 0 && idx === 0 ? { caption } : {}),
}));
await bot.telegram.sendMediaGroup(targetUserId, mediaGroup);
} catch (e) {
console.error(`Failed to send attachment batch ${i / batchSize + 1}:`, e);
}
}
}
};
if (globalPush) {

11
worker/src/types.d.ts vendored
View File

@@ -86,6 +86,7 @@ type Bindings = {
TG_MAX_ADDRESS: number | undefined
TG_BOT_INFO: string | object | undefined
TG_ALLOW_USER_LANG: string | boolean | undefined
ENABLE_TG_PUSH_ATTACHMENT: string | boolean | undefined
// webhook config
FRONTEND_URL: string | undefined
@@ -135,6 +136,13 @@ type RPCEmailMessage = {
headers: object | undefined | null,
}
type ParsedEmailAttachment = {
filename: string,
mimeType: string,
content: Uint8Array,
disposition: string,
}
type ParsedEmailContext = {
rawEmail: string,
parsedEmail?: {
@@ -142,7 +150,8 @@ type ParsedEmailContext = {
subject: string,
text: string,
html: string,
headers?: Record<string, string>[]
headers?: Record<string, string>[],
attachments?: ParsedEmailAttachment[],
} | undefined
}

View File

@@ -93,6 +93,8 @@ ENABLE_AUTO_REPLY = false
# TG_BOT_INFO = "{}"
# allow user to switch language via /lang command
# TG_ALLOW_USER_LANG = true
# enable sending email attachments via Telegram push (50MB per file limit)
# ENABLE_TG_PUSH_ATTACHMENT = true
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# subdomain forward address list, if set, subdomain emails will be forwarded to these addresses