Gmail CLI for Claude Code - multi-account Gmail access via command line A developer created a Gmail CLI tool for Claude Code that provides multi-account Gmail access via the command line. The tool uses the Google Gmail API directly and supports multiple accounts through separate credential files, with OAuth flow for authentication. | /usr/bin/env python3 | | | """ | | | Gmail CLI tool for Claude Code skill. | | | Supports multiple Gmail accounts via separate credential files. | | | Uses the Google Gmail API directly no third-party wrappers . | | | """ | | | import argparse | | | import base64 | | | import json | | | import os | | | import sys | | | from pathlib import Path | | | Config directory for storing credentials | | | CONFIG DIR = Path.home / ".claude" / "skills" / "gmail" / "config" | | | Account aliases mapping to credential files | | | Customize these with your own email addresses | | | ACCOUNTS = { | | | "personal": "you@gmail.com", | | | "work": "you@company.com", | | | "business": "you@business.io", | | | } | | | def resolve account account: str : | | | """Resolve account alias to email and safe filename.""" | | | email = ACCOUNTS.get account, account | | | safe name = email.replace "@", " at " .replace ".", " " | | | return email, safe name | | | def run oauth flow client secret file: str, token file: str, email: str : | | | """Perform OAuth flow and save token as plain JSON.""" | | | from google auth oauthlib.flow import InstalledAppFlow | | | SCOPES = 'https://mail.google.com/' | | | print f"\nAuthenticating: {email}", file=sys.stderr | | | print f"A browser window will open — sign in with: {email}\n", file=sys.stderr | | | flow = InstalledAppFlow.from client secrets file client secret file, SCOPES | | | creds = flow.run local server port=0 | | | Save as plain JSON compatible with get service | | | token data = { | | | "access token": creds.token, | | | "refresh token": creds.refresh token, | | | "token uri": creds.token uri or "https://oauth2.googleapis.com/token", | | | "client id": creds.client id, | | | "client secret": creds.client secret, | | | } | | | with open token file, "w" as f: | | | json.dump token data, f, indent=2 | | | print f"Token saved for {email}\n", file=sys.stderr | | | def get service account: str : | | | """Get a Gmail API service client for the given account. | | | Triggers OAuth flow if no token exists yet.""" | | | from google.oauth2.credentials import Credentials | | | from googleapiclient.discovery import build | | | email, safe name = resolve account account | | | client secret = CONFIG DIR / f"client secret {safe name}.json" | | | token path = CONFIG DIR / f"token {safe name}.json" | | | if not client secret.exists : | | | print f"ERROR: No credentials found for {email}", file=sys.stderr | | | print f"Expected: {client secret}", file=sys.stderr | | | print f"\nRun 'gmail cli.py setup' for instructions.", file=sys.stderr | | | sys.exit 1 | | | if not token path.exists : | | | run oauth flow str client secret , str token path , email | | | with open token path as f: | | | td = json.load f | | | creds = Credentials | | | token=td.get "access token" , | | | refresh token=td.get "refresh token" , | | | token uri=td.get "token uri", "https://oauth2.googleapis.com/token" , | | | client id=td.get "client id" , | | | client secret=td.get "client secret" , | | | | | | return build "gmail", "v1", credentials=creds, cache discovery=False | | | def extract body payload : | | | """Extract text/plain body from a Gmail API message payload.""" | | | if payload.get "mimeType" == "text/plain": | | | data = payload "body" .get "data", "" | | | if data: | | | return base64.urlsafe b64decode data .decode "utf-8", errors="replace" | | | for part in payload.get "parts", : | | | result = extract body part | | | if result: | | | return result | | | return "" | | | def extract html payload : | | | """Extract text/html body from a Gmail API message payload.""" | | | if payload.get "mimeType" == "text/html": | | | data = payload "body" .get "data", "" | | | if data: | | | return base64.urlsafe b64decode data .decode "utf-8", errors="replace" | | | for part in payload.get "parts", : | | | result = extract html part | | | if result: | | | return result | | | return "" | | | def extract attachments payload, attachments=None : | | | """Extract attachment filenames from a Gmail API message payload.""" | | | if attachments is None: | | | attachments = | | | filename = payload.get "filename" | | | if filename: | | | attachments.append filename | | | for part in payload.get "parts", : | | | extract attachments part, attachments | | | return attachments | | | def cmd list args : | | | """List recent emails.""" | | | service = get service args.account | | | Build query | | | query parts = "in:inbox" | | | if args.unread: | | | query parts.append "is:unread" | | | if args.starred: | | | query parts = "is:starred" | | | if args.label: | | | query parts = f"label:{args.label}" | | | query parts.append "newer than:30d" | | | query = " ".join query parts | | | try: | | | result = service.users .messages .list userId="me", q=query, maxResults=args.limit .execute | | | except Exception as e: | | | print f"ERROR: List failed: {e}", file=sys.stderr | | | sys.exit 1 | | | msg refs = result.get "messages", | | | if args.json: | | | output = | | | for ref in msg refs: | | | msg = service.users .messages .get userId="me", id=ref "id" , format="metadata", | | | metadataHeaders= "From", "To", "Subject", "Date" .execute | | | headers = {h "name" : h "value" for h in msg.get "payload", {} .get "headers", } | | | labels = msg.get "labelIds", | | | output.append { | | | "id": msg "id" , | | | "thread id": msg "threadId" , | | | "from": headers.get "From", "" , | | | "to": headers.get "To", "" , | | | "subject": headers.get "Subject", "" , | | | "date": headers.get "Date", "" , | | | "snippet": msg.get "snippet", "" , | | | "labels": labels, | | | "unread": "UNREAD" in labels, | | | } | | | print json.dumps output, indent=2, default=str | | | else: | | | for ref in msg refs: | | | msg = service.users .messages .get userId="me", id=ref "id" , format="metadata", | | | metadataHeaders= "From", "Subject", "Date" .execute | | | headers = {h "name" : h "value" for h in msg.get "payload", {} .get "headers", } | | | labels = msg.get "labelIds", | | | status = " UNREAD " if "UNREAD" in labels else "" | | | print f"{status} {headers.get 'Date', '?' } | From: {headers.get 'From', '?' }" | | | print f" Subject: {headers.get 'Subject', '?' }" | | | print f" ID: {msg 'id' }" | | | print | | | def cmd read args : | | | """Read a specific email by ID.""" | | | service = get service args.account | | | try: | | | msg = service.users .messages .get userId="me", id=args.message id, format="full" .execute | | | except Exception as e: | | | print f"ERROR: Could not read message {args.message id}: {e}", file=sys.stderr | | | sys.exit 1 | | | headers = {h "name" : h "value" for h in msg "payload" "headers" } | | | body plain = extract body msg "payload" | | | body html = extract html msg "payload" | | | attachments = extract attachments msg "payload" | | | if args.json: | | | output = { | | | "id": msg "id" , | | | "thread id": msg "threadId" , | | | "from": headers.get "From", "" , | | | "to": headers.get "To", "" , | | | "cc": headers.get "Cc", "" , | | | "subject": headers.get "Subject", "" , | | | "date": headers.get "Date", "" , | | | "body plain": body plain, | | | "body html": body html, | | | "attachments": attachments, | | | } | | | print json.dumps output, indent=2, default=str | | | else: | | | print f"From: {headers.get 'From', '?' }" | | | print f"To: {headers.get 'To', '?' }" | | | cc = headers.get "Cc", "" | | | if cc: | | | print f"CC: {cc}" | | | print f"Date: {headers.get 'Date', '?' }" | | | print f"Subject: {headers.get 'Subject', '?' }" | | | print "-" 60 | | | print body plain or body html or " no body " | | | if attachments: | | | print "-" 60 | | | print f"Attachments: {', '.join attachments }" | | | def cmd search args : | | | """Search emails with Gmail query syntax.""" | | | service = get service args.account | | | try: | | | result = service.users .messages .list userId="me", q=args.query, maxResults=args.limit .execute | | | except Exception as e: | | | print f"ERROR: Search failed: {e}", file=sys.stderr | | | sys.exit 1 | | | msg refs = result.get "messages", | | | if args.json: | | | output = | | | for ref in msg refs: | | | msg = service.users .messages .get userId="me", id=ref "id" , format="metadata", | | | metadataHeaders= "From", "Subject", "Date" .execute | | | headers = {h "name" : h "value" for h in msg.get "payload", {} .get "headers", } | | | output.append { | | | "id": msg "id" , | | | "from": headers.get "From", "" , | | | "subject": headers.get "Subject", "" , | | | "date": headers.get "Date", "" , | | | "snippet": msg.get "snippet", "" , | | | } | | | print json.dumps output, indent=2, default=str | | | else: | | | print f"Found {len msg refs } messages matching: {args.query}\n" | | | for ref in msg refs: | | | msg = service.users .messages .get userId="me", id=ref "id" , format="metadata", | | | metadataHeaders= "From", "Subject", "Date" .execute | | | headers = {h "name" : h "value" for h in msg.get "payload", {} .get "headers", } | | | print f"{headers.get 'Date', '?' } | From: {headers.get 'From', '?' }" | | | print f" Subject: {headers.get 'Subject', '?' }" | | | print f" ID: {msg 'id' }" | | | print | | | def cmd send args : | | | """Send an email.""" | | | import email.mime.text | | | import email.mime.multipart | | | import email.mime.base | | | import mimetypes | | | service = get service args.account | | | sender = ACCOUNTS.get args.account, args.account | | | Read body from stdin if needed | | | body = args.body | | | if body == "-": | | | body = sys.stdin.read | | | if args.attachments: | | | msg = email.mime.multipart.MIMEMultipart | | | if args.html: | | | msg.attach email.mime.text.MIMEText body, "html" | | | else: | | | msg.attach email.mime.text.MIMEText body, "plain" | | | for filepath in args.attachments: | | | content type, = mimetypes.guess type filepath | | | if content type is None: | | | content type = "application/octet-stream" | | | main type, sub type = content type.split "/", 1 | | | with open filepath, "rb" as f: | | | attachment = email.mime.base.MIMEBase main type, sub type | | | attachment.set payload f.read | | | import email.encoders | | | email.encoders.encode base64 attachment | | | attachment.add header "Content-Disposition", "attachment", filename=os.path.basename filepath | | | msg.attach attachment | | | else: | | | if args.html: | | | msg = email.mime.text.MIMEText body, "html" | | | else: | | | msg = email.mime.text.MIMEText body, "plain" | | | msg "to" = args.to | | | msg "from" = sender | | | msg "subject" = args.subject | | | if args.cc: | | | msg "cc" = args.cc | | | if args.bcc: | | | msg "bcc" = args.bcc | | | raw = base64.urlsafe b64encode msg.as bytes .decode | | | try: | | | sent = service.users .messages .send userId="me", body={"raw": raw} .execute | | | except Exception as e: | | | print f"ERROR: Send failed: {e}", file=sys.stderr | | | sys.exit 1 | | | if args.json: | | | print json.dumps {"status": "sent", "id": sent.get "id", "unknown" }, indent=2 | | | else: | | | print f"Email sent successfully. ID: {sent.get 'id', '?' }" | | | def cmd reply args : | | | """Reply to an email.""" | | | import email.mime.text | | | service = get service args.account | | | try: | | | original = service.users .messages .get userId="me", id=args.message id, format="metadata", | | | metadataHeaders= "From", "To", "Subject", "Message-ID", "References", "In-Reply-To" .execute | | | except Exception as e: | | | print f"ERROR: Could not find message {args.message id}: {e}", file=sys.stderr | | | sys.exit 1 | | | headers = {h "name" : h "value" for h in original.get "payload", {} .get "headers", } | | | thread id = original "threadId" | | | body = args.body | | | if body == "-": | | | body = sys.stdin.read | | | reply to = headers.get "From", "" | | | subject = headers.get "Subject", "" | | | if not subject.lower .startswith "re:" : | | | subject = f"Re: {subject}" | | | message id = headers.get "Message-ID", "" | | | references = headers.get "References", "" | | | if message id: | | | references = f"{references} {message id}".strip | | | sender = ACCOUNTS.get args.account, args.account | | | msg = email.mime.text.MIMEText body | | | msg "to" = reply to | | | msg "from" = sender | | | msg "subject" = subject | | | if message id: | | | msg "In-Reply-To" = message id | | | if references: | | | msg "References" = references | | | raw = base64.urlsafe b64encode msg.as bytes .decode | | | try: | | | sent = service.users .messages .send userId="me", body={"raw": raw, "threadId": thread id} .execute | | | except Exception as e: | | | print f"ERROR: Reply failed: {e}", file=sys.stderr | | | sys.exit 1 | | | if args.json: | | | print json.dumps {"status": "sent", "id": sent.get "id", "unknown" }, indent=2 | | | else: | | | print f"Reply sent successfully. ID: {sent.get 'id', '?' }" | | | def cmd labels args : | | | """List all labels.""" | | | service = get service args.account | | | try: | | | result = service.users .labels .list userId="me" .execute | | | except Exception as e: | | | print f"ERROR: Failed to list labels: {e}", file=sys.stderr | | | sys.exit 1 | | | labels = result.get "labels", | | | if args.json: | | | output = {"id": l "id" , "name": l "name" } for l in labels | | | print json.dumps output, indent=2 | | | else: | | | for label in sorted labels, key=lambda l: l "name" : | | | print f"{label 'name' } ID: {label 'id' } " | | | def cmd mark read args : | | | """Mark message s as read.""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .modify | | | userId='me', id=msg id, | | | body={'removeLabelIds': 'UNREAD' } | | | .execute | | | print f"Marked as read: {msg id}" | | | except Exception as e: | | | print f"ERROR marking {msg id} as read: {e}", file=sys.stderr | | | def cmd mark unread args : | | | """Mark message s as unread.""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .modify | | | userId='me', id=msg id, | | | body={'addLabelIds': 'UNREAD' } | | | .execute | | | print f"Marked as unread: {msg id}" | | | except Exception as e: | | | print f"ERROR marking {msg id} as unread: {e}", file=sys.stderr | | | def cmd archive args : | | | """Archive message s by removing from inbox.""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .modify | | | userId='me', id=msg id, | | | body={'removeLabelIds': 'INBOX' } | | | .execute | | | print f"Archived: {msg id}" | | | except Exception as e: | | | print f"ERROR archiving {msg id}: {e}", file=sys.stderr | | | def cmd star args : | | | """Star message s .""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .modify | | | userId='me', id=msg id, | | | body={'addLabelIds': 'STARRED' } | | | .execute | | | print f"Starred: {msg id}" | | | except Exception as e: | | | print f"ERROR starring {msg id}: {e}", file=sys.stderr | | | def cmd unstar args : | | | """Remove star from message s .""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .modify | | | userId='me', id=msg id, | | | body={'removeLabelIds': 'STARRED' } | | | .execute | | | print f"Unstarred: {msg id}" | | | except Exception as e: | | | print f"ERROR unstarring {msg id}: {e}", file=sys.stderr | | | def cmd trash args : | | | """Move message s to trash.""" | | | service = get service args.account | | | for msg id in args.message ids: | | | try: | | | service.users .messages .trash userId='me', id=msg id .execute | | | print f"Trashed: {msg id}" | | | except Exception as e: | | | print f"ERROR trashing {msg id}: {e}", file=sys.stderr | | | def cmd accounts args : | | | """List configured accounts.""" | | | print "Configured account aliases:" | | | for alias, email in ACCOUNTS.items : | | | , safe name = resolve account alias | | | token file = CONFIG DIR / f"token {safe name}.json" | | | status = "authenticated" if token file.exists else "NOT SET UP" | | | print f" {alias}: {email} {status} " | | | def cmd setup args : | | | """Print setup instructions.""" | | | print """ | | | Gmail Skill Setup | | | ================= | | | You only need ONE Google Cloud project for all accounts. | | | 1. Go to https://console.cloud.google.com/ | | | 2. Create a project e.g. "Claude Gmail Access" | | | 3. Enable the Gmail API APIs & Services Library | | | 4. Configure OAuth consent screen: | | | - External type | | | - Add ALL email addresses as test users | | | - Add scope: https://mail.google.com/ | | | 5. Create OAuth credentials: | | | - APIs & Services Credentials Create OAuth client ID Desktop app | | | - Download the JSON file | | | 6. Copy the SAME JSON file for each account just rename : | | | """ | | | for alias, email in ACCOUNTS.items : | | | , safe name = resolve account alias | | | print f" cp client secret.json ~/.claude/skills/gmail/config/client secret {safe name}.json" | | | print """ | | | 7. Authorize each account browser opens for OAuth : | | | python3 gmail cli.py --account personal list --limit 1 | | | python3 gmail cli.py --account work list --limit 1 | | | etc. | | | 8. IMPORTANT: Publish your OAuth app APIs & Services OAuth consent screen | | | Publish App or refresh tokens expire after 7 days | | | """ | | | def main : | | | parser = argparse.ArgumentParser | | | description="Gmail CLI for Claude Code", | | | formatter class=argparse.RawDescriptionHelpFormatter | | | | | | parser.add argument | | | "--account", "-a", | | | default="personal", | | | help="Account to use: personal, work, business or full email " | | | | | | parser.add argument | | | "--json", "-j", | | | action="store true", | | | help="Output in JSON format" | | | | | | subparsers = parser.add subparsers dest="command", help="Commands" | | | List command | | | list parser = subparsers.add parser "list", help="List recent emails" | | | list parser.add argument "--limit", "-n", type=int, default=10, help="Number of emails" | | | list parser.add argument "--unread", "-u", action="store true", help="Only unread" | | | list parser.add argument "--starred", "-s", action="store true", help="Only starred" | | | list parser.add argument "--label", "-l", help="Filter by label" | | | list parser.set defaults func=cmd list | | | Read command | | | read parser = subparsers.add parser "read", help="Read a specific email" | | | read parser.add argument "message id", help="Message ID" | | | read parser.set defaults func=cmd read | | | Search command | | | search parser = subparsers.add parser "search", help="Search emails" | | | search parser.add argument "query", help="Gmail search query" | | | search parser.add argument "--limit", "-n", type=int, default=20, help="Max results" | | | search parser.set defaults func=cmd search | | | Send command | | | send parser = subparsers.add parser "send", help="Send an email" | | | send parser.add argument "--to", "-t", required=True, help="Recipient" | | | send parser.add argument "--subject", "-s", required=True, help="Subject" | | | send parser.add argument "--body", "-b", default="-", help="Body use - for stdin " | | | send parser.add argument "--cc", help="CC recipients" | | | send parser.add argument "--bcc", help="BCC recipients" | | | send parser.add argument "--html", action="store true", help="Body is HTML" | | | send parser.add argument "--attachments", nargs=" ", help="File paths to attach" | | | send parser.add argument "--no-signature", action="store true", help="Omit signature" | | | send parser.set defaults func=cmd send | | | Reply command | | | reply parser = subparsers.add parser "reply", help="Reply to an email" | | | reply parser.add argument "message id", help="Message ID to reply to" | | | reply parser.add argument "--body", "-b", default="-", help="Body use - for stdin " | | | reply parser.set defaults func=cmd reply | | | Labels command | | | labels parser = subparsers.add parser "labels", help="List all labels" | | | labels parser.set defaults func=cmd labels | | | Mark read/unread commands | | | mark read parser = subparsers.add parser "mark-read", help="Mark messages as read" | | | mark read parser.add argument "message ids", nargs="+", help="Message IDs" | | | mark read parser.set defaults func=cmd mark read | | | mark unread parser = subparsers.add parser "mark-unread", help="Mark messages as unread" | | | mark unread parser.add argument "message ids", nargs="+", help="Message IDs" | | | mark unread parser.set defaults func=cmd mark unread | | | Archive command | | | archive parser = subparsers.add parser "archive", help="Archive messages remove from inbox " | | | archive parser.add argument "message ids", nargs="+", help="Message IDs" | | | archive parser.set defaults func=cmd archive | | | Star command | | | star parser = subparsers.add parser "star", help="Star messages" | | | star parser.add argument "message ids", nargs="+", help="Message IDs" | | | star parser.set defaults func=cmd star | | | Unstar command | | | unstar parser = subparsers.add parser "unstar", help="Remove star from messages" | | | unstar parser.add argument "message ids", nargs="+", help="Message IDs" | | | unstar parser.set defaults func=cmd unstar | | | Trash command | | | trash parser = subparsers.add parser "trash", help="Move messages to trash" | | | trash parser.add argument "message ids", nargs="+", help="Message IDs" | | | trash parser.set defaults func=cmd trash | | | Accounts command | | | accounts parser = subparsers.add parser "accounts", help="List configured accounts" | | | accounts parser.set defaults func=cmd accounts | | | Setup command | | | setup parser = subparsers.add parser "setup", help="Show setup instructions" | | | setup parser.set defaults func=cmd setup | | | args = parser.parse args | | | if not args.command: | | | parser.print help | | | sys.exit 1 | | | args.func args | | | if name == " main ": | | | main |