Automating Email-to-Issue Workflows: Accessing Gmail from GitHub Actions and Creating GitHub Issues
READER BEWARE: THE FOLLOWING WAS WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.
Introduction
Email remains a critical communication channel in modern business workflows, but manually processing emails to create actionable tasks is time-consuming and error-prone. What if you could automatically scan your Gmail inbox, filter relevant messages, and transform them into tracked GitHub Issues—all without manual intervention?
This comprehensive guide demonstrates how to build an automated email-to-issue workflow using GitHub Actions to access Gmail, filter messages based on labels, and create GitHub Issues that can be assigned to GitHub Copilot for AI-powered task management.
Why Automate Email Processing?
Common Use Cases:
- Support Ticket Creation - Convert customer emails into tracked issues
- Bug Report Ingestion - Automatically file bug reports from email notifications
- Task Management - Transform action items from emails into trackable work items
- Alert Processing - Convert monitoring alerts into actionable issues
- Feature Request Tracking - Capture feature requests from stakeholder emails
- Code Review Requests - Create issues for email-based code review requests
Benefits:
- ✅ Eliminate Manual Work - No more copying email content into issue trackers
- ✅ Never Miss Important Emails - Automated scanning ensures nothing falls through the cracks
- ✅ Consistent Formatting - Standardized issue creation from email content
- ✅ Fast Response Times - Issues created immediately when emails arrive
- ✅ Centralized Tracking - All work items in one place (GitHub Issues)
- ✅ AI-Powered Assignment - Leverage GitHub Copilot for intelligent task handling
System Architecture
The automated email-to-issue workflow consists of several components working together:
┌─────────────────────────────────────────────────────────────────┐
│ Email-to-Issue Automation │
│ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ GitHub │ │ Gmail API │ │
│ │ Actions │─────▶│ Client │ │
│ │ Workflow │ │ (OAuth 2.0) │ │
│ └────────────────┘ └───────┬────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ │ │ Gmail Inbox │ │
│ │ │ Label Filter │ │
│ │ └───────┬────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ │ │ Email Parser │ │
│ │ │ & Processor │ │
│ │ └───────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ GitHub Issues API Client │ │
│ │ (Create, Label, Assign) │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ GitHub Issue │ │
│ │ Assignment to │ │
│ │ @copilot │ │
│ └────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
Workflow Steps:
- Scheduled Trigger - GitHub Actions runs on a schedule (e.g., every 15 minutes)
- Gmail Authentication - Authenticate using OAuth 2.0 credentials stored as GitHub secrets
- Email Filtering - Query Gmail for messages with specific labels
- Label Management - Exclude processed emails, add labels to mark processing status
- Email Retrieval - Fetch full email content including subject, body, sender, and metadata
- Issue Creation - Create GitHub Issues with email content
- Copilot Assignment - Assign newly created issues to @copilot for AI-powered handling
Prerequisites
Before implementing this workflow, you’ll need:
- Gmail Account with API access enabled
- GitHub Repository where issues will be created
- Google Cloud Project for Gmail API credentials
- GitHub Personal Access Token or GitHub App for issue creation
- Basic Knowledge of YAML, Python, and GitHub Actions
Part 1: Setting Up Gmail API Access
Step 1: Create Google Cloud Project
First, set up a Google Cloud project to access the Gmail API:
- Go to Google Cloud Console
- Click “Create Project”
- Name your project (e.g., “GitHub Actions Gmail Integration”)
- Click “Create”
Step 2: Enable Gmail API
# Navigate to your project in Google Cloud Console
# Then enable the Gmail API:
1. Go to "APIs & Services" → "Library"
2. Search for "Gmail API"
3. Click "Gmail API"
4. Click "Enable"
Step 3: Create OAuth 2.0 Credentials
For GitHub Actions to access Gmail, you need OAuth 2.0 credentials:
- Go to “APIs & Services” → “Credentials”
- Click “Create Credentials” → “OAuth client ID”
- Configure the OAuth consent screen (if prompted):
- User Type: External (for personal Gmail) or Internal (for Google Workspace)
- Add scopes:
https://www.googleapis.com/auth/gmail.modify
- Application type: “Desktop app” or “Web application”
- Name: “GitHub Actions Gmail Client”
- Download the credentials JSON file
Step 4: Generate Refresh Token
You’ll need to generate a refresh token for long-term access. Here’s a Python script to obtain it:
#!/usr/bin/env python3
"""
Generate Gmail API Refresh Token for GitHub Actions
This script performs the OAuth flow and outputs a refresh token
"""
from google_auth_oauthlib.flow import InstalledAppFlow
import json
# Gmail API scopes - modify for reading and labeling
SCOPES = ['https://www.googleapis.com/auth/gmail.modify']
def generate_refresh_token(credentials_file):
"""Generate refresh token from OAuth credentials"""
# Create the flow using the client secrets file
flow = InstalledAppFlow.from_client_secrets_file(
credentials_file,
SCOPES
)
# Run the local server for OAuth flow
credentials = flow.run_local_server(port=0)
# Extract the refresh token
refresh_token = credentials.refresh_token
client_id = credentials.client_id
client_secret = credentials.client_secret
print("\n" + "="*60)
print("SUCCESS! Save these values as GitHub Secrets:")
print("="*60)
print(f"\nGMAIL_CLIENT_ID: {client_id}")
print(f"\nGMAIL_CLIENT_SECRET: {client_secret}")
print(f"\nGMAIL_REFRESH_TOKEN: {refresh_token}")
print("\n" + "="*60)
return {
'client_id': client_id,
'client_secret': client_secret,
'refresh_token': refresh_token
}
if __name__ == '__main__':
# Path to your downloaded credentials.json
credentials_file = 'credentials.json'
print("Starting OAuth flow...")
print("Your browser will open for authentication.")
tokens = generate_refresh_token(credentials_file)
# Optionally save to file
with open('gmail_tokens.json', 'w') as f:
json.dump(tokens, f, indent=2)
print("\nTokens saved to gmail_tokens.json")
print("DO NOT commit this file to git!")
Installation Requirements:
pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client
Run the script:
python generate_refresh_token.py
This will:
- Open your browser for Google authentication
- Ask you to authorize the application
- Generate and display a refresh token
- Save credentials to
gmail_tokens.json
⚠️ Security Warning: Never commit credentials.json or gmail_tokens.json to version control!
Step 5: Store Credentials as GitHub Secrets
Add the generated credentials to your GitHub repository secrets:
- Go to your GitHub repository
- Navigate to Settings → Secrets and variables → Actions
- Click “New repository secret”
- Add three secrets:
GMAIL_CLIENT_ID: Your OAuth client IDGMAIL_CLIENT_SECRET: Your OAuth client secretGMAIL_REFRESH_TOKEN: Your refresh token
Part 2: Filtering Gmail Messages with Labels
Understanding Gmail Labels
Gmail labels are like folders but more flexible—a single email can have multiple labels. Common label strategies:
- Inbox Organization:
work/urgent,personal,notifications - Processing Status:
needs-action,processed,archived - Source Tracking:
github,jira,customer-support - Priority Levels:
p0-critical,p1-high,p2-medium,p3-low
Creating and Managing Labels
Labels can be created manually in Gmail or programmatically via the API.
Manual Creation:
- Open Gmail
- Click Settings (gear icon) → “See all settings”
- Go to “Labels” tab
- Click “Create new label”
- Name it (e.g.,
github-actions/to-process)
Programmatic Creation:
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
def create_label(service, label_name):
"""Create a Gmail label if it doesn't exist"""
# Check if label exists
results = service.users().labels().list(userId='me').execute()
labels = results.get('labels', [])
for label in labels:
if label['name'] == label_name:
print(f"Label '{label_name}' already exists")
return label['id']
# Create new label
label_object = {
'name': label_name,
'messageListVisibility': 'show',
'labelListVisibility': 'labelShow'
}
created_label = service.users().labels().create(
userId='me',
body=label_object
).execute()
print(f"Created label '{label_name}' with ID: {created_label['id']}")
return created_label['id']
Filtering Messages by Label
The Gmail API provides powerful query syntax for filtering messages:
def get_messages_with_label(service, label_name, max_results=10):
"""Retrieve messages with a specific label"""
# Find label ID
results = service.users().labels().list(userId='me').execute()
labels = results.get('labels', [])
label_id = None
for label in labels:
if label['name'] == label_name:
label_id = label['id']
break
if not label_id:
print(f"Label '{label_name}' not found")
return []
# Query messages with label
query = f'label:{label_name}'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=max_results
).execute()
messages = results.get('messages', [])
print(f"Found {len(messages)} messages with label '{label_name}'")
return messages
Excluding Messages with Specific Labels
To process only unprocessed emails, exclude those already marked as processed:
def get_unprocessed_messages(service, include_label, exclude_label):
"""Get messages with include_label but without exclude_label"""
# Build query: has include_label AND NOT exclude_label
query = f'label:{include_label} -label:{exclude_label}'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=50
).execute()
messages = results.get('messages', [])
print(f"Found {len(messages)} unprocessed messages")
return messages
Example Queries:
# Messages labeled "to-process" but not "processed"
query = 'label:to-process -label:processed'
# Messages labeled "urgent" excluding "archived"
query = 'label:urgent -label:archived'
# Multiple conditions
query = 'label:support label:high-priority -label:resolved'
# With date filter
query = 'label:to-process -label:processed newer_than:1d'
# From specific sender
query = 'from:alerts@example.com label:monitoring -label:acknowledged'
Adding Labels to Processed Messages
After processing an email, mark it with a label to prevent reprocessing:
def add_label_to_message(service, message_id, label_name):
"""Add a label to a message"""
# Get or create label
label_id = get_or_create_label_id(service, label_name)
# Add label to message
service.users().messages().modify(
userId='me',
id=message_id,
body={'addLabelIds': [label_id]}
).execute()
print(f"Added label '{label_name}' to message {message_id}")
def get_or_create_label_id(service, label_name):
"""Get label ID or create if doesn't exist"""
results = service.users().labels().list(userId='me').execute()
labels = results.get('labels', [])
# Find existing label
for label in labels:
if label['name'] == label_name:
return label['id']
# Create new label
label_object = {
'name': label_name,
'messageListVisibility': 'show',
'labelListVisibility': 'labelShow'
}
created_label = service.users().labels().create(
userId='me',
body=label_object
).execute()
return created_label['id']
Complete Label Management Example
def process_emails_with_labels(service, process_label='to-process',
processed_label='processed'):
"""
Complete workflow for processing emails with label management
"""
# Get unprocessed messages
query = f'label:{process_label} -label:{processed_label}'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=50
).execute()
messages = results.get('messages', [])
if not messages:
print("No messages to process")
return
print(f"Processing {len(messages)} messages...")
for message in messages:
message_id = message['id']
try:
# Get full message details
msg = service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
# Extract email content (shown in next section)
email_data = parse_email(msg)
# Process the email (e.g., create GitHub issue)
process_email(email_data)
# Mark as processed
add_label_to_message(service, message_id, processed_label)
# Optionally remove from "to-process" label
remove_label_from_message(service, message_id, process_label)
print(f"✓ Processed message {message_id}")
except Exception as e:
print(f"✗ Failed to process message {message_id}: {e}")
# Optionally add "error" label
add_label_to_message(service, message_id, 'processing-error')
def remove_label_from_message(service, message_id, label_name):
"""Remove a label from a message"""
label_id = get_label_id(service, label_name)
if not label_id:
return
service.users().messages().modify(
userId='me',
id=message_id,
body={'removeLabelIds': [label_id]}
).execute()
def get_label_id(service, label_name):
"""Get label ID by name"""
results = service.users().labels().list(userId='me').execute()
labels = results.get('labels', [])
for label in labels:
if label['name'] == label_name:
return label['id']
return None
Part 3: Retrieving Individual Email Items
Understanding Gmail Message Format
Gmail messages are retrieved in different formats:
minimal- Message ID and thread ID onlyfull- Complete message including headers and bodyraw- Raw MIME message (base64 encoded)metadata- Message metadata and headers only
Fetching Full Email Content
import base64
from email.mime.text import MIMEText
def get_message_details(service, message_id):
"""Retrieve full details of a specific message"""
message = service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
return message
def parse_email(message):
"""Parse Gmail message into structured data"""
headers = message['payload']['headers']
# Extract common headers
subject = get_header(headers, 'Subject')
sender = get_header(headers, 'From')
to = get_header(headers, 'To')
date = get_header(headers, 'Date')
message_id_header = get_header(headers, 'Message-ID')
# Extract body
body = extract_body(message['payload'])
# Extract labels
label_ids = message.get('labelIds', [])
email_data = {
'id': message['id'],
'thread_id': message['threadId'],
'subject': subject,
'from': sender,
'to': to,
'date': date,
'message_id': message_id_header,
'body': body,
'labels': label_ids,
'snippet': message.get('snippet', ''),
'internal_date': message.get('internalDate')
}
return email_data
def get_header(headers, name):
"""Extract header value by name"""
for header in headers:
if header['name'].lower() == name.lower():
return header['value']
return None
def extract_body(payload):
"""Extract email body from payload"""
# Handle multipart messages
if 'parts' in payload:
parts = payload['parts']
# Try to find text/plain or text/html
for part in parts:
mime_type = part.get('mimeType', '')
if mime_type == 'text/plain':
return decode_body(part['body'])
elif mime_type == 'text/html':
# Prefer plain text, but use HTML if no plain text found
html_body = decode_body(part['body'])
# Could convert HTML to plain text here if needed
return html_body
elif 'parts' in part:
# Recursive for nested parts
nested_body = extract_body(part)
if nested_body:
return nested_body
# Single part message
if 'body' in payload and 'data' in payload['body']:
return decode_body(payload['body'])
return ""
def decode_body(body_data):
"""Decode base64url encoded body"""
if 'data' not in body_data:
return ""
data = body_data['data']
# Decode base64url
decoded_bytes = base64.urlsafe_b64decode(data)
# Decode to string
try:
decoded_text = decoded_bytes.decode('utf-8')
except UnicodeDecodeError:
decoded_text = decoded_bytes.decode('latin-1')
return decoded_text
Handling Different Email Types
def extract_email_with_attachments(service, message_id):
"""Extract email including attachment metadata"""
message = service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
email_data = parse_email(message)
# Extract attachments
attachments = []
if 'parts' in message['payload']:
for part in message['payload']['parts']:
if part.get('filename'):
attachment_info = {
'filename': part['filename'],
'mime_type': part['mimeType'],
'size': part['body'].get('size', 0),
'attachment_id': part['body'].get('attachmentId')
}
attachments.append(attachment_info)
email_data['attachments'] = attachments
return email_data
def download_attachment(service, message_id, attachment_id):
"""Download an attachment"""
attachment = service.users().messages().attachments().get(
userId='me',
messageId=message_id,
id=attachment_id
).execute()
file_data = base64.urlsafe_b64decode(attachment['data'])
return file_data
Batch Processing Multiple Messages
def batch_retrieve_messages(service, message_ids):
"""Efficiently retrieve multiple messages"""
from googleapiclient.http import BatchHttpRequest
messages = []
def callback(request_id, response, exception):
if exception is not None:
print(f"Error retrieving message: {exception}")
else:
messages.append(response)
# Create batch request
batch = service.new_batch_http_request(callback=callback)
for message_id in message_ids:
batch.add(service.users().messages().get(
userId='me',
id=message_id,
format='full'
))
# Execute batch
batch.execute()
return messages
Complete Email Retrieval Example
#!/usr/bin/env python3
"""
Complete example: Retrieve and parse Gmail messages
"""
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
import base64
def build_gmail_service(client_id, client_secret, refresh_token):
"""Build Gmail API service"""
credentials = Credentials.from_authorized_user_info({
'client_id': client_id,
'client_secret': client_secret,
'refresh_token': refresh_token,
'token_uri': 'https://oauth2.googleapis.com/token'
})
service = build('gmail', 'v1', credentials=credentials)
return service
def retrieve_unprocessed_emails(service, label='to-process'):
"""Main function to retrieve unprocessed emails"""
print(f"Retrieving emails with label '{label}'...")
# Query for messages
query = f'label:{label} -label:processed'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=10
).execute()
messages = results.get('messages', [])
if not messages:
print("No unprocessed messages found")
return []
print(f"Found {len(messages)} messages to process")
# Retrieve full details for each message
email_data_list = []
for msg in messages:
message_id = msg['id']
print(f"\nProcessing message {message_id}...")
# Get full message
full_message = service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
# Parse email
email_data = parse_email(full_message)
# Display summary
print(f" From: {email_data['from']}")
print(f" Subject: {email_data['subject']}")
print(f" Date: {email_data['date']}")
print(f" Body preview: {email_data['snippet'][:100]}...")
email_data_list.append(email_data)
return email_data_list
if __name__ == '__main__':
# Load credentials from environment or config
import os
CLIENT_ID = os.getenv('GMAIL_CLIENT_ID')
CLIENT_SECRET = os.getenv('GMAIL_CLIENT_SECRET')
REFRESH_TOKEN = os.getenv('GMAIL_REFRESH_TOKEN')
# Build service
service = build_gmail_service(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
# Retrieve emails
emails = retrieve_unprocessed_emails(service, label='github-actions/to-process')
print(f"\n{'='*60}")
print(f"Successfully retrieved {len(emails)} emails")
print(f"{'='*60}")
Part 4: Creating GitHub Issues from Email Content
GitHub Issues API Overview
GitHub provides a REST API for creating and managing issues. You can use:
- Personal Access Token (PAT) - Simple authentication for personal repositories
- GitHub App - Better for organization-wide automation
- GITHUB_TOKEN - Built-in token in GitHub Actions (recommended)
Setting Up GitHub Authentication
For GitHub Actions, use the built-in GITHUB_TOKEN:
permissions:
issues: write
contents: read
For external scripts, create a Personal Access Token:
- Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
- Click “Generate new token”
- Select scopes:
repo(for private repos) orpublic_repo(for public repos) - Copy the token
Creating Issues with Python
import requests
import json
def create_github_issue(owner, repo, title, body, labels=None, assignees=None, token=None):
"""
Create a GitHub issue
Args:
owner: Repository owner (username or org)
repo: Repository name
title: Issue title
body: Issue body (markdown supported)
labels: List of label names
assignees: List of assignee usernames
token: GitHub personal access token or GITHUB_TOKEN
Returns:
Issue number if successful, None otherwise
"""
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28"
}
data = {
"title": title,
"body": body
}
if labels:
data["labels"] = labels
if assignees:
data["assignees"] = assignees
response = requests.post(url, headers=headers, json=data)
if response.status_code == 201:
issue = response.json()
print(f"✓ Created issue #{issue['number']}: {issue['title']}")
return issue['number']
else:
print(f"✗ Failed to create issue: {response.status_code}")
print(f" Response: {response.text}")
return None
Formatting Email Content for Issues
def format_email_as_issue(email_data):
"""Convert email data into GitHub issue format"""
# Create title from email subject
title = f"[Email] {email_data['subject']}"
# Limit title length
if len(title) > 256:
title = title[:253] + "..."
# Format body with email metadata
body = f"""## Email Details
**From:** {email_data['from']}
**To:** {email_data['to']}
**Date:** {email_data['date']}
**Message ID:** {email_data.get('message_id', 'N/A')}
---
## Email Content
{email_data['body']}
---
*This issue was automatically created from an email by GitHub Actions.*
*Original email ID: `{email_data['id']}`*
"""
return title, body
def create_issue_from_email(email_data, owner, repo, token, default_labels=None):
"""Create GitHub issue from email data"""
# Format email as issue
title, body = format_email_as_issue(email_data)
# Determine labels
labels = default_labels or []
# Could add logic to extract labels from email subject/body
# e.g., if "bug" in subject, add "bug" label
subject_lower = email_data['subject'].lower()
if 'bug' in subject_lower:
labels.append('bug')
elif 'feature' in subject_lower or 'request' in subject_lower:
labels.append('enhancement')
elif 'urgent' in subject_lower or 'critical' in subject_lower:
labels.append('priority:high')
# Add email source label
labels.append('source:email')
# Create issue
issue_number = create_github_issue(
owner=owner,
repo=repo,
title=title,
body=body,
labels=labels,
token=token
)
return issue_number
Advanced Issue Creation Features
def create_issue_with_template(email_data, owner, repo, token):
"""Create issue using a template format"""
# Parse email to extract structured data
# This example looks for key-value pairs in email body
title = f"[Email] {email_data['subject']}"
# Try to extract structured info
body_lines = email_data['body'].split('\n')
# Build structured issue body
body = f"""## Issue Information
**Source:** Email from {email_data['from']}
**Received:** {email_data['date']}
## Description
{email_data['body']}
## Metadata
- Email ID: `{email_data['id']}`
- Thread ID: `{email_data['thread_id']}`
- Snippet: {email_data.get('snippet', '')}
## Checklist
- [ ] Review email content
- [ ] Determine action items
- [ ] Assign to appropriate team member
- [ ] Add relevant labels
- [ ] Link to related issues if applicable
---
*Auto-generated from email. Reply to this issue, not the original email.*
"""
issue_number = create_github_issue(
owner=owner,
repo=repo,
title=title,
body=body,
labels=['email-automation', 'needs-triage'],
token=token
)
return issue_number
def create_issue_with_attachments(email_data, owner, repo, token):
"""Create issue and note attachments"""
title, body = format_email_as_issue(email_data)
# Add attachment information
if email_data.get('attachments'):
attachment_list = "\n".join([
f"- `{att['filename']}` ({att['mime_type']}, {att['size']} bytes)"
for att in email_data['attachments']
])
body += f"\n\n## Attachments\n\n{attachment_list}\n"
body += "\n*Note: Attachments are not uploaded. Access them in the original email.*\n"
issue_number = create_github_issue(
owner=owner,
repo=repo,
title=title,
body=body,
labels=['email-automation', 'has-attachments'],
token=token
)
return issue_number
Using PyGithub for Issue Creation
For a more Pythonic approach, use the PyGithub library:
from github import Github
def create_issue_with_pygithub(email_data, owner, repo, token):
"""Create GitHub issue using PyGithub library"""
# Initialize GitHub client
g = Github(token)
repository = g.get_repo(f"{owner}/{repo}")
# Format issue
title, body = format_email_as_issue(email_data)
# Create issue
issue = repository.create_issue(
title=title,
body=body,
labels=['email-automation', 'needs-triage']
)
print(f"✓ Created issue #{issue.number}: {issue.title}")
print(f" URL: {issue.html_url}")
return issue.number
Part 5: Assigning Issues to GitHub Copilot
Understanding GitHub Copilot for Issues
GitHub Copilot can be assigned to issues just like a regular user. When assigned, Copilot can:
- Analyze the issue and suggest solutions
- Generate code to address the problem
- Create pull requests with proposed fixes
- Provide context and explanations
Assigning Issues via API
def assign_issue_to_copilot(owner, repo, issue_number, token):
"""
Assign a GitHub issue to @copilot
Args:
owner: Repository owner
repo: Repository name
issue_number: Issue number to assign
token: GitHub token
"""
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}"
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28"
}
# Assign to copilot
data = {
"assignees": ["copilot"]
}
response = requests.patch(url, headers=headers, json=data)
if response.status_code == 200:
print(f"✓ Assigned issue #{issue_number} to @copilot")
return True
else:
print(f"✗ Failed to assign issue: {response.status_code}")
print(f" Response: {response.text}")
return False
def create_and_assign_issue(email_data, owner, repo, token):
"""Create issue from email and assign to Copilot"""
# Create issue
issue_number = create_issue_from_email(email_data, owner, repo, token)
if issue_number:
# Assign to Copilot
assign_issue_to_copilot(owner, repo, issue_number, token)
return issue_number
return None
Using PyGithub for Assignment
def assign_issue_with_pygithub(owner, repo, issue_number, token):
"""Assign issue to Copilot using PyGithub"""
g = Github(token)
repository = g.get_repo(f"{owner}/{repo}")
# Get the issue
issue = repository.get_issue(issue_number)
# Get copilot user
copilot = g.get_user("copilot")
# Assign
issue.add_to_assignees(copilot)
print(f"✓ Assigned issue #{issue_number} to @copilot")
Adding Instructions for Copilot
To help Copilot understand what to do, add specific instructions in the issue body:
def create_issue_with_copilot_instructions(email_data, owner, repo, token):
"""Create issue with instructions for Copilot"""
title, base_body = format_email_as_issue(email_data)
# Add Copilot-specific instructions
copilot_instructions = """
## Instructions for @copilot
This issue was automatically created from an email. Please:
1. **Analyze the email content** and determine if it's a:
- Bug report → Apply `bug` label and create a reproduction checklist
- Feature request → Apply `enhancement` label and outline implementation steps
- Question → Apply `question` label and research the answer
- Other → Apply `needs-triage` label
2. **Extract action items** from the email and add them as checklist items
3. **Suggest next steps** based on the content
4. **Identify related issues** if any exist in the repository
Thank you!
"""
body = base_body + copilot_instructions
# Create issue
issue_number = create_github_issue(
owner=owner,
repo=repo,
title=title,
body=body,
labels=['email-automation', 'copilot-task'],
assignees=['copilot'],
token=token
)
return issue_number
Complete Workflow: Email to Copilot Issue
#!/usr/bin/env python3
"""
Complete workflow: Process emails and create Copilot-assigned issues
"""
def process_email_to_copilot_issue(service, email_label, owner, repo, github_token):
"""
Main workflow function
Args:
service: Gmail API service
email_label: Gmail label to filter emails
owner: GitHub repository owner
repo: GitHub repository name
github_token: GitHub authentication token
"""
print("=" * 70)
print("Email to GitHub Copilot Issue Automation")
print("=" * 70)
print()
# Step 1: Retrieve unprocessed emails
print("Step 1: Retrieving unprocessed emails...")
query = f'label:{email_label} -label:processed'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=10
).execute()
messages = results.get('messages', [])
if not messages:
print(" No messages to process")
return
print(f" Found {len(messages)} messages")
print()
# Step 2: Process each email
processed_count = 0
failed_count = 0
for msg in messages:
message_id = msg['id']
try:
print(f"Processing message {message_id}...")
# Retrieve full message
full_message = service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
# Parse email
email_data = parse_email(full_message)
print(f" Subject: {email_data['subject']}")
print(f" From: {email_data['from']}")
# Create GitHub issue
print(f" Creating GitHub issue...")
issue_number = create_issue_with_copilot_instructions(
email_data, owner, repo, github_token
)
if issue_number:
print(f" ✓ Created issue #{issue_number}")
# Mark email as processed
add_label_to_message(service, message_id, 'processed')
print(f" ✓ Marked email as processed")
processed_count += 1
else:
print(f" ✗ Failed to create issue")
failed_count += 1
except Exception as e:
print(f" ✗ Error: {e}")
failed_count += 1
# Add error label
try:
add_label_to_message(service, message_id, 'processing-error')
except:
pass
print()
# Step 3: Summary
print("=" * 70)
print("Processing Complete")
print("=" * 70)
print(f"Processed successfully: {processed_count}")
print(f"Failed: {failed_count}")
print(f"Total: {len(messages)}")
print()
if __name__ == '__main__':
import os
# Gmail credentials
GMAIL_CLIENT_ID = os.getenv('GMAIL_CLIENT_ID')
GMAIL_CLIENT_SECRET = os.getenv('GMAIL_CLIENT_SECRET')
GMAIL_REFRESH_TOKEN = os.getenv('GMAIL_REFRESH_TOKEN')
# GitHub credentials
GITHUB_TOKEN = os.getenv('GITHUB_TOKEN')
GITHUB_OWNER = os.getenv('GITHUB_REPOSITORY_OWNER')
GITHUB_REPO = os.getenv('GITHUB_REPOSITORY_NAME')
# Build Gmail service
gmail_service = build_gmail_service(
GMAIL_CLIENT_ID,
GMAIL_CLIENT_SECRET,
GMAIL_REFRESH_TOKEN
)
# Process emails
process_email_to_copilot_issue(
service=gmail_service,
email_label='github-actions/to-process',
owner=GITHUB_OWNER,
repo=GITHUB_REPO,
github_token=GITHUB_TOKEN
)
Part 6: GitHub Actions Workflow
Complete Workflow Configuration
Create .github/workflows/email-to-issue.yml:
name: Email to GitHub Issue Automation
on:
# Run every 15 minutes
schedule:
- cron: '*/15 * * * *'
# Allow manual triggering
workflow_dispatch:
permissions:
issues: write
contents: read
jobs:
process-emails:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install google-auth google-auth-oauthlib google-auth-httplib2
pip install google-api-python-client
pip install PyGithub
pip install requests
- name: Process Gmail to GitHub Issues
env:
GMAIL_CLIENT_ID: ${{ secrets.GMAIL_CLIENT_ID }}
GMAIL_CLIENT_SECRET: ${{ secrets.GMAIL_CLIENT_SECRET }}
GMAIL_REFRESH_TOKEN: ${{ secrets.GMAIL_REFRESH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
run: |
python scripts/email_to_issue.py
- name: Summary
if: always()
run: |
echo "Email processing workflow completed"
echo "Check logs above for details"
Python Script for GitHub Actions
Create scripts/email_to_issue.py:
#!/usr/bin/env python3
"""
Email to GitHub Issue Automation Script
For use in GitHub Actions
"""
import os
import sys
import base64
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from github import Github
def build_gmail_service():
"""Build Gmail API service from environment variables"""
client_id = os.environ['GMAIL_CLIENT_ID']
client_secret = os.environ['GMAIL_CLIENT_SECRET']
refresh_token = os.environ['GMAIL_REFRESH_TOKEN']
credentials = Credentials.from_authorized_user_info({
'client_id': client_id,
'client_secret': client_secret,
'refresh_token': refresh_token,
'token_uri': 'https://oauth2.googleapis.com/token'
})
return build('gmail', 'v1', credentials=credentials)
def get_header(headers, name):
"""Extract header value by name"""
for header in headers:
if header['name'].lower() == name.lower():
return header['value']
return None
def extract_body(payload):
"""Extract email body from payload"""
if 'parts' in payload:
for part in payload['parts']:
if part.get('mimeType') == 'text/plain':
if 'data' in part['body']:
return base64.urlsafe_b64decode(part['body']['data']).decode('utf-8')
if 'body' in payload and 'data' in payload['body']:
return base64.urlsafe_b64decode(payload['body']['data']).decode('utf-8')
return ""
def parse_email(message):
"""Parse Gmail message"""
headers = message['payload']['headers']
return {
'id': message['id'],
'subject': get_header(headers, 'Subject'),
'from': get_header(headers, 'From'),
'to': get_header(headers, 'To'),
'date': get_header(headers, 'Date'),
'body': extract_body(message['payload']),
'snippet': message.get('snippet', '')
}
def get_or_create_label_id(service, label_name):
"""Get or create Gmail label"""
results = service.users().labels().list(userId='me').execute()
labels = results.get('labels', [])
for label in labels:
if label['name'] == label_name:
return label['id']
# Create label
label_object = {
'name': label_name,
'messageListVisibility': 'show',
'labelListVisibility': 'labelShow'
}
created = service.users().labels().create(
userId='me',
body=label_object
).execute()
return created['id']
def add_label_to_message(service, message_id, label_name):
"""Add label to message"""
label_id = get_or_create_label_id(service, label_name)
service.users().messages().modify(
userId='me',
id=message_id,
body={'addLabelIds': [label_id]}
).execute()
def main():
"""Main execution function"""
print("=" * 70)
print("Email to GitHub Issue Automation")
print("=" * 70)
# Initialize services
print("\nInitializing services...")
gmail_service = build_gmail_service()
github_token = os.environ['GITHUB_TOKEN']
owner = os.environ['GITHUB_REPOSITORY_OWNER']
repo = os.environ['GITHUB_REPOSITORY_NAME']
g = Github(github_token)
repository = g.get_repo(f"{owner}/{repo}")
# Query emails
print("Querying Gmail...")
query = 'label:github-actions/to-process -label:processed'
results = gmail_service.users().messages().list(
userId='me',
q=query,
maxResults=10
).execute()
messages = results.get('messages', [])
if not messages:
print("No messages to process")
return 0
print(f"Found {len(messages)} messages to process\n")
# Process each message
processed = 0
failed = 0
for msg in messages:
message_id = msg['id']
try:
# Get full message
full_msg = gmail_service.users().messages().get(
userId='me',
id=message_id,
format='full'
).execute()
# Parse
email_data = parse_email(full_msg)
print(f"Processing: {email_data['subject']}")
print(f" From: {email_data['from']}")
# Create issue
title = f"[Email] {email_data['subject']}"
if len(title) > 256:
title = title[:253] + "..."
body = f"""## Email Details
**From:** {email_data['from']}
**To:** {email_data['to']}
**Date:** {email_data['date']}
---
## Content
{email_data['body']}
---
*Auto-created from email `{email_data['id']}`*
## Instructions for @copilot
Please analyze this email and:
1. Determine the type (bug, feature, question, etc.)
2. Extract action items
3. Suggest next steps
"""
issue = repository.create_issue(
title=title,
body=body,
labels=['email-automation', 'copilot-task'],
assignees=['copilot']
)
print(f" ✓ Created issue #{issue.number}")
# Mark as processed
add_label_to_message(gmail_service, message_id, 'processed')
print(f" ✓ Marked as processed\n")
processed += 1
except Exception as e:
print(f" ✗ Error: {e}\n")
failed += 1
try:
add_label_to_message(gmail_service, message_id, 'processing-error')
except:
pass
# Summary
print("=" * 70)
print(f"Processed: {processed}")
print(f"Failed: {failed}")
print("=" * 70)
return 0 if failed == 0 else 1
if __name__ == '__main__':
sys.exit(main())
Testing the Workflow
# Test locally with environment variables
export GMAIL_CLIENT_ID="your_client_id"
export GMAIL_CLIENT_SECRET="your_client_secret"
export GMAIL_REFRESH_TOKEN="your_refresh_token"
export GITHUB_TOKEN="your_github_token"
export GITHUB_REPOSITORY_OWNER="your_username"
export GITHUB_REPOSITORY_NAME="your_repo"
python scripts/email_to_issue.py
Best Practices and Security
Security Best Practices
Never Commit Secrets
- Use GitHub Secrets for all credentials
- Add
*.jsonto.gitignorefor credential files - Rotate tokens regularly
Minimal Permissions
- Gmail: Only request
gmail.modifyscope - GitHub: Use
GITHUB_TOKENwith minimal permissions - Review OAuth scopes regularly
- Gmail: Only request
Token Management
- Use refresh tokens, not access tokens
- Implement token expiration handling
- Monitor token usage
Rate Limiting
- Respect Gmail API quotas (250 quota units/user/second)
- Implement exponential backoff for retries
- Don’t process more emails than necessary
Performance Optimization
# Batch API requests where possible
from googleapiclient.http import BatchHttpRequest
def batch_process_messages(service, message_ids):
"""Process multiple messages in batch"""
messages = []
def callback(request_id, response, exception):
if response:
messages.append(response)
batch = service.new_batch_http_request(callback=callback)
for msg_id in message_ids:
batch.add(service.users().messages().get(
userId='me',
id=msg_id,
format='full'
))
batch.execute()
return messages
Error Handling and Logging
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def safe_process_email(email_data, owner, repo, token):
"""Process email with comprehensive error handling"""
try:
issue_number = create_issue_from_email(email_data, owner, repo, token)
if issue_number:
logger.info(f"Successfully created issue #{issue_number}")
return True
else:
logger.error("Failed to create issue - no issue number returned")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Network error creating issue: {e}")
return False
except Exception as e:
logger.exception(f"Unexpected error: {e}")
return False
Monitoring and Alerting
# Add to workflow for Slack notifications
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK }}
payload: |
{
"text": "Email to Issue workflow failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "❌ Email processing workflow failed\nRepo: ${{ github.repository }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
}
]
}
Advanced Features
Filtering by Email Sender
def get_emails_from_sender(service, sender_email, label='to-process'):
"""Get emails from specific sender"""
query = f'from:{sender_email} label:{label} -label:processed'
results = service.users().messages().list(
userId='me',
q=query,
maxResults=50
).execute()
return results.get('messages', [])
Smart Label Classification
def classify_email_for_labels(email_data):
"""Automatically determine labels based on email content"""
labels = ['email-automation']
subject = email_data['subject'].lower()
body = email_data['body'].lower()
# Bug detection
if any(word in subject + body for word in ['bug', 'error', 'issue', 'broken']):
labels.append('bug')
# Feature request
if any(word in subject + body for word in ['feature', 'enhancement', 'request', 'suggestion']):
labels.append('enhancement')
# Urgency
if any(word in subject for word in ['urgent', 'critical', 'asap', 'emergency']):
labels.append('priority:high')
# Question
if '?' in subject or 'question' in subject.lower():
labels.append('question')
return labels
Duplicate Detection
def check_for_duplicate_issue(repository, email_data):
"""Check if issue already exists for this email"""
# Search issues by email ID in body
query = f'repo:{repository.full_name} "{email_data["id"]}" in:body'
issues = repository.search_issues(query=query)
if issues.totalCount > 0:
print(f"Duplicate found: Issue #{issues[0].number}")
return issues[0]
return None
Troubleshooting
Common Issues
1. Gmail API Authentication Error
Error: invalid_grant
- Refresh token may have expired
- Regenerate refresh token using the OAuth flow
- Check that OAuth consent screen is configured correctly
2. GitHub API Rate Limiting
Error: API rate limit exceeded
- Use
GITHUB_TOKENinstead of PAT (higher limits) - Implement exponential backoff
- Reduce workflow frequency
3. Label Not Found
Error: Label 'to-process' not found
- Ensure label exists in Gmail
- Create label programmatically or manually
- Check label name spelling
4. Permission Denied
Error: 403 Forbidden
- Verify GitHub token has
issues: writepermission - Check repository access
- Confirm workflow permissions are set
Debug Mode
# Enable debug logging
import logging
logging.basicConfig(level=logging.DEBUG)
# Gmail API debug
import googleapiclient.discovery
import googleapiclient.http
googleapiclient.http.LOGGER.setLevel(logging.DEBUG)
Conclusion
Automating email-to-issue workflows with GitHub Actions, Gmail API, and GitHub Copilot creates a powerful system for capturing and tracking work items. This integration eliminates manual data entry, ensures consistent issue creation, and leverages AI to help process and prioritize tasks.
Key Takeaways
- ✅ Gmail API provides robust access to email with powerful filtering capabilities
- ✅ Label-based filtering enables sophisticated email processing workflows
- ✅ GitHub Actions provides reliable scheduled execution
- ✅ GitHub Copilot can analyze and help process issues automatically
- ✅ Secure credential management is critical for production deployments
- ✅ Error handling and logging ensure reliable operation
- ✅ Batch processing improves performance for high-volume scenarios
Next Steps
- Extend filtering logic - Add more sophisticated email classification
- Implement bi-directional sync - Update emails when issues are closed
- Add attachment handling - Upload email attachments to GitHub
- Create custom issue templates - Standardize issue format
- Build dashboards - Visualize email processing metrics
- Add multi-repository support - Route emails to different repos
- Implement approval workflows - Review before creating issues
Resources
- Gmail API Documentation
- GitHub REST API Documentation
- GitHub Actions Documentation
- PyGithub Documentation
- Google API Python Client
- OAuth 2.0 for Python
- GitHub Copilot Documentation
This automated workflow represents a modern approach to task management, bridging email communication with structured issue tracking and AI-powered assistance.