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:

  1. Scheduled Trigger - GitHub Actions runs on a schedule (e.g., every 15 minutes)
  2. Gmail Authentication - Authenticate using OAuth 2.0 credentials stored as GitHub secrets
  3. Email Filtering - Query Gmail for messages with specific labels
  4. Label Management - Exclude processed emails, add labels to mark processing status
  5. Email Retrieval - Fetch full email content including subject, body, sender, and metadata
  6. Issue Creation - Create GitHub Issues with email content
  7. Copilot Assignment - Assign newly created issues to @copilot for AI-powered handling

Prerequisites

Before implementing this workflow, you’ll need:

  1. Gmail Account with API access enabled
  2. GitHub Repository where issues will be created
  3. Google Cloud Project for Gmail API credentials
  4. GitHub Personal Access Token or GitHub App for issue creation
  5. 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:

  1. Go to Google Cloud Console
  2. Click “Create Project”
  3. Name your project (e.g., “GitHub Actions Gmail Integration”)
  4. 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:

  1. Go to “APIs & Services” → “Credentials”
  2. Click “Create Credentials” → “OAuth client ID”
  3. 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
  4. Application type: “Desktop app” or “Web application”
  5. Name: “GitHub Actions Gmail Client”
  6. 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:

  1. Open your browser for Google authentication
  2. Ask you to authorize the application
  3. Generate and display a refresh token
  4. 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:

  1. Go to your GitHub repository
  2. Navigate to Settings → Secrets and variables → Actions
  3. Click “New repository secret”
  4. Add three secrets:
    • GMAIL_CLIENT_ID: Your OAuth client ID
    • GMAIL_CLIENT_SECRET: Your OAuth client secret
    • GMAIL_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:

  1. Open Gmail
  2. Click Settings (gear icon) → “See all settings”
  3. Go to “Labels” tab
  4. Click “Create new label”
  5. 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 only
  • full - Complete message including headers and body
  • raw - 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:

  1. Personal Access Token (PAT) - Simple authentication for personal repositories
  2. GitHub App - Better for organization-wide automation
  3. 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:

  1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
  2. Click “Generate new token”
  3. Select scopes: repo (for private repos) or public_repo (for public repos)
  4. 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

  1. Never Commit Secrets

    • Use GitHub Secrets for all credentials
    • Add *.json to .gitignore for credential files
    • Rotate tokens regularly
  2. Minimal Permissions

    • Gmail: Only request gmail.modify scope
    • GitHub: Use GITHUB_TOKEN with minimal permissions
    • Review OAuth scopes regularly
  3. Token Management

    • Use refresh tokens, not access tokens
    • Implement token expiration handling
    • Monitor token usage
  4. 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_TOKEN instead 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: write permission
  • 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

  1. Gmail API provides robust access to email with powerful filtering capabilities
  2. Label-based filtering enables sophisticated email processing workflows
  3. GitHub Actions provides reliable scheduled execution
  4. GitHub Copilot can analyze and help process issues automatically
  5. Secure credential management is critical for production deployments
  6. Error handling and logging ensure reliable operation
  7. 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


This automated workflow represents a modern approach to task management, bridging email communication with structured issue tracking and AI-powered assistance.