diff --git a/.github/config/mail-parser-wasm-worker.patch b/.github/config/mail-parser-wasm-worker.patch index 289b9a80..e0e88520 100644 --- a/.github/config/mail-parser-wasm-worker.patch +++ b/.github/config/mail-parser-wasm-worker.patch @@ -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) { diff --git a/CHANGELOG.md b/CHANGELOG.md index d57364db..06c881e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index f22fdfeb..6ca9650d 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -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 diff --git a/vitepress-docs/docs/en/guide/feature/telegram.md b/vitepress-docs/docs/en/guide/feature/telegram.md index 00fd4df0..9e2407b3 100644 --- a/vitepress-docs/docs/en/guide/feature/telegram.md +++ b/vitepress-docs/docs/en/guide/feature/telegram.md @@ -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 diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index 374e6a1a..8072f236 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -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 diff --git a/vitepress-docs/docs/zh/guide/feature/telegram.md b/vitepress-docs/docs/zh/guide/feature/telegram.md index 3f568a3c..39019a9c 100644 --- a/vitepress-docs/docs/zh/guide/feature/telegram.md +++ b/vitepress-docs/docs/zh/guide/feature/telegram.md @@ -80,6 +80,17 @@ Telegram Bot 支持 **每用户独立推送**,用户绑定地址后,该地 > [!NOTE] > 全局推送和每用户推送可以同时生效。如果某地址已绑定用户,同时该用户也在全局推送列表中,则会收到两条通知。 +### 附件推送 + +> [!NOTE] +> 此功能从 v1.5.0 版本开始支持 + +配置 `ENABLE_TG_PUSH_ATTACHMENT = true` 后,邮件附件会随推送一起发送到 Telegram。 + +- 单个附件大小限制 50MB(Telegram Bot API 限制),超过的附件会被跳过 +- 多附件通过 `sendMediaGroup` 批量发送,每批最多 6 个 +- 第一个附件会附带邮件发件人和主题信息作为 caption + ## Mini App 可以通过命令行部署,或者 UI 界面部署 diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index 4e302d3e..610a59fd 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -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 有限,可能会导致大邮件解析超时 diff --git a/worker/src/common.ts b/worker/src/common.ts index 9f2d2d35..9b758f0f 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -456,7 +456,8 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P subject: string, text: string, html: string, - headers?: Record[] + headers?: Record[], + 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); diff --git a/worker/src/telegram_api/telegram.ts b/worker/src/telegram_api/telegram.ts index 2caf06b6..849324e7 100644 --- a/worker/src/telegram_api/telegram.ts +++ b/worker/src/telegram_api/telegram.ts @@ -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, @@ -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) { diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts index e8d437fc..df6bca5a 100644 --- a/worker/src/types.d.ts +++ b/worker/src/types.d.ts @@ -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[] + headers?: Record[], + attachments?: ParsedEmailAttachment[], } | undefined } diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template index ca365c0f..aa7da461 100644 --- a/worker/wrangler.toml.template +++ b/worker/wrangler.toml.template @@ -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