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

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