Cloudsteading
Telegram Mini App: push notification, inbox, message reader and iPad split view

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

@tbxark

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.
Check current pricing ↗
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.

Gmail logoGmail ↗

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 ↗
Fastmail logoFastmail ↗

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 ↗
external SaaS target
varies
→ D1 + Email Workers + R2
external SaaS target
varies
→ D1 + Email Workers + R2

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 ↗
Public interface
Configured entry points1
mail2telegram
wrangler.jsonc
↓
App
mail2telegram
entry
Cloudflare Workers
Entrypoint: packages/server/src/index.tsConfigured cron (UTC): 0 3 * * *
↓

Configuration and workflow sources

Reviewed commit cea86d352a95. Files were read as data; upstream applications and CI jobs were not executed.

Deployment configuration · 1 files
wrangler.jsonc ↗

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 → D1
  • BUCKET → R2
  • AI → Workers AI
  • Static 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.

packages/server/src/index.ts ↗
  • L7 · fetch handler exported
  • L8 · email handler exported
  • L9 · scheduled handler exported
packages/server/src/handler/email.ts ↗
  • 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

packages/server/src/handler/fetch/index.ts ↗
  • 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

packages/server/src/handler/scheduled.ts ↗
  • L12 · scheduledHandler calls (conditional paths may differ): loadSettings, toISOString, Date.now, purgeEmails, console.log

Environment references: env.DB · env.BUCKET

packages/server/src/db/index.ts ↗
  • 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
packages/server/src/db/schema.ts ↗
  • 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
packages/server/src/db/settings.ts ↗
  • 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

packages/server/src/db/cleanup.ts ↗
  • 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
packages/server/src/mail/summarization.ts ↗
  • 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

packages/server/src/mail/check.ts ↗
  • 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

packages/server/src/mail/body.ts ↗
  • 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
packages/server/src/mail/parse.ts ↗
  • 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
packages/server/src/mail/render.ts ↗
  • 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
packages/server/src/mail/resend.ts ↗
  • L19 · replyToEmail calls (conditional paths may differ): email.subject.startsWith, sendEmail
  • L34 · sendEmail calls (conditional paths may differ): fetch, JSON.stringify, email.attachments.map, response.json
packages/server/src/telegram/api.ts ↗
  • L66 · createTelegramBotAPI calls (conditional paths may differ): Reflect.get, prop.endsWith, prop.slice, Reflect.apply
packages/server/src/telegram/telegram.ts ↗
  • 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.

Deploy · .github/workflows/deploy.yml ↗

Triggers: workflow_dispatch

deploy · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. pnpm/action-setup@v4pnpm/action-setup@v4
  3. actions/setup-node@v4actions/setup-node@v4
  4. Install dependenciespnpm install --frozen-lockfile
  5. Lint and check formattingpnpm exec oxlint pnpm exec oxfmt --check
  6. Testpnpm test
  7. Generate deploy configpnpm run deploy:config
  8. Typecheck and buildpnpm run build
  9. Apply D1 migrationspnpm run db:migrate:remote
  10. Deploy Workercloudflare/wrangler-action@v3Wrangler command: deploy --config wrangler.deploy.jsonc
package.json ↗
  • build: pnpm typecheck && pnpm --filter @mail2telegram/web build
  • build:web: pnpm --filter @mail2telegram/web build
  • deploy:config: node scripts/build-config.mjs
  • deploy: 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

Full upstream document by @tbxark · README.md · snapshot cea86d3

mail2telegram


English | 中文

Receive email in Telegram: instant push notifications plus a Mini App inbox.

Deploy to Cloudflare

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.

Telegram Mini App: push notification, inbox, message reader and iPad split view

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, Summary and Open.
  • 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)
  1. Preview shows the plain text body directly in the chat, limited to 4096 characters.
  2. Summary appears when a summary backend is enabled in Settings (Workers AI, or an OpenAI-compatible provider).
  3. Open launches 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 Size under ~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 →
No comments yet — be the first.