ZZentralit
PythonintermediateRecipeUpdated 20 Jan 2025

Gmail label to Slack channel

When a Gmail message hits a specific label, post a compact summary into a Slack channel — including sender, subject, and a deep link.

#gmail#slack#notifications#oauth

The problem

Your team triages a shared support@ inbox. When a message is labelled urgent, you want it surfaced in #support-urgent on Slack within a minute — with the original still available in Gmail so nothing gets lost.

This recipe polls Gmail every 60 seconds, picks up newly-labelled messages, and posts them to an incoming Slack webhook. It remembers which message IDs it has already posted in a tiny JSON file so restarts are safe.

Install

pip install google-api-python-client google-auth google-auth-oauthlib requests

Set two environment variables:

  • SLACK_WEBHOOK_URL — an Incoming Webhook from Slack
  • GMAIL_CREDENTIALS_JSON — path to your OAuth client secrets file

The script

import json
import os
import time
from pathlib import Path
import requests
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

SCOPES       = ["https://www.googleapis.com/auth/gmail.readonly"]
STATE_FILE   = Path("seen.json")
LABEL_NAME   = "urgent"
POLL_SECONDS = 60
SLACK_URL    = os.environ["SLACK_WEBHOOK_URL"]


def gmail_service():
    token = Path("token.json")
    if token.exists():
        creds = Credentials.from_authorized_user_file(str(token), SCOPES)
    else:
        flow = InstalledAppFlow.from_client_secrets_file(
            os.environ["GMAIL_CREDENTIALS_JSON"], SCOPES
        )
        creds = flow.run_local_server(port=0)
        token.write_text(creds.to_json())
    return build("gmail", "v1", credentials=creds)


def load_seen() -> set[str]:
    if STATE_FILE.exists():
        return set(json.loads(STATE_FILE.read_text()))
    return set()


def save_seen(seen: set[str]) -> None:
    STATE_FILE.write_text(json.dumps(sorted(seen)))


def label_id(svc, name: str) -> str:
    for lbl in svc.users().labels().list(userId="me").execute().get("labels", []):
        if lbl["name"].lower() == name.lower():
            return lbl["id"]
    raise RuntimeError(f"label {name!r} not found")


def header(msg, name: str) -> str:
    for h in msg["payload"]["headers"]:
        if h["name"].lower() == name.lower():
            return h["value"]
    return ""


def post_to_slack(msg) -> None:
    subject = header(msg, "Subject") or "(no subject)"
    sender  = header(msg, "From") or "(unknown)"
    url     = f"https://mail.google.com/mail/u/0/#all/{msg['id']}"
    payload = {
        "text": f":rotating_light: *{subject}*\nfrom `{sender}`\n<{url}|Open in Gmail>",
    }
    r = requests.post(SLACK_URL, json=payload, timeout=10)
    r.raise_for_status()


def main():
    svc  = gmail_service()
    lid  = label_id(svc, LABEL_NAME)
    seen = load_seen()
    print(f"watching label {LABEL_NAME!r} — known: {len(seen)}")

    while True:
        try:
            resp = svc.users().messages().list(
                userId="me", labelIds=[lid], maxResults=25,
            ).execute()
            for meta in resp.get("messages", []):
                mid = meta["id"]
                if mid in seen:
                    continue
                full = svc.users().messages().get(userId="me", id=mid, format="metadata").execute()
                post_to_slack(full)
                seen.add(mid)
                save_seen(seen)
                print(f"  posted {mid}")
        except Exception as exc:
            print(f"!! poll error: {exc}")
        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    main()

Security notes

  • Use a service account with domain-wide delegation if you need to read a shared mailbox rather than your own. Personal OAuth tokens are fine for single-user triage.
  • The Slack webhook URL is a credential — store it in a secret manager, not in git.
  • This is read-only (gmail.readonly). If you want to auto-archive after posting, upgrade to gmail.modify and call messages.modify to remove the label.

Extensions

  • Parse the body and attach a 300-char preview.
  • Route by keywords: #urgent-finance vs #urgent-support.
  • Move from polling to Gmail’s push notifications via Pub/Sub for sub-second latency.