Gmail Inbox Router: Every Decision Gets a Receipt

An invoice arrives, but nobody knows who picked it up. A refund request lands beside it, and a keyword-based automation sends both to the same channel. The problem is not getting Gmail into Telegram; it is making a narrow routing decision, stopping on exceptions, and recording whether the alert was actually delivered.
Here’s exactly how I’d build that router for a small business: a Gmail label selects candidate messages, an Apps Script trigger evaluates explicit rules, Telegram receives a short notification, and a restricted log keeps a receipt for every attempt. It does not reply to customers, approve invoices, or update payment details.
Start with two routes and a stop rule
A useful inbox router needs fewer categories than you might think. Route messages that match a known invoice pattern or a known lead-form pattern; send sensitive, unfamiliar, or ambiguous messages to a person. Apply the stop rule before either routine route.
For a first deployment, create a Gmail label named automation-inbox and a filter that applies it only to test messages or a small set of expected senders. Create a second label, needs-human-review. The first label selects candidates; it does not declare them safe.
Set up a Telegram bot with two private destinations:
| Destination | What belongs there |
|---|---|
| Routine alerts | Matches for the approved invoice or lead-form rules |
| Human review | Refunds, disputes, payment-detail changes, unexpected attachments, and anything unmatched |
Keep clients separate. A bot that posts alerts from multiple client mailboxes into one shared chat creates an access-control problem, even if the routing code is correct.
The decision order is the important part:
- Stop rule: Check the subject and a limited body preview for refund requests, disputes, legal requests, or changed payment details. Review any attachment type the workflow has not explicitly been designed to handle.
- Invoice rule: Require both an approved sender domain and an expected invoice subject pattern.
- Lead rule: Require a known lead-form sender and the expected subject format.
- Default: Send everything else to human review.
A sender domain is a routing hint, not proof that an email is authentic. Forwarding and spoofing exist. In particular, an “invoice” email asking for new bank details must never become a routine payment instruction because it matched a vendor name.
This design is deliberately narrower than an AI agent that interprets every message. There is no model here to infer intent from an ambiguous email or to talk itself past the stop rule. Once the rules and receipts work, you can evaluate whether a model adds value for suggesting categories on the review queue—without granting it authority to bypass that queue.
Use message IDs, not thread labels, as receipts
Gmail groups replies into threads, but the unit you are routing is an individual message. If an invoice thread receives a new reply tomorrow saying “please use our new account,” marking the thread processed today must not suppress tomorrow’s review.
An Apps Script time-driven trigger can run the router every five minutes. On each run, search the automation-inbox label, inspect messages in matching threads, and check each Gmail message ID against the decision log. A completed delivery for that ID means skip it. A failed or pending delivery means it still needs attention.
The receipt should answer two separate questions: What did the router decide? and What happened when it tried to notify someone?
| Field | Example | Why it matters |
|---|---|---|
| Timestamp | 2026-09-27T14:05:00Z |
Establishes when the attempt happened |
| Message ID | Gmail’s message ID | Distinguishes replies within a thread |
| Matched rule | invoice, lead, stop, unmatched |
Explains the decision |
| Intended destination | routine or review |
Shows where the alert was meant to go |
| Delivery status | PENDING, DELIVERED, FAILED |
Separates classification from delivery |
| Error | HTTP 429 |
Makes failures actionable without storing the email |
For a low-volume setup, a restricted Google Sheet is enough for this append-only log. Don’t copy email bodies, attachment contents, bot tokens, or Telegram chat IDs into it. Use a database when the log grows beyond what is practical to scan on each run.
There is a subtle concurrency issue: two scheduled executions can overlap, read the same message as unfinished, and send two alerts. Google describes Apps Script’s Lock Service as a way to “prevent concurrent access to sections of code.” Hold a script lock while checking the receipt, deciding, sending, and recording the result. The lock prevents overlapping runs of this script from making the same decision simultaneously; it cannot make Gmail, Sheets, and Telegram one atomic transaction.
Build the router without giving it customer-facing permissions
The code below is a small-volume blueprint, not a drop-in configuration. Replace the example senders and subject patterns with formats you have tested. It sends notifications only; it never sends a customer email or changes an invoice or CRM record.
Create a Sheet with a tab named Decisions and this header row:
timestamp | message_id | rule | destination | status | error
In Apps Script → Project Settings → Script properties, set LOG_SHEET_ID, TELEGRAM_BOT_TOKEN, ROUTINE_CHAT_ID, and REVIEW_CHAT_ID. Keep project edit access restricted: script properties keep secrets out of the source and Sheet, but they are not a vault against project editors.
const INBOX_LABEL = 'automation-inbox';
const REVIEW_LABEL = 'needs-human-review';
function routeInbox() {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const props = PropertiesService.getScriptProperties();
const sheet = SpreadsheetApp
.openById(props.getProperty('LOG_SHEET_ID'))
.getSheetByName('Decisions');
if (!sheet) throw new Error('Decisions sheet not found');
// This full-sheet scan is suitable for a small log, not high volume.
const rows = sheet.getDataRange().getValues();
const latest = new Map();
const attempts = new Map();
for (const row of rows.slice(1)) {
const id = String(row[1]);
if (!id) continue;
latest.set(id, String(row[4]));
if (row[4] === 'FAILED') {
attempts.set(id, (attempts.get(id) || 0) + 1);
}
}
const inbox = GmailApp.getUserLabelByName(INBOX_LABEL);
const review = GmailApp.getUserLabelByName(REVIEW_LABEL);
if (!inbox || !review) throw new Error('Required Gmail label missing');
// Search returns threads. Examine every message in each thread.
const threads = GmailApp.search(
'label:' + INBOX_LABEL + ' newer_than:7d', 0, 50
);
for (const thread of threads) {
for (const message of thread.getMessages()) {
const id = message.getId();
if (latest.get(id) === 'DELIVERED') continue;
const decision = classify(message);
const destination = decision.route === 'review'
? 'review' : 'routine';
// Record intent before the network call.
receipt(sheet, id, decision.rule, destination, 'PENDING', '');
if (destination === 'review') thread.addLabel(review);
const alert = [
decision.route === 'review'
? 'Needs human review'
: decision.route === 'invoice'
? 'Invoice queue'
: 'Lead queue',
'Rule: ' + decision.rule,
'Open in Gmail: ' + thread.getPermalink()
].join('\n');
try {
sendTelegram(
props.getProperty('TELEGRAM_BOT_TOKEN'),
props.getProperty(
destination === 'review'
? 'REVIEW_CHAT_ID' : 'ROUTINE_CHAT_ID'
),
alert
);
receipt(sheet, id, decision.rule, destination,
'DELIVERED', '');
latest.set(id, 'DELIVERED');
} catch (error) {
const reason = String(error.message).slice(0, 100);
receipt(sheet, id, decision.rule, destination,
'FAILED', reason);
latest.set(id, 'FAILED');
const failures = (attempts.get(id) || 0) + 1;
attempts.set(id, failures);
if (failures === 3) {
// Configure this as a monitored internal address.
MailApp.sendEmail(
'ops@example.com',
'Inbox router: three failed deliveries',
'Check message ID ' + id + ' in the decision log.'
);
}
}
}
}
} finally {
lock.releaseLock();
}
}
function classify(message) {
const subject = message.getSubject().toLowerCase();
// Only the first 300 characters influence routing. Apps Script still
// retrieves the plain-text body; do not mistake this for partial fetching.
const preview = message.getPlainBody().slice(0, 300).toLowerCase();
const sender = message.getFrom().toLowerCase();
if (/\b(refund|dispute|legal request|new bank details|change.*payment details)\b/
.test(subject + ' ' + preview)) {
return { route: 'review', rule: 'stop-sensitive' };
}
// Until attachment handling is explicitly designed, review all of them.
if (message.getAttachments({ includeInlineImages: false }).length > 0) {
return { route: 'review', rule: 'stop-attachment' };
}
if (/<[^>]+@approved-vendor\.example>/.test(sender) &&
/^invoice\b/.test(subject)) {
return { route: 'invoice', rule: 'approved-invoice' };
}
if (/<[^>]+@forms\.example>/.test(sender) &&
/^new lead:/.test(subject)) {
return { route: 'lead', rule: 'known-lead-form' };
}
return { route: 'review', rule: 'unmatched' };
}
function sendTelegram(token, chatId, text) {
if (!token || !chatId) throw new Error('Missing Telegram configuration');
const response = UrlFetchApp.fetch(
'https://api.telegram.org/bot' + token + '/sendMessage',
{
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ chat_id: chatId, text: text }),
muteHttpExceptions: true
}
);
let result;
try {
result = JSON.parse(response.getContentText());
} catch (_) {
throw new Error('Telegram returned invalid JSON');
}
if (response.getResponseCode() !== 200 || result.ok !== true) {
throw new Error('Telegram HTTP ' + response.getResponseCode());
}
}
function receipt(sheet, id, rule, destination, status, error) {
sheet.appendRow([
new Date().toISOString(), id, rule, destination, status, error
]);
}
Create one time-driven trigger for routeInbox at a five-minute interval. Authorize the script under the mailbox account that should read the messages, and check its requested permissions before approving it.
The example deliberately leaves subject lines and body text out of Telegram. A Gmail permalink is useful to someone who already has mailbox access; posting it does not grant a Telegram member access to Gmail. If you later add subjects to alerts, first check whether your subjects contain customer information. Review who can access both Telegram chats, the Sheet, and the Apps Script project.
One operational wrinkle: the code reviews all messages with attachments. Many real invoices arrive as PDFs. That is a safe starting behavior, not a permanent invoice policy. Add attachment-specific rules only after you have defined acceptable sources, file types, scanning, and what the alert is allowed to reveal.
Test the decisions and the failed-delivery path
A four-message batch can verify the branches, but it cannot establish an accuracy rate or a time-savings claim. Run these cases through a test filter first, then inspect both the Telegram destinations and the Sheet:
| Test message | Expected decision |
|---|---|
Approved vendor; subject Invoice April; no attachment |
Invoice alert |
Known form sender; subject New lead: consultation |
Lead alert |
Approved vendor; subject Invoice refund—use new bank details |
Human review; stop rule wins |
| Unfamiliar sender; general question | Human review; no allowlist match |
Then test delivery failure. Temporarily use an invalid chat ID in the test configuration. The log should show PENDING followed by FAILED, not DELIVERED. Restore the chat ID and rerun: the same message ID should become eligible for another attempt. The Gmail review label is not evidence that a Telegram notification arrived; the delivery receipt is.
A successful Telegram API response confirms that Telegram accepted the request. It does not prove a person saw or acted on the alert. There is also an unavoidable ambiguity when a request times out: Telegram might have received it even though Apps Script did not receive a response. Retrying can create a duplicate notification. Put the Gmail message ID in a production alert if reviewers need to recognize duplicates, and design the downstream process so duplicate alerts do not cause duplicate payments or replies.
The example’s newer_than:7d search is a bounded work window, not a retention policy. If delivery is broken long enough for a message to age out, this script will not find it automatically. In production, monitor pending and failed receipts independently, alert an operations address through a channel other than Telegram, and run a controlled backfill after an outage. A Telegram 429, server error, bad chat ID, and expired bot token do not all have the same remedy; the log should tell an operator what failed without storing message contents.
Before widening the Gmail filter, check Apps Script quotas and limits against your mailbox volume. The full-sheet scan, 50-thread search batch, and lock held across the network call are intentional simplifications for a small workflow. Higher volume needs pagination, a more efficient state store, and clearer retry scheduling.
Decide what this router is allowed to do next
The safe boundary is notification, not action. The router can say “this looks like an invoice” or “a person must review this,” but it must not approve a charge, change bank details, reply to a lead, or treat text inside an email as instructions for the automation.
That boundary matters even without an AI model. Emails are untrusted input. A sender can write “ignore previous rules” in the body, forge a familiar-looking display name, or put a routine keyword beside an urgent exception. Explicit rule order, narrow sender-and-format matches, and a default review route keep those messages from silently becoming approved work.
If you later add a customer reply or CRM update, build it as a separate workflow with its own permissions, validation, and approval step. Do not quietly expand the Gmail router’s authority because its first four test messages landed in the right chats.
The measure of success is not how many emails avoided human review. It is whether routine messages reached the right queue, exceptions stopped, and every message has a traceable outcome—including failed deliveries.
Why bizflowai.io helps with this
At bizflowai.io, we build small-business workflow automation that connects inbox triage with the people responsible for follow-up, while keeping exception handling and delivery records visible. This Gmail-to-Telegram router is one component of that work: a narrow decision point with a receipt, not an agent pretending every email is safe to act on.
Want more like this?
I publish practical AI automation, GenAI engineering, and faceless content workflows on YouTube every week.
Subscribe to bizflowai.io on YouTube — never miss a new tutorial.
Planning an AI automation project or need a second opinion on your architecture?
Connect with me on LinkedIn — Lazar Milicevic, GenAI Engineer & bizflowai.io Founder.
Visit bizflowai.io for our services, case studies, and AI consulting.
Frequently asked questions
What is the Gmail-to-Telegram email router?
It is a narrow triage system for selected Gmail messages. A Gmail label marks messages for evaluation, a scheduled Apps Script checks explicit rules, and Telegram receives a short invoice, lead, or human-review alert. A decision log records the outcome. The router does not reply to customers, update invoices, or change CRM records.
How do I set up the email router for a first test?
Create an automation-inbox Gmail label and use a filter to apply it to a test address or small set of senders, rather than your whole inbox. Create a needs-human-review label, a Telegram bot, and separate private destinations for routine alerts and review. Store the bot token and chat IDs in Apps Script properties, not in source code or a shared spreadsheet.
When should an email go to human review instead of an invoice or lead alert?
Send a message to human review if its subject or limited preview indicates a refund, dispute, legal request, changed payment details, or another sensitive exception. Attachments the router has not been designed and tested to handle also require review. Only approved vendor-domain messages matching the invoice subject pattern and known lead-form messages matching the expected format receive routine alerts. Everything else goes to review.
Why does the router check message IDs instead of thread labels?
The Apps Script trigger checks whether each message ID already has a completed decision before applying routing rules. A thread label alone is not enough for deduplication because a new reply can arrive in a thread that was previously processed. The script also acquires a lock while handling a message and records its decision in a log.
Why should Telegram alerts contain limited email information?
The example invoice alert includes a queue name, vendor, subject, and link to open the message in Gmail, but leaves out the body and attachment. If subjects contain customer data, the subject should be omitted too. A Gmail link does not grant a Telegram user mailbox access. For multiple clients, alerts should not be combined in one shared chat; mailboxes, destinations, and access controls should be separate.