
mail2telegram
Receive domain email and read messages and attachments through Telegram.
mail2telegram is a self-hosted Fastmail/Gmail alternative built on Cloudflare (D1, Email Workers, R2, Workers, Workers AI). Free tier eligible within limits. Inspect the source and license in the linked repository.
Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The documented mail2telegram deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.
Hosting requirements
- Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
- Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
- Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account.
- Only ordinary Free-eligible Workers AI models are covered, within 10,000 neurons/day; optional external providers and paid-only models are excluded.
- Inbound Email Routing can use Workers Free, but processing consumes Workers quotas and requires a domain; arbitrary-recipient outbound email requires Workers Paid.
- Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
Sources checked 01/10/2026
Repository snapshot: cea86d3. Hosting eligibility reflects the deployment documentation and listed assumptions.
- gmail ↗
**mail2telegram** is a Telegram bot for receiving email, running entirely on [Cloudflare Workers](https://developers.cloudflare.com/workers/). It combines instant push notifications with a Telegram Mini App: every incoming email is pushed to your chat with quick action buttons, while the full history, attachments and every setting live in the Mini App. Mail arrives through [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/), which forwards every message to the worker, and AI summaries run on [Workers AI](https://developers.cloudflare.com/workers-ai/) or any OpenAI-compatible provider.
- fastmail ↗
**mail2telegram** is a Telegram bot for receiving email, running entirely on [Cloudflare Workers](https://developers.cloudflare.com/workers/). It combines instant push notifications with a Telegram Mini App: every incoming email is pushed to your chat with quick action buttons, while the full history, attachments and every setting live in the Mini App. Mail arrives through [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/), which forwards every message to the worker, and AI summaries run on [Workers AI](https://developers.cloudflare.com/workers-ai/) or any OpenAI-compatible provider.
- workers ↗
END_API_KEY, …) go in the // gitignored `.dev.vars`. { "$schema": "./node_modules/wrangler/config-schema.json", "name": "mail2telegram", "main": "packages/server/src/index.ts", // From this date Workers enables nodejs_compat and nodejs_compat_v2 by // default, so no compatibility_flags are needed — adding them would be // redundant and is discouraged by Cloudflare. "compatibility_date": "2026-08-04", // Without this Wrangler treats the config as the only source of tru
- d1 ↗
ps the ids the button wrote back. // Migrations are read from packages/server/migrations. "d1_databases": [ { "binding": "DB", "database_name": "mail2telegram", "database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- r2 ↗
"database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- workers-ai ↗
figured // under Workers & Pages → your worker → Settings → Variables and Secrets. // `keep_vars` below stops a deploy from deleting them. DOMAIN is // optional: when unset, /init discovers the worker host and stores it // in D1. // - D1 / R2 use provisionable placeholders, so a Deploy to Cloudflare button // creates the resources and writes the real ids back. Manual deploys pass // DEPLOY_* build variables to scripts/build-config.mjs, which writes them // to the giti
- email-workers ↗
ef="docs/README_CN.md">中文</a> </p> <p align="center"> <em>Receive email in Telegram: instant push notifications plus a Mini App inbox.</em> </p> <p align="center"> <a href="https://deploy.workers.cloudflare.com/?url=https://github.com/TBXark/mail2telegram"><img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare"></a> </p> **mail2telegram** is a Telegram bot for receiving email, running entirely on [Cloudflare Workers](https://developers.cloudflare.com/workers/). It combines instant push notifications with a Telegram Mini App: every incoming email is pushed to your chat with quick action buttons, while the full history, attachments and every setting live in the Mini App. Mail a
- free-tier-eligible ↗
END_API_KEY, …) go in the // gitignored `.dev.vars`. { "$schema": "./node_modules/wrangler/config-schema.json", "name": "mail2telegram", "main": "packages/server/src/index.ts", // From this date Workers enables nodejs_compat and nodejs_compat_v2 by // default, so no compatibility_flags are needed — adding them would be // redundant and is discouraged by Cloudflare. "compatibility_date": "2026-08-04", // Without this Wrangler treats the config as the only source of tru
- free-tier-eligible ↗
ps the ids the button wrote back. // Migrations are read from packages/server/migrations. "d1_databases": [ { "binding": "DB", "database_name": "mail2telegram", "database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- free-tier-eligible ↗
"database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- free-tier-eligible ↗
up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co
- free-tier-eligible ↗
oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie
- free-tier-eligible ↗
infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the *peak* storage per day over a billing period (30 days). For examp
- free-tier-eligible ↗
000 Neurons**. Our free allocation allows anyone to use a total of **10,000 Neurons per day at no charge**. To use more than 10,000 Neurons per day, you need to sign up for the [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/#workers). On Workers Paid, you will be charged at $0.011 / 1,000 Neurons for any usage above the free allocation of 10,000 Neurons per day. You can monitor your Neuron usage in the [Cloudflare Workers AI dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/ai/workers-ai). All limits reset daily at 00:00 UTC. If you exceed any one of the above limits, further operations will fail with an error. | | Free <br> allocation | Pricing | | --- | --- | --- | |
- free-tier-eligible ↗
g is based on your Cloudflare plan and email usage. ## Plan pricing Email Routing is available on both the Workers Free and Workers Paid plans. Sending to arbitrary recipients requires the Workers Paid plan. Sending to [verified destination addresses](https://developers.cloudflare.com/email-service/configuration/email-routing-addresses/#destination-addresses) in your account is free on all plans, including when only Email Routing is configured. | | Workers Free | Workers Paid | | --- | --- | --- | | **Outbound emails (Email Sending)** | Not available | 3,000 included per month, then $0.35 per 1,000 emails | | **Inbound emails (Email Routing)** | Unlimited | Unlimited | The 3,000 included emails apply per a
- MIT ↗
MIT License Copyright (c) 2023 TBXark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEME
- architecture ↗
END_API_KEY, …) go in the // gitignored `.dev.vars`. { "$schema": "./node_modules/wrangler/config-schema.json", "name": "mail2telegram", "main": "packages/server/src/index.ts", // From this date Workers enables nodejs_compat and nodejs_compat_v2 by // default, so no compatibility_flags are needed — adding them would be // redundant and is discouraged by Cloudflare. "compatibility_date": "2026-08-04", // Without this Wrangler treats the config as the only source of tru
- architecture ↗
ps the ids the button wrote back. // Migrations are read from packages/server/migrations. "d1_databases": [ { "binding": "DB", "database_name": "mail2telegram", "database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- architecture ↗
"database_id": "", "migrations_dir": "packages/server/migrations" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "mail2telegram" } ], "ai": { "binding": "AI" } }
- architecture ↗
figured // under Workers & Pages → your worker → Settings → Variables and Secrets. // `keep_vars` below stops a deploy from deleting them. DOMAIN is // optional: when unset, /init discovers the worker host and stores it // in D1. // - D1 / R2 use provisionable placeholders, so a Deploy to Cloudflare button // creates the resources and writes the real ids back. Manual deploys pass // DEPLOY_* build variables to scripts/build-config.mjs, which writes them // to the giti
Upstream screenshot · tbxark/mail2telegram repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Receiving owned-domain mail and reading messages and attachments through Telegram; managed SMTP/IMAP, bundled outbound mail and complete account-suite parity are excluded.
See supporting source ↗Receiving owned-domain mail and reading messages and attachments through Telegram; managed SMTP/IMAP, bundled outbound mail and complete account-suite parity are excluded.
See supporting source ↗How it works
The shape of mail2telegram on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit cea86d352a95. Files were read as data; upstream applications and CI jobs were not executed.
Deployment configuration · 1 files
Cloudflare Workers · compatibility 2026-08-04
mail2telegram · default
Entrypoint: packages/server/src/index.ts
Static assets: ./packages/web/dist/client · single-page-application · Worker first: ["/api/*","/init","/email/*","/telegram/*"]
Cron triggers (UTC): 0 3 * * *
DB→ D1BUCKET→ R2AI→ Workers AIStatic assets→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L26 · readBytes calls (conditional paths may differ): stream.getReader, reader.read, chunks.push, value.subarray, catch, reader.cancel, bytes.set
- L66 · identityBufferLimit calls (conditional paths may differ): Math.max, Math.min
- L77 · toHex calls (conditional paths may differ): join, Array.from, padStart, byte.toString
- L81 · sha256Hex calls (conditional paths may differ): encode, toHex, crypto.subtle.digest
- L102 · resolveMessageIdentity calls (conditional paths may differ): toLowerCase, trim, message.headers.get, sha256Hex, readBytes, identityBufferLimit
- L116 · persistEmail calls (conditional paths may differ): crypto.randomUUID, console.error, Promise.all, parsed.attachments.map, bucket.put, attachmentRecords.push, stored.filter, env.BUCKET.put, dao.insertEmail, dao.insertAttachments, dao.getEmail
- L222 · sendMailToTelegram calls (conditional paths may differ): loadDiscoveredDomain, loadSettings, hydrateEmail, createTelegramBotAPI, filter, map, TELEGRAM_ID.split, item.trim, Promise.allSettled, chats.map, renderEmailListMode, test, api.sendMessageWithReturns, outcomes.filter
- L263 · notifyTelegram calls (conditional paths may differ): sendMailToTelegram, Promise.all, notifications.map, dao.saveTelegramMessage, console.error
- L274 · emailHandler calls (conditional paths may differ): warnIfSchemaOutdated, loadSettings, isMessageBlock, settings.blockPolicy.includes, message.setReject, resolveMessageIdentity, dao.claimMailStatus, JSON.parse, forward.trim, forwarded.has, message.forward, forwarded.add, dao.upsertMailStatus, console.error, dao.getEmailByMessageId, parseEmail, persistEmail, dao.updateEmailFlags, ctx.waitUntil, notifyTelegram
Environment references: env.BUCKET · env.DOMAIN · env.DB
- L62 · hmacHex calls (conditional paths may differ): crypto.subtle.importKey, encode, crypto.subtle.sign, join, map, padStart, byte.toString
- L78 · issueWebToken calls (conditional paths may differ): Date.now, crypto.randomUUID, hmacHex, toISOString
- L85 · verifyWebToken calls (conditional paths may differ): token.split, Number.parseInt, Number.isFinite, Date.now, timingSafeEqual, hmacHex
- L98 · createAuthMiddleware calls (conditional paths may differ): filter, map, TELEGRAM_ID.split, item.trim, req.headers.get, header.indexOf, header.slice, validate, JSON.parse, get, allowed.has, verifyWebToken
- L154 · isAuthenticatedOwner calls (conditional paths may differ): createAuthMiddleware
- L167 · timingSafeEqual calls (conditional paths may differ): encode
- L180 · errorHandler calls (conditional paths may differ): JSON.stringify
- L201 · senderRuleAddress calls (conditional paths may differ): headerAddress, test
- L212 · parseAddressField calls (conditional paths may differ): filter, map, value.split, item.trim, test, headerAddress
- L228 · parseFolder calls (conditional paths may differ): allowed.includes
- L250 · parseCutoff calls (conditional paths may differ): Number, Number.isFinite, toISOString, Date.now
- L261 · createRouter calls (conditional paths may differ): Router, createAuthMiddleware, router.get, Boolean, router.post, req.json, timingSafeEqual, issueWebToken, requireEmail, createTelegramBotAPI, isAuthenticatedOwner, loadDiscoveredDomain, saveDiscoveredDomain, loadWebhookSecret, generateWebhookSecret, api.setWebhook, claimWebhookSecret, api.setMyCommands, api.setChatMenuButton, webhook.json
- L781 · configuredDomainOf calls (conditional paths may differ): configuredDomains.has, configuredDomains.set, configuredDomains.get
- L788 · fetchHandler calls (conditional paths may differ): configuredDomainOf, warnIfSchemaOutdated, createRouter, catch, router.fetch, JSON.stringify
Environment references: env.WEB_PASSWORD · env.TELEGRAM_TOKEN · env.RESEND_API_KEY · env.AI · env.DOMAIN · env.DB
- L12 · scheduledHandler calls (conditional paths may differ): loadSettings, toISOString, Date.now, purgeEmails, console.log
Environment references: env.DB · env.BUCKET
- L72 · originalMessageId calls (conditional paths may differ): JSON.parse, value.trim
- L86 · chunkIds calls (conditional paths may differ): chunks.push, ids.slice
- L94 · placeholders calls (conditional paths may differ): join, fill, Array.from
- L660 · loadArrayFromRaw calls (conditional paths may differ): JSON.parse, Array.isArray, filter, list.map
- L68 · findSchemaProblems calls (conditional paths may differ): Object.keys, db.batch, tables.map, db.prepare, tables.forEach, problems.push, columns.map, requirement.columns.filter, present.has, missing.join, map, toSorted, columns.filter, expected.every, actual.includes, actual.join, expected.join
- L121 · warnIfSchemaOutdated calls (conditional paths may differ): checks.get, findSchemaProblems, checks.set, warned.has, warned.add, console.error, problems.join
- L45 · toInt calls (conditional paths may differ): Number.parseInt, Number.isNaN
- L57 · toBlockPolicy calls (conditional paths may differ): map, split, item.trim, list.filter, allowed.has
- L64 · toMaxSizePolicy calls (conditional paths may differ): allowed.includes
- L70 · defaultSettings calls (conditional paths may differ): toInt, toBool, toBlockPolicy, filter, map, split, item.trim, toMaxSizePolicy, Boolean, toSummaryProvider, openaiBaseUrl, trim
- L99 · mergeSettings calls (conditional paths may differ): toInt, toBool, toBlockPolicy, loadArrayFromRaw, toMaxSizePolicy, toSummaryProvider, openaiBaseUrl
- L157 · loadSettings calls (conditional paths may differ): dao.getSettings, mergeSettings, defaultSettings
- L175 · saveDiscoveredDomain calls (conditional paths may differ): dao.getSetting, dao.setSetting
- L192 · loadDiscoveredDomain calls (conditional paths may differ): getSetting
- L199 · generateWebhookSecret calls (conditional paths may differ): replace, crypto.randomUUID
- L208 · claimWebhookSecret calls (conditional paths may differ): dao.setSettingIfAbsent, dao.getSetting
- L215 · loadWebhookSecret calls (conditional paths may differ): getSetting
- L220 · saveSettings calls (conditional paths may differ): patch.blockPolicy.join, JSON.stringify, patch.openaiApiKey.trim, openaiBaseUrl, dao.setSettings
- L278 · importSettingsFromEnv calls (conditional paths may differ): saveSettings, defaultSettings, map, dao.listAddresses, item.address.toLowerCase, loadArrayFromRaw, pattern.toLowerCase, existing.has, validateAddressPattern, skippedAddresses.push, console.error, existing.add, dao.addAddress, loadSettings
Environment references: env.AUTO_CLEANUP_DAYS · env.ATTACHMENT_SAVE_ENABLED · env.ATTACHMENT_MAX_SIZE · env.BLOCK_POLICY · env.FORWARD_LIST · env.MAX_EMAIL_SIZE · env.MAX_EMAIL_SIZE_POLICY · env.AI · env.WORKERS_AI_MODEL · env.OPENAI_API_KEY · env.SUMMARY_PROVIDER · env.OPENAI_CHAT_MODEL · env.OPENAI_BASE_URL · env.OPENAI_COMPLETIONS_API · env.SUMMARY_TARGET_LANG · env.DB · env.WHITE_LIST · env.BLOCK_LIST
- L14 · deleteR2Objects calls (conditional paths may differ): bucket.delete, keys.slice, console.error
- L27 · collectStoredKeys calls (conditional paths may differ): keys.push, isR2Pointer
- L51 · purgeEmailsByIds calls (conditional paths may differ): targets.map, dao.findAttachmentKeys, deleteR2Objects, collectStoredKeys, attachments.map, dao.deleteAttachmentsByIds, dao.deleteEmailsByIds, dao.deleteTelegramMessagesByEmailIds, dao.deleteMailStatusByJournalKeys, map, targets.filter
- L75 · purgeEmails calls (conditional paths may differ): dao.findCleanupTargets, purgeEmailsByIds, dao.countCleanupTargets
- L98 · purgeAttachments calls (conditional paths may differ): dao.findAttachmentsBefore, deleteR2Objects, rows.map, dao.deleteAttachmentsByIds, dao.clearAttachmentFlags, dao.countAttachmentsBefore
- L17 · openaiBaseUrl calls (conditional paths may differ): replace, endpoint.trim, base.replace
- L22 · chatCompletionsUrl calls (conditional paths may differ): openaiBaseUrl
- L26 · summarizedByWorkerAI calls (conditional paths may differ): ai.run
- L47 · summarizedByOpenAI calls (conditional paths may differ): fetch, chatCompletionsUrl, JSON.stringify, resp.json
- L78 · summaryEnabled calls (conditional paths may differ): Boolean, openaiBaseUrl
- L89 · summarizeEmail calls (conditional paths may differ): substring, summarizedByWorkerAI, summarizedByOpenAI
- L104 · collectTextModels calls (conditional paths may differ): map, models.filter
- L111 · listWorkersAiTextModels calls (conditional paths may differ): ai.models, collectTextModels, names.concat, toSorted, a.localeCompare
- L131 · listOpenAiCompatibleModels calls (conditional paths may differ): openaiBaseUrl, fetch, resp.json, Array.isArray, filter, list.map, Boolean, toSorted, a.localeCompare
Environment references: env.AI
- L84 · isSafeAddressPattern calls (conditional paths may differ): pattern.indexOf, trim, pattern.slice, test, applyQuantifier, consumeQuantifier, readGroupPrefix, scan
- L93 · consumeQuantifier calls (conditional paths may differ): pattern.indexOf, trim, pattern.slice, test
- L128 · scan calls (conditional paths may differ): applyQuantifier, consumeQuantifier, readGroupPrefix, scan
- L247 · validateAddressPattern calls (conditional paths may differ): isSafeAddressPattern
- L272 · testAddress calls (conditional paths may differ): pattern.toLowerCase, address.toLowerCase, regex.test
- L289 · matchAddress calls (conditional paths may differ): testAddress
- L310 · loadAddressLists calls (conditional paths may differ): map, dao.listAddresses, loadArrayFromRaw, usablePatterns
- L323 · usablePatterns calls (conditional paths may differ): isSafeAddressPattern, usable.push, console.error
- L346 · checkAddressStatus calls (conditional paths may differ): loadAddressLists, matchAddress
- L373 · headerAddress calls (conditional paths may differ): value.match, trim, candidate.replace
- L395 · isMessageBlock calls (conditional paths may differ): headerAddress, checkAddressStatus
- L415 · testAddressAgainstLists calls (conditional paths may differ): loadAddressLists, white.filter, testAddress, block.filter
Environment references: env.DB · env.WHITE_LIST · env.BLOCK_LIST
- L5 · isR2Pointer calls (conditional paths may differ): Boolean, value.startsWith
- L9 · resolveBodyValue calls (conditional paths may differ): isR2Pointer, bucket.get, object.text
- L29 · resolveEmailBody calls (conditional paths may differ): Promise.all, resolveBodyValue
- L38 · hydrateEmail calls (conditional paths may differ): isR2Pointer, resolveEmailBody
- L56 · toPublicEmail calls (conditional paths may differ): resolveEmailBody
- L7 · truncateStream calls (conditional paths may differ): controller.terminate, controller.enqueue, chunk.slice, stream.pipeThrough
- L30 · normalizeAttachmentContent calls (conditional paths may differ): encode
- L54 · parseEmail calls (conditional paths may differ): crypto.randomUUID, message.headers.get, toISOString, truncateStream, parser.parse, filter, map, split, ref.trim, JSON.stringify, convert, normalizeAttachmentContent
- L32 · renderEmailListMode calls (conditional paths may differ): summaryEnabled, keyboard.push
- L106 · renderEmailPreviewMode calls (conditional paths may differ): renderEmailDetail, substring, sendableText
- L115 · renderEmailSummaryMode calls (conditional paths may differ): renderEmailDetail, summarizeEmail
- L130 · renderEmailDebugMode calls (conditional paths may differ): checkAddressStatus, maskUrl, JSON.stringify, renderEmailDetail
- L164 · maskUrl calls (conditional paths may differ): url.toString
- L66 · createTelegramBotAPI calls (conditional paths may differ): Reflect.get, prop.endsWith, prop.slice, Reflect.apply
- L18 · logTelegram calls (conditional paths may differ): console.log, JSON.stringify
- L22 · logTelegramError calls (conditional paths may differ): console.error, JSON.stringify, String
- L33 · logTelegramResponse calls (conditional paths may differ): substring, text, response.clone, logTelegram
- L54 · consumeFirstStart calls (conditional paths may differ): claimFirstStart
- L58 · handleStartCommand calls (conditional paths may differ): consumeFirstStart, logTelegramError, sendMessage, createTelegramBotAPI
- L98 · allowedChats calls (conditional paths may differ): filter, map, env.TELEGRAM_ID.split, item.trim
- L105 · isAllowedChat calls (conditional paths may differ): allowedChats, allowed.includes, toLowerCase, allowed.some, item.toLowerCase
- L114 · handleReplyEmailCommand calls (conditional paths may differ): createTelegramBotAPI, api.sendMessage, logTelegram, reply, dao.getEmailIdByTelegramMessage, dao.getEmail, replyToEmail, dao.recordSentReply, logTelegramError
- L167 · telegramCommandHandler calls (conditional paths may differ): logTelegram, isAllowedChat, handleReplyEmailCommand, text.startsWith, substring, text.split, handleStartCommand
- L202 · telegramCallbackHandler calls (conditional paths may differ): createTelegramBotAPI, logTelegram, isAllowedChat, loadSettings, dao.getEmail, hydrateEmail, render, api.editMessageText, logTelegramResponse, api.deleteMessage, renderHandlerBuilder, data.split, logTelegramError, api.answerCallbackQuery
- L294 · telegramWebhookHandler calls (conditional paths may differ): req.json, logTelegram, Object.keys, telegramCommandHandler, telegramCallbackHandler
Environment references: env.DB · env.TELEGRAM_ID · env.DEBUG
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: workflow_dispatch
deploy · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v4
actions/setup-node@v4 - Install dependencies
pnpm install --frozen-lockfile - Lint and check formatting
pnpm exec oxlint pnpm exec oxfmt --check - Test
pnpm test - Generate deploy config
pnpm run deploy:config - Typecheck and build
pnpm run build - Apply D1 migrations
pnpm run db:migrate:remote - Deploy Worker
cloudflare/wrangler-action@v3Wrangler command: deploy --config wrangler.deploy.jsonc
build: pnpm typecheck && pnpm --filter @mail2telegram/web buildbuild:web: pnpm --filter @mail2telegram/web builddeploy:config: node scripts/build-config.mjsdeploy: node scripts/build-config.mjs && wrangler d1 migrations apply DB --remote --config wrangler.deploy.jsonc && pnpm --filter @mail2telegram/web build && wrangler deploy --config wrangler.deploy.jsonc
build: vite build
Repository README
View original on GitHub ↗Full upstream document by @tbxark · README.md · snapshot cea86d3
mail2telegram
English | 中文
Receive email in Telegram: instant push notifications plus a Mini App inbox.
mail2telegram is a Telegram bot for receiving email, running entirely on Cloudflare Workers. It combines instant push notifications with a Telegram Mini App: every incoming email is pushed to your chat with quick action buttons, while the full history, attachments and every setting live in the Mini App. Mail arrives through Cloudflare Email Routing, which forwards every message to the worker, and AI summaries run on Workers AI or any OpenAI-compatible provider.

How it works
Email ──▶ Telegram push with quick action buttons
└─▶ Mini App inbox (history, attachments, settings)
- Push notifications carry quick action buttons per email:
Preview,SummaryandOpen. - Mini App inbox lists history per folder, renders HTML in a sandbox, downloads attachments, replies and composes new mail through Resend, and manages all settings.
- Settings in the Mini App include white/black lists with regex matching, an address tester, block policy, forwarding, AI summary options and mail handling limits.
All behavior is configured in the Mini App and stored in D1 — the worker itself only needs a few variables for its Telegram identity and optional API keys.
Guides
- Quick deploy — Deploy to Cloudflare clones the repo, creates the D1 / R2 resources, asks for the Telegram values and deploys. Bind the bot and Email Routing afterwards.
- Deployment Guide (中文) — install 2.0 from scratch: Telegram bot setup, D1 / R2 storage, deploying with the Deploy to Cloudflare button, Cloudflare Workers Builds, GitHub Actions or the CLI, runtime variables and Email Routing.
- Migration Guide (中文) — upgrade an existing 1.0 deployment: what changed in 2.0, the step-by-step upgrade path and the variable mapping.
Telegram Mini App
Open the Mini App from the bot with /start. The first /start from a chat also binds the webhook and points the bot menu button at the worker, so later opens can use the menu button directly. Everything below is managed inside the Mini App:
- Inbox — folders (Inbox / Spam / Trash / Sent), search, unread and starred filters, pull through history with "Load more".
- Reader — sandboxed HTML view with a plain text toggle, attachments, star/read/delete, AI summary and reply.
- Settings — white list, block list, address tester, block policy, forwarding, summary options and mail handling limits.
The Mini App is protected by Telegram initData validation against TELEGRAM_TOKEN, restricted to the IDs in TELEGRAM_ID. In a plain browser the same URL asks for WEB_PASSWORD; when no password is configured, the Mini App is the only way in and the project landing page is shown instead.
On phones the app uses a native tab bar and navigation stack. On desktop clients (macOS, Telegram Desktop) and wide viewports it switches to an iPad-style split view with a sidebar, list and reading pane.
Usage
The push message structure is unchanged:
[Subject]
-----------
From : [sender]
To : [recipient]
(Preview)(Summary)(Open)
Previewshows the plain text body directly in the chat, limited to 4096 characters.Summaryappears when a summary backend is enabled in Settings (Workers AI, or an OpenAI-compatible provider).Openlaunches the Mini App straight to this message's detail page. Telegram only allows Mini App buttons in private chats, so group notifications omit it.
Reply to any pushed message in Telegram to answer the sender through Resend.
CPU budget: parsing a message costs roughly 2ms of CPU per MB, so keep
Max Sizeunder ~2MB on the Workers free plan (10ms CPU per request). The paid plan's 30s budget has no such constraint. See Attachments.
Address lists
Rules are managed in the Mini App. A rule matches either an exact address (case-insensitive) or a regular expression. The white list takes precedence over the block list, so an allow rule can override a broad block rule for the same address.
Attachments
Attachments are stored in R2 and listed in the reader, where they can be downloaded. If no BUCKET binding is configured, mail is still stored without attachment contents.
Attachments need the whole message, so Max Size in Mail Handling has to be at least as large as the mail you want to receive. Base64 encoding adds about a third to every attachment, so budget the limit around the largest file you expect. Mail over the limit has its body truncated, and because attachments follow the body in the message, a truncated mail stores no attachments at all — the alternative would be handing you a corrupt download. The oversize policy Headers avoids the problem differently by skipping the body altogether.
Retention
Mail older than the retention setting is not part of the notification cache, but the full history remains until you delete it from the Mini App.
Development
The repo is a pnpm workspace with three packages and the deployable worker config at the root:
| Path | Package | Contents |
|---|---|---|
packages/server |
@mail2telegram/server |
Cloudflare Worker: email handlers, Telegram bot, Mini App API, D1 migrations and the Worker-runtime tests. |
packages/web |
@mail2telegram/web |
Telegram Mini App (Vite + React), built to packages/web/dist/client. |
packages/shared |
@mail2telegram/shared |
Types-only HTTP contract consumed by both sides; owns every type that crosses the wire. |
pnpm install
pnpm dev # Vite dev server on :5173 + wrangler dev on :8787
pnpm build # typecheck every package and build the Mini App into packages/web/dist/client
pnpm typecheck # tsc --noEmit for each package
pnpm test # pure-logic tests (tsx) + Worker-runtime tests (vitest, Miniflare)
pnpm test:unit # mergeSettings / testAddress / parseEmail only
pnpm test:pool # D1, inbound-email and cleanup tests in the Workers runtime
pnpm lint # oxlint
pnpm lint:fix # oxlint --fix
pnpm format # oxfmt
pnpm format:check # oxfmt --check (used by CI)
pnpm screenshots # regenerate the README images in docs/assets from mock data
wrangler dev serves the built packages/web/dist/client, so run pnpm build (or keep pnpm build:web running) at least once before starting it; pnpm dev runs both processes but does not build the assets.
Local D1 migrations:
pnpm db:migrate:local
The Mini App normally requires a valid Telegram initData signature. For local development, put WEB_PASSWORD=dev (plus your bot values) in the gitignored .dev.vars, run wrangler dev and open http://localhost:5173, then sign in with that password — exactly how a browser session works in production. The ?debug flag still mocks the Telegram UI (theme, viewport, platform) for previewing, and ?platform=ios previews the phone layout in a desktop browser. Never set a real WEB_PASSWORD in anything but the deployed worker's variables.
License
mail2telegram is released under the MIT license. See LICENSE for details.
Frequently asked about mail2telegram
What is mail2telegram?+
mail2telegram is a self-hosted Fastmail/Gmail alternative built on the Cloudflare developer platform. Receive domain email and read messages and attachments through Telegram.
What does mail2telegram replace?+
mail2telegram is listed as an alternative to Fastmail, Gmail. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does mail2telegram use?+
mail2telegram is built on D1, Email Workers, R2, Workers, Workers AI.
How much does mail2telegram cost to run?+
The documented mail2telegram deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account. Only ordinary Free-eligible Workers AI models are covered, within 10,000 neurons/day; optional external providers and paid-only models are excluded. Inbound Email Routing can use Workers Free, but processing consumes Workers quotas and requires a domain; arbitrary-recipient outbound email requires Workers Paid. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Check current Cloudflare pricing before deploying.
Is mail2telegram open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/tbxark/mail2telegram/cea86d352a95c544a42772931080dfbfb401a2bc/LICENSE. Source code and contributor credit are available at https://github.com/tbxark/mail2telegram.


Discussion · 0
sign in to comment →