Repository: tonykipkemboi/crewai-gmail-automation
Branch: main
Commit: 0946e1747eff
Files: 16
Total size: 72.3 KB
Directory structure:
gitextract_9jx_yv_y/
├── .env_example
├── .gitignore
├── LICENSE
├── README.md
├── knowledge/
│ └── user_preference.txt
├── pyproject.toml
└── src/
└── gmail_crew_ai/
├── __init__.py
├── config/
│ ├── agents.yaml
│ └── tasks.yaml
├── crew.py
├── main.py
├── models.py
└── tools/
├── __init__.py
├── date_tools.py
├── gmail_tools.py
└── slack_tool.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .env_example
================================================
# OpenAI Model
MODEL=openai/gpt-4o-mini
OPENAI_API_KEY=your_openai_api_key
# Gmail credentials
EMAIL_ADDRESS=your_email_address@gmail.com
APP_PASSWORD=your_app_password
# Slack Webhook URL
SLACK_WEBHOOK_URL=your_slack_webhook_url
================================================
FILE: .gitignore
================================================
# Environment variables
.env
.env.*
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
.venv/
env/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Project specific
output
*.log
# OS specific
.DS_Store
Thumbs.db
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Tony Kipkemboi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Gmail Automation with CrewAI 📧✨
[](https://www.youtube.com/@tonykipkemboi)
[](https://github.com/tonykipkemboi)
[](https://twitter.com/tonykipkemboi)
[](https://www.linkedin.com/in/tonykipkemboi/)
Gmail Automation with CrewAI is an intelligent email management system that uses AI agents to categorize, organize, respond to, and clean up your Gmail inbox automatically.

## ✨ Features



- **📋 Email Categorization**: Automatically categorizes emails into specific types (newsletters, promotions, personal, etc.)
- **🔔 Priority Assignment**: Assigns priority levels (HIGH, MEDIUM, LOW) based on content and sender with strict classification rules
- **🏷️ Smart Organization**: Applies Gmail labels and stars based on categories and priorities
- **💬 Automated Responses**: Generates draft responses for important emails that need replies
- **📱 Slack Notifications**: Sends creative notifications for high-priority emails
- **🧹 Intelligent Cleanup**: Safely deletes low-priority emails based on age and category
- **🎬 YouTube Content Protection**: Special handling for YouTube-related emails
- **🗑️ Trash Management**: Automatically empties trash to free up storage space
- **🧵 Thread Awareness**: Recognizes and properly handles email threads
## 🚀 Installation
```bash
# Clone the repository
git clone https://github.com/tonykipkemboi/crewai-gmail-automation.git
cd crewai-gmail-automation
# Create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
crewai install
```
## ⚙️ Configuration
1. Create a `.env` file in the root directory with the following variables:
```
# Choose your LLM provider
# OpenAI (Recommended)
MODEL=openai/gpt-4o-mini
OPENAI_API_KEY=your_openai_api_key
# Or Gemini
# MODEL=gemini/gemini-2.0-flash
# GEMINI_API_KEY=your_gemini_api_key
# Or Ollama (Note: May have compatibility issues with tool calling)
# Download the model from https://ollama.com/library
# MODEL=ollama/llama3-groq-tool-use # use ones that have tool calling capabilities
# Gmail credentials
EMAIL_ADDRESS=your_email@gmail.com
APP_PASSWORD=your_app_password
# Optional: Slack notifications
SLACK_WEBHOOK_URL=your_slack_webhook_url
```
<details>
<summary><b>🔑 How to create a Gmail App Password</b></summary>
1. Go to your Google Account settings at [myaccount.google.com](https://myaccount.google.com/)
2. Select **Security** from the left navigation panel
3. Under "Signing in to Google," find and select **2-Step Verification** (enable it if not already enabled)
4. Scroll to the bottom and find **App passwords**
5. Select **Mail** from the "Select app" dropdown
6. Select **Other (Custom name)** from the "Select device" dropdown
7. Enter `Gmail CrewAI` as the name
8. Click **Generate**
9. Copy the 16-character password that appears (spaces will be removed automatically)
10. Paste this password in your `.env` file as the `APP_PASSWORD` value
11. Click **Done**
**Note**: App passwords can only be created if you have 2-Step Verification enabled on your Google account.
</details>
<details>
<summary><b>🔗 How to create a Slack Webhook URL</b></summary>
1. Go to [api.slack.com/apps](https://api.slack.com/apps)
2. Click **Create New App**
3. Select **From scratch**
4. Enter `Gmail Notifications` as the app name
5. Select your workspace and click **Create App**
6. In the left sidebar, find and click on **Incoming Webhooks**
7. Toggle the switch to **Activate Incoming Webhooks**
8. Click **Add New Webhook to Workspace**
9. Select the channel where you want to receive notifications
10. Click **Allow**
11. Find the **Webhook URL** section and copy the URL that begins with `https://hooks.slack.com/services/`
12. Paste this URL in your `.env` file as the `SLACK_WEBHOOK_URL` value
**Customizing your Slack app (optional):**
1. Go to **Basic Information** in the left sidebar
2. Scroll down to **Display Information**
3. Add an app icon and description
4. Click **Save Changes**
**Note**: You need admin permissions or the ability to install apps in your Slack workspace.
</details>
## 📧 How It Works
This application uses the IMAP (Internet Message Access Protocol) to securely connect to your Gmail account and manage your emails. Here's how it works:
<details>
<summary><b>🔄 IMAP Connection Process</b></summary>
1. **Secure Connection**: The application establishes a secure SSL connection to Gmail's IMAP server (`imap.gmail.com`).
2. **Authentication**: It authenticates using your email address and app password (not your regular Google password).
3. **Mailbox Access**: Once authenticated, it can access your inbox and other mailboxes to:
- Read unread emails
- Apply labels
- Move emails to trash
- Save draft responses
4. **Safe Disconnection**: After each operation, the connection is properly closed to maintain security.
IMAP allows the application to work with your emails while they remain on Google's servers, unlike POP3 which would download them to your device. This means you can still access all emails through the regular Gmail interface.
**Security Note**: Your credentials are only stored locally in your `.env` file and are never shared with any external services.
</details>
## 🔍 Usage
Run the application with:
```bash
crewai run
```
You'll be prompted to enter the number of emails to process (default is 5).
The application will:
1. 📥 Fetch your unread emails
2. 🔎 Categorize them by type and priority
3. ⭐ Apply appropriate labels and stars
4. ✏️ Generate draft responses for important emails
5. 🔔 Send Slack notifications for high-priority items
6. 🗑️ Clean up low-priority emails based on age
7. 🧹 Empty the trash to free up storage space
## 🌟 Special Features
- **📅 Smart Deletion Rules**:
- Promotions older than 2 days are automatically deleted
- Newsletters older than 7 days (unless HIGH priority) are deleted
- Shutterfly emails are always deleted regardless of age
- Receipts and important documents are archived instead of deleted
- **🎬 YouTube Protection**: All YouTube-related emails are preserved and marked as READ_ONLY (you'll respond directly on YouTube)
- **✍️ Smart Response Generation**: Responses are tailored to the email context and include proper formatting
- **💡 Creative Slack Notifications**: Fun, attention-grabbing notifications for important emails
- **🧵 Thread Handling**: Properly tracks and manages email threads to maintain conversation context
## 👥 Contributing
Contributions are welcome! Please feel free to submit a [Pull Request](https://github.com/tonykipkemboi/crewai-gmail-automation/pulls).
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 📚 References
- [CrewAI](https://github.com/crewAIInc/crewAI/)
- [IMAP Protocol for Gmail](https://support.google.com/mail/answer/7126229)
- [Slack API](https://api.slack.com/messaging/webhooks)
- [Ollama](https://ollama.com/library)
- [Gemini](https://ai.google.com/gemini-api)
- [OpenAI](https://openai.com/api/)
================================================
FILE: knowledge/user_preference.txt
================================================
User name is John Doe.
User is an AI Engineer.
User is interested in AI Agents.
User is based in San Francisco, California.
================================================
FILE: pyproject.toml
================================================
[project]
name = "gmail_crew_ai"
version = "0.1.0"
description = "gmail-crew-ai using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.13"
dependencies = [
"bs4>=0.0.2",
"crewai[tools]>=0.102.0,<1.0.0",
]
[project.scripts]
gmail_crew_ai = "gmail_crew_ai.main:run"
run_crew = "gmail_crew_ai.main:run"
train = "gmail_crew_ai.main:train"
replay = "gmail_crew_ai.main:replay"
test = "gmail_crew_ai.main:test"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.crewai]
type = "crew"
================================================
FILE: src/gmail_crew_ai/__init__.py
================================================
================================================
FILE: src/gmail_crew_ai/config/agents.yaml
================================================
categorizer:
role: >
Email Categorizer and Prioritizer
goal: >
Analyze and categorize unread emails based on urgency, importance, and required actions,
ensuring critical communications are identified and properly prioritized.
backstory: >
You are an expert email manager with years of experience in handling high-volume inboxes.
You excel at quickly identifying urgent matters, understanding email context, and determining
appropriate priority levels. You're particularly skilled at recognizing the true importance
of messages beyond just their subject lines.
verbose: true
memory: true
organizer:
role: >
Email Organization Specialist
goal: >
Apply Gmail's organizational features (stars, labels, priority markers) consistently and
effectively based on email categorization, maintaining a well-structured and easily navigable inbox.
backstory: >
You are a Gmail power user and productivity expert who understands how to leverage Gmail's
organizational features to their fullest potential. You've helped numerous executives maintain
pristine inboxes and have developed a systematic approach to email organization that ensures
important messages are easily findable and properly highlighted.
verbose: true
memory: true
response_generator:
role: >
Email Response Draft Generator
goal: >
Create professional, context-aware email responses that effectively address the sender's needs
while maintaining appropriate tone and including all necessary information.
backstory: >
You are a skilled professional writer with extensive experience in business communication.
You excel at crafting responses that are clear, concise, and appropriate for the context.
You understand the importance of maintaining professional relationships through effective
email communication and know when to include additional resources like calendar links for meetings.
verbose: true
memory: true
notifier:
role: >
Email Notification Specialist
goal: >
Monitor important emails and send timely notifications to Slack for high and medium priority messages.
backstory: >
You are a vigilant notification specialist who ensures that important communications are never missed.
You understand which emails require immediate attention and can summarize complex information into
clear, actionable notifications. Your role is to bridge the gap between email and Slack, ensuring
that urgent matters are promptly brought to attention.
verbose: true
memory: true
cleaner:
role: >
Email Cleanup Specialist
goal: >
Identify and delete low-priority emails that are cluttering the inbox while ensuring
no important communications are lost.
backstory: >
You are a meticulous digital organizer who specializes in email management. You have a
keen eye for distinguishing between emails that need to be preserved and those that can
be safely removed. You understand the importance of maintaining a clean inbox while being
extremely careful not to delete anything of value.
verbose: true
memory: true
trash_cleaner:
role: "Email Trash Cleanup Specialist"
goal: "Empty the Gmail trash folder to free up storage space"
backstory: "You are an expert at managing email storage efficiently. Your job is to help users free up space by emptying the trash folder when needed."
================================================
FILE: src/gmail_crew_ai/config/tasks.yaml
================================================
categorization_task:
description: >
First, read the fetched emails from the file at 'output/fetched_emails.json' using the `FileReadTool`.
For each email, analyze the content and categorize as follows:
1. Category (choose one):
- NEWSLETTERS
- PROMOTIONS
- PERSONAL
- GITHUB
- YOUTUBE
- RECEIPTS_INVOICES
- OTHER
2. Priority (choose one):
IMPORTANT - BE VERY STRICT WITH PRIORITY LEVELS:
- HIGH: ONLY for urgent matters requiring immediate attention or response
- MEDIUM: ONLY for important but not urgent communications from real people
- DO NOT mark promotional content, newsletters, or automated notifications as MEDIUM
- DO NOT mark Shutterfly or other promotional emails as MEDIUM - they should be LOW
- Only actual communication from real people can be MEDIUM
- LOW
3. Required Action (choose one):
- REPLY
- READ_ONLY
- TASK
- IGNORE
IMPORTANT YOUTUBE EMAIL RULES:
- ANY email from youtube.com domains MUST be categorized as YOUTUBE
- ANY email containing "YouTube" in the subject or sender MUST be categorized as YOUTUBE
- YouTube emails about comments should be HIGH priority but require READ_ONLY action
(the user will reply directly on YouTube, not via email)
IMPORTANT FORMAT INSTRUCTIONS:
Your final answer must be a valid JSON object with these exact fields(example without the quotes):
"email_id": "The email's unique identifier",
"subject": "The email's subject line",
"sender": "The email sender",
"category": "The category you assigned",
"priority": "The priority you assigned",
"required_action": "The action needed",
"date": "The email date in YYYY-MM-DD format",
"age_days": "The age of the email in days"
Do not include any explanations, thoughts, or additional text outside the JSON object.
Do not use markdown formatting or code blocks.
Just return the raw JSON object.
expected_output: >
A JSON object with email_id, subject, category, priority, and required_action fields.
agent: categorizer
output_file: output/categorization_report.json
organization_task:
description: >
First, read the categorization report from 'output/categorization_report.json' using the `FileReadTool`.
For each email, organize it using Gmail's priority features with the 'organize_email' tool.
Use the email content, email domain, and categorization report to determine the correct labels.
Simplified Label Rules:
1. For YOUTUBE emails:
- Add "YOUTUBE" label
- Star the email
- If HIGH priority, also add "URGENT" label
2. For NEWSLETTERS emails:
- Add "NEWSLETTERS" label
- No newsletter is HIGH or MEDIUM priority
- No newsletter requires action
3. For other emails:
- Add a label matching the category (e.g., "PERSONAL", "GITHUB")
- If HIGH priority, add "URGENT" label and star the email
- If MEDIUM priority, add "ACTION_NEEDED" label
Your final answer should be a JSON object with:
- email_id: The email's ID
- subject: The email's subject
- applied_labels: List of labels applied
- starred: Whether the email was starred (true/false)
- result: "Success" or error message
expected_output: >
A detailed report of how each email was organized, including:
- The email ID and subject
- The labels that were applied
- Whether the email was starred
- The result of the organization attempt
The report should include special handling for YOUTUBE and NEWSLETTERS emails to ensure they receive
the appropriate labels and priority indicators.
agent: organizer
context: [categorization_task]
output_file: output/organization_report.json
response_task:
description: >
Based on the categorization report, generate responses ONLY for emails that require action.
Only generate responses for:
- PERSONAL emails with HIGH or MEDIUM priority
- DO NOT generate responses for ANY YouTube emails - the user will respond directly on YouTube instead
For YouTube comments:
- Thank them for their comment
- Answer their question if possible
- End with "Best regards, Tony Kipkemboi"
IMPORTANT: For the recipient field, extract the username from the comment.
For example, if the comment is from "@username commented on your video",
use "username@gmail.com" as the recipient.
Use the 'save_email_draft' tool with:
- subject: Add "Re: " to the original subject
- body: Your response
- recipient: The extracted username + "@gmail.com" or use "user@example.com" if you can't extract it
Your final answer should be a simple list of emails you responded to.
expected_output: >
A draft email saved to the drafts folder for each email that requires a response, or
"No response needed" for emails to be deleted or ignored. Each draft should follow the
specified structure and formatting guidelines. The should be in correct Markdown format without
any additional text or formatting such as "```" or "```md".
agent: response_generator
context: [categorization_task, organization_task]
output_file: output/response_report.json
notification_task:
description: >
Based on the categorization report, send Slack notifications for HIGH priority emails using the 'SlackNotificationTool'.
For each HIGH priority email:
1. Create a brief summary of the email content
2. Identify any action needed
3. Create an attention-grabbing headline
4. Add an emoji-filled intro
Use the 'SlackNotificationTool' with:
- subject: The email subject
- sender: The email sender
- category: The email category (YOUTUBE, PERSONAL, etc.)
- priority: HIGH
- summary: Your brief summary
- action_needed: What action is needed
- headline: Your custom headline
- intro: Your custom intro with emojis
Your final answer should list all notifications sent.
expected_output: >
A report of which emails had Slack notifications sent, including their subjects, priorities, and the creative elements used.
The report should be in correct Markdown format without any additional text or formatting such as "```" or "```md".
agent: notifier
context: [categorization_task]
output_file: output/notification_report.json
cleanup_task:
description: >
Based on the categorization report, identify LOW priority emails that are safe to delete.
EMAILS TO ARCHIVE INSTEAD OF DELETE:
- Receipts and Invoices (archive for tax purposes)
- Confirmation emails for purchases
- Travel-related emails with booking information
- Anything that might be needed for reference later
For these emails, use the 'GmailArchiveTool' instead of deleting them.
NEVER delete:
- ANY email marked as HIGH or MEDIUM priority
- ANY email from a personal contact
- ANY email related to YouTube
- ANY email less than 5 days old
ALWAYS delete:
- ANY email with "Shutterfly" in the sender or subject, regardless of categorization
- ANY email categorized as PROMOTIONS and older than 2 days
- ANY email categorized as NEWSLETTERS and older than 7 days, unless HIGH priority
The emails already have their age calculated in the "age_days" field.
An email is less than 5 days old if age_days < 5.
For each email:
1. Check if it meets the deletion criteria (LOW priority AND not from criteria above)
2. For emails you decide to delete, document:
- The email subject and sender
- The reason for deletion
- Use the 'GmailDeleteTool' with the email_id and reason
AFTER processing all emails for deletion:
3. IMPORTANT: Use the 'empty_gmail_trash' tool to permanently empty the trash folder
and free up storage space in the Gmail account.
WORKFLOW:
1. First, analyze each email one at a time
2. For emails that should be deleted, use delete_email tool with ONLY the required fields
3. After processing all emails, use empty_gmail_trash tool
4. Then compile your final report
Your final answer should provide a detailed report containing:
- DELETED EMAILS: For each deleted email, include email_id, subject, sender, category, priority, age, and reason for deletion
- PRESERVED EMAILS: For each preserved email, include email_id, subject, sender, category, priority, age, and reason for preservation
- TRASH STATUS: Information about whether the trash was emptied successfully and how many messages were permanently removed
Also include information about whether the trash was emptied successfully.
IMPORTANT: When using the 'delete_email' tool, ONLY provide the email_id and reason
as a simple JSON object. For example:
"email_id": "123",
"reason": "Low priority promotional email"
expected_output: >
A comprehensive deletion report with three sections:
1. DELETED EMAILS: Full details of each deleted email including subject, sender, id, category, priority, age and deletion reason
2. PRESERVED EMAILS: Full details of each preserved email including the reason for preservation
3. TRASH STATUS: Results of the trash emptying operation including number of messages permanently removed
The report should be in JSON format for easy parsing and tracking.
agent: cleaner
context: [categorization_task, organization_task]
output_file: output/cleanup_report.json
================================================
FILE: src/gmail_crew_ai/crew.py
================================================
from crewai import Agent, Crew, Process, Task, LLM
from crewai.project import CrewBase, agent, crew, task, before_kickoff
from crewai_tools import FileReadTool
import json
import os
from typing import List, Dict, Any, Callable
from pydantic import SkipValidation
from datetime import date, datetime
from gmail_crew_ai.tools.gmail_tools import GetUnreadEmailsTool, SaveDraftTool, GmailOrganizeTool, GmailDeleteTool, EmptyTrashTool
from gmail_crew_ai.tools.slack_tool import SlackNotificationTool
from gmail_crew_ai.tools.date_tools import DateCalculationTool
from gmail_crew_ai.models import CategorizedEmail, OrganizedEmail, EmailResponse, SlackNotification, EmailCleanupInfo, SimpleCategorizedEmail, EmailDetails
@CrewBase
class GmailCrewAi():
"""Crew that processes emails."""
agents_config = 'config/agents.yaml'
tasks_config = 'config/tasks.yaml'
@before_kickoff
def fetch_emails(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch emails before starting the crew and calculate ages."""
print("Fetching emails before starting the crew...")
# Get the email limit from inputs
email_limit = inputs.get('email_limit', 5)
print(f"Fetching {email_limit} emails...")
# Create the output directory if it doesn't exist
os.makedirs("output", exist_ok=True)
# Use the GetUnreadEmailsTool directly
email_tool = GetUnreadEmailsTool()
email_tuples = email_tool._run(limit=email_limit)
# Convert email tuples to EmailDetails objects with pre-calculated ages
emails = []
today = date.today()
for email_tuple in email_tuples:
email_detail = EmailDetails.from_email_tuple(email_tuple)
# Calculate age if date is available
if email_detail.date:
try:
email_date_obj = datetime.strptime(email_detail.date, "%Y-%m-%d").date()
email_detail.age_days = (today - email_date_obj).days
print(f"Email date: {email_detail.date}, age: {email_detail.age_days} days")
except Exception as e:
print(f"Error calculating age for email date {email_detail.date}: {e}")
email_detail.age_days = None
emails.append(email_detail.dict())
# Save emails to file
with open('output/fetched_emails.json', 'w') as f:
json.dump(emails, f, indent=2)
print(f"Fetched and saved {len(emails)} emails to output/fetched_emails.json")
return inputs
llm = LLM(
model="openai/gpt-4o-mini",
api_key=os.getenv("OPENAI_API_KEY"),
)
@agent
def categorizer(self) -> Agent:
"""The email categorizer agent."""
return Agent(
config=self.agents_config['categorizer'],
tools=[FileReadTool()],
llm=self.llm,
)
@agent
def organizer(self) -> Agent:
"""The email organization agent."""
return Agent(
config=self.agents_config['organizer'],
tools=[GmailOrganizeTool(), FileReadTool()],
llm=self.llm,
)
@agent
def response_generator(self) -> Agent:
"""The email response generator agent."""
return Agent(
config=self.agents_config['response_generator'],
tools=[SaveDraftTool()],
llm=self.llm,
)
@agent
def notifier(self) -> Agent:
"""The email notification agent."""
return Agent(
config=self.agents_config['notifier'],
tools=[SlackNotificationTool()],
llm=self.llm,
)
@agent
def cleaner(self) -> Agent:
"""The email cleanup agent."""
return Agent(
config=self.agents_config['cleaner'],
tools=[GmailDeleteTool(), EmptyTrashTool()],
llm=self.llm,
)
@task
def categorization_task(self) -> Task:
"""The email categorization task."""
return Task(
config=self.tasks_config['categorization_task'],
output_pydantic=SimpleCategorizedEmail
)
@task
def organization_task(self) -> Task:
"""The email organization task."""
return Task(
config=self.tasks_config['organization_task'],
output_pydantic=OrganizedEmail,
)
@task
def response_task(self) -> Task:
"""The email response task."""
return Task(
config=self.tasks_config['response_task'],
output_pydantic=EmailResponse,
)
@task
def notification_task(self) -> Task:
"""The email notification task."""
return Task(
config=self.tasks_config['notification_task'],
output_pydantic=SlackNotification,
)
@task
def cleanup_task(self) -> Task:
"""The email cleanup task."""
return Task(
config=self.tasks_config['cleanup_task'],
output_pydantic=EmailCleanupInfo,
)
@crew
def crew(self) -> Crew:
"""Creates the email processing crew."""
return Crew(
agents=self.agents,
tasks=self.tasks,
process=Process.sequential,
verbose=True
)
def _debug_callback(self, event_type, payload):
"""Debug callback for crew events."""
if event_type == "task_start":
print(f"DEBUG: Starting task: {payload.get('task_name')}")
elif event_type == "task_end":
print(f"DEBUG: Finished task: {payload.get('task_name')}")
print(f"DEBUG: Task output type: {type(payload.get('output'))}")
# Add more detailed output inspection
output = payload.get('output')
if output:
if isinstance(output, dict):
print(f"DEBUG: Output keys: {output.keys()}")
for key, value in output.items():
print(f"DEBUG: {key}: {value[:100] if isinstance(value, str) and len(value) > 100 else value}")
elif isinstance(output, list):
print(f"DEBUG: Output list length: {len(output)}")
if output and len(output) > 0:
print(f"DEBUG: First item type: {type(output[0])}")
if isinstance(output[0], dict):
print(f"DEBUG: First item keys: {output[0].keys()}")
else:
print(f"DEBUG: Output: {str(output)[:200]}...")
elif event_type == "agent_start":
print(f"DEBUG: Agent starting: {payload.get('agent_name')}")
elif event_type == "agent_end":
print(f"DEBUG: Agent finished: {payload.get('agent_name')}")
elif event_type == "error":
print(f"DEBUG: Error: {payload.get('error')}")
def _validate_categorization_output(self, output):
"""Validate the categorization output before writing to file."""
print(f"DEBUG: Validating categorization output: {output}")
# If output is empty or invalid, provide a default
if not output:
print("WARNING: Empty categorization output, providing default")
return {
"email_id": "",
"subject": "",
"category": "",
"priority": "",
"required_action": ""
}
# If output is a string (which might happen if the LLM returns JSON as a string)
if isinstance(output, str):
try:
# Try to parse it as JSON
import json
# First, check if the string starts with "my best complete final answer"
if "my best complete final answer" in output.lower():
# Extract the JSON part
json_start = output.find("{")
json_end = output.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
json_str = output[json_start:json_end]
parsed = json.loads(json_str)
print("DEBUG: Successfully extracted and parsed JSON from answer")
return parsed
# Try to parse the whole string as JSON
parsed = json.loads(output)
print("DEBUG: Successfully parsed string output as JSON")
return parsed
except Exception as e:
print(f"WARNING: Output is a string but not valid JSON: {e}")
# Try to extract anything that looks like JSON
import re
json_pattern = r'\{.*\}'
match = re.search(json_pattern, output, re.DOTALL)
if match:
try:
json_str = match.group(0)
parsed = json.loads(json_str)
print("DEBUG: Successfully extracted and parsed JSON using regex")
return parsed
except:
print("WARNING: Failed to parse extracted JSON")
# If output is already a dict, make sure it has the required fields
if isinstance(output, dict):
required_fields = ["email_id", "subject", "category", "priority", "required_action"]
missing_fields = [field for field in required_fields if field not in output]
if missing_fields:
print(f"WARNING: Output missing required fields: {missing_fields}")
# Add missing fields with empty values
for field in missing_fields:
output[field] = ""
# Check if the values match the expected format
if output.get("email_id") == "12345" and output.get("subject") == "Urgent Task Update":
print("WARNING: Output contains placeholder values, trying to fix")
# Try to get the real email ID from the fetched emails
try:
with open("output/fetched_emails.json", "r") as f:
fetched_emails = json.load(f)
if fetched_emails and len(fetched_emails) > 0:
real_email = fetched_emails[0]
output["email_id"] = real_email.get("email_id", "")
output["subject"] = real_email.get("subject", "")
except Exception as e:
print(f"WARNING: Failed to fix placeholder values: {e}")
return output
================================================
FILE: src/gmail_crew_ai/main.py
================================================
#!/usr/bin/env python
import sys
import warnings
from dotenv import load_dotenv
# Remove or comment out these debug lines
# import litellm
# litellm._turn_on_debug()
# Add this line to suppress the warning
warnings.filterwarnings("ignore", message=".*not a Python type.*")
# Keep your existing warning filter
warnings.filterwarnings("ignore", category=SyntaxWarning, module="pysbd")
from gmail_crew_ai.crew import GmailCrewAi
def run():
"""Run the Gmail Crew AI."""
try:
# Load environment variables
load_dotenv()
# Get user input for number of emails to process
try:
email_limit = input("How many emails would you like to process? (default: 5): ")
if email_limit.strip() == "":
email_limit = 5
else:
email_limit = int(email_limit)
if email_limit <= 0:
print("Number must be positive. Using default of 5.")
email_limit = 5
except ValueError:
print("Invalid input. Using default of 5 emails.")
email_limit = 5
print(f"Processing {email_limit} emails...")
# Create and run the crew with the specified email limit
result = GmailCrewAi().crew().kickoff(inputs={'email_limit': email_limit})
# Check if result is empty or None
if not result:
print("\nNo emails were processed. Inbox might be empty.")
return 0
# Print the result in a clean way
if result:
print("\nCrew execution completed successfully! 🎉")
print("Results have been saved to the output directory.")
return 0 # Return success code
else:
print("\nCrew execution completed but no results were returned.")
return 0 # Still consider this a success
except Exception as e:
print(f"\nError: {e}")
return 1 # Return error code
if __name__ == "__main__":
sys.exit(run()) # Use the return value as the exit code
================================================
FILE: src/gmail_crew_ai/models.py
================================================
from pydantic import BaseModel, Field, SkipValidation
from typing import List, Optional, Dict, Literal, Callable, Any
from datetime import datetime
class EmailDetails(BaseModel):
"""Model for email details."""
email_id: Optional[str] = Field(None, description="Email ID")
subject: Optional[str] = Field(None, description="Email subject")
sender: Optional[str] = Field(None, description="Email sender")
body: Optional[str] = Field(None, description="Email body")
date: Optional[str] = Field(None, description="Email date (YYYY-MM-DD)")
age_days: Optional[int] = Field(None, description="Age of the email in days from today")
thread_info: Optional[Dict[str, Any]] = Field(None, description="Thread information")
is_part_of_thread: Optional[bool] = Field(False, description="Whether this email is part of a thread")
thread_size: Optional[int] = Field(1, description="Number of emails in this thread")
thread_position: Optional[int] = Field(1, description="Position of this email in the thread (1 = first)")
@classmethod
def from_email_tuple(cls, email_tuple):
"""Create an EmailDetails from an email tuple."""
if not email_tuple or len(email_tuple) < 5:
return cls(email_id=None, subject=None)
subject, sender, body, email_id, thread_info = email_tuple
# Extract date from thread_info
date = ""
if isinstance(thread_info, dict) and 'date' in thread_info:
date = thread_info['date']
return cls(
email_id=email_id,
subject=subject,
sender=sender,
body=body,
date=date,
thread_info=thread_info
)
# Define the valid categories, priorities, and actions as type aliases
EmailCategoryType = Literal["NEWSLETTERS", "PROMOTIONS", "PERSONAL", "GITHUB",
"SPONSORSHIPS", "RECRUITMENT", "COLD_EMAIL",
"EVENT_INVITATIONS", "RECEIPTS_INVOICES", "YOUTUBE", "SOCIALS"]
EmailPriorityType = Literal["HIGH", "MEDIUM", "LOW"]
EmailActionType = Literal["REPLY", "READ_ONLY", "TASK", "IGNORE"]
class CategorizedEmail(BaseModel):
"""Model for categorized email information."""
email_id: str = Field(..., description="Unique identifier for the email")
subject: str = Field(..., description="Email subject line")
sender: str = Field(..., description="Email sender (name and address)")
date: str = Field(..., description="Email date in YYYY-MM-DD format")
category: EmailCategoryType = Field(..., description="Category of the email")
priority: EmailPriorityType = Field(..., description="Priority level of the email")
required_action: EmailActionType = Field(..., description="Required action for the email")
reason: str = Field(..., description="Reason for the categorization")
due_date: Optional[str] = Field(None, description="Due date for action if applicable")
thread_info: Optional[Dict] = Field(default=None, description="Thread information for replies")
class OrganizedEmail(BaseModel):
"""Model for organized email information."""
email_id: str = Field(..., description="Unique identifier for the email")
subject: str = Field(..., description="Email subject line")
applied_labels: List[str] = Field(default_factory=list, description="Labels applied to the email")
starred: bool = Field(default=False, description="Whether the email was starred")
result: str = Field(..., description="Result of the organization attempt")
class EmailResponse(BaseModel):
"""Model for email response information."""
email_id: str = Field(..., description="Unique identifier for the email")
subject: str = Field(..., description="Email subject line")
recipient: str = Field(..., description="Email recipient")
response_summary: str = Field(..., description="Summary of the response")
response_needed: bool = Field(..., description="Whether a response was needed")
draft_saved: bool = Field(default=False, description="Whether a draft was saved")
class SlackNotification(BaseModel):
"""Model for Slack notification information."""
email_id: str = Field(..., description="Unique identifier for the email")
subject: str = Field(..., description="Email subject line")
sender: str = Field(..., description="Email sender")
category: EmailCategoryType = Field(..., description="Category of the email")
priority: EmailPriorityType = Field(..., description="Priority level of the email")
summary: str = Field(..., description="Brief summary of the email content")
action_needed: Optional[str] = Field(None, description="Action needed, if any")
headline: str = Field(..., description="Custom headline for the notification")
intro: str = Field(..., description="Custom intro phrase for the notification")
action_header: Optional[str] = Field(None, description="Custom header for the action section")
notification_sent: bool = Field(default=False, description="Whether notification was sent")
class EmailCleanupInfo(BaseModel):
"""Model for email cleanup information."""
email_id: str = Field(..., description="Unique identifier for the email")
subject: str = Field(..., description="Email subject line")
sender: str = Field(..., description="Email sender")
age_days: int = Field(..., description="Age of the email in days")
deleted: bool = Field(..., description="Whether the email was deleted")
reason: str = Field(..., description="Reason for deletion or preservation")
class SimpleCategorizedEmail(BaseModel):
"""Simplified model for debugging."""
email_id: Optional[str] = Field(None, description="Unique identifier for the email")
subject: Optional[str] = Field(None, description="Email subject line")
sender: Optional[str] = Field(None, description="Email sender address")
category: Optional[str] = Field(None, description="Category of the email")
priority: Optional[str] = Field(None, description="Priority level of the email")
required_action: Optional[str] = Field(None, description="Required action for the email")
date: Optional[str] = Field(None, description="Date the email was received (YYYY-MM-DD)")
age_days: Optional[int] = Field(None, description="Age of the email in days from today")
# Update the from_email_tuple method to include date
@classmethod
def from_email_tuple(cls, email_tuple):
"""Create a SimpleCategorizedEmail from an email tuple."""
if not email_tuple or len(email_tuple) < 5:
return cls(email_id=None, subject=None)
subject, sender, body, email_id, thread_info = email_tuple
# Extract date from body or thread_info
date = ""
if isinstance(thread_info, dict) and 'date' in thread_info:
date = thread_info['date']
elif body and body.startswith("EMAIL DATE:"):
date_line = body.split("\n")[0]
date = date_line.replace("EMAIL DATE:", "").strip()
return cls(
email_id=email_id,
subject=subject,
sender=sender,
date=date
)
================================================
FILE: src/gmail_crew_ai/tools/__init__.py
================================================
================================================
FILE: src/gmail_crew_ai/tools/date_tools.py
================================================
from datetime import datetime, date
from typing import Optional
from pydantic import BaseModel, Field
from crewai.tools import BaseTool
import time
class DateCalculationSchema(BaseModel):
"""Schema for DateCalculationTool input."""
email_date: str = Field(..., description="Email date in ISO format (YYYY-MM-DD)")
# Remove reference_date from schema to prevent agent from using it
# reference_date: Optional[str] = Field(None, description="Reference date in ISO format (YYYY-MM-DD). Defaults to today.")
class DateCalculationTool(BaseTool):
"""Tool to calculate the age of an email in days."""
name: str = "calculate_email_age"
description: str = "Calculate how many days old an email is compared to today's date"
args_schema: type[BaseModel] = DateCalculationSchema
def _run(self, email_date: str, reference_date: Optional[str] = None) -> str:
"""Calculate the age of an email in days compared to today.
Args:
email_date: The date of the email in YYYY-MM-DD format
Returns:
A string with the email age information
"""
try:
# Parse the email date
email_date_obj = datetime.strptime(email_date, "%Y-%m-%d").date()
# Always use today's date - ignore any provided reference_date
today = date.today()
# Calculate the age in days
age_days = (today - email_date_obj).days
# Create the response
response = f"Email age: {age_days} days from today ({today})\n"
response += f"Email date: {email_date_obj}\n"
response += f"- Less than 5 days old: {'Yes' if age_days < 5 else 'No'}\n"
response += f"- Older than 7 days: {'Yes' if age_days > 7 else 'No'}\n"
response += f"- Older than 10 days: {'Yes' if age_days > 10 else 'No'}\n"
response += f"- Older than 14 days: {'Yes' if age_days > 14 else 'No'}\n"
response += f"- Older than 30 days: {'Yes' if age_days > 30 else 'No'}\n"
return response
except Exception as e:
return f"Error calculating email age: {str(e)}"
================================================
FILE: src/gmail_crew_ai/tools/gmail_tools.py
================================================
import imaplib
import email
from email.header import decode_header
from typing import List, Tuple, Literal, Optional, Type, Dict, Any
import re
from bs4 import BeautifulSoup
from crewai.tools import BaseTool
import os
from pydantic import BaseModel, Field
from crewai.tools import tool
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import base64
def decode_header_safe(header):
"""
Safely decode email headers that might contain encoded words or non-ASCII characters.
"""
if not header:
return ""
try:
decoded_parts = []
for decoded_str, charset in decode_header(header):
if isinstance(decoded_str, bytes):
if charset:
decoded_parts.append(decoded_str.decode(charset or 'utf-8', errors='replace'))
else:
decoded_parts.append(decoded_str.decode('utf-8', errors='replace'))
else:
decoded_parts.append(str(decoded_str))
return ' '.join(decoded_parts)
except Exception as e:
# Fallback to raw header if decoding fails
return str(header)
def clean_email_body(email_body: str) -> str:
"""
Clean the email body by removing HTML tags and excessive whitespace.
"""
try:
soup = BeautifulSoup(email_body, "html.parser")
text = soup.get_text(separator=" ") # Get text with spaces instead of <br/>
except Exception as e:
print(f"Error parsing HTML: {e}")
text = email_body # Fallback to raw body if parsing fails
# Remove excessive whitespace and newlines
text = re.sub(r'\s+', ' ', text).strip()
return text
class GmailToolBase(BaseTool):
"""Base class for Gmail tools, handling connection and credentials."""
class Config:
arbitrary_types_allowed = True
email_address: Optional[str] = Field(None, description="Gmail email address")
app_password: Optional[str] = Field(None, description="Gmail app password")
def __init__(self, description: str = ""):
super().__init__(description=description)
self.email_address = os.environ.get("EMAIL_ADDRESS")
self.app_password = os.environ.get("APP_PASSWORD")
if not self.email_address or not self.app_password:
raise ValueError("EMAIL_ADDRESS and APP_PASSWORD must be set in the environment.")
def _connect(self):
"""Connect to Gmail."""
try:
print(f"Connecting to Gmail with email: {self.email_address[:3]}...{self.email_address[-8:]}")
mail = imaplib.IMAP4_SSL("imap.gmail.com")
mail.login(self.email_address, self.app_password)
print("Successfully logged in to Gmail")
return mail
except Exception as e:
print(f"Error connecting to Gmail: {e}")
raise e
def _disconnect(self, mail):
"""Disconnect from Gmail."""
try:
mail.close()
mail.logout()
print("Successfully disconnected from Gmail")
except:
pass
def _get_thread_messages(self, mail: imaplib.IMAP4_SSL, msg) -> List[str]:
"""Get all messages in the thread by following References and In-Reply-To headers."""
thread_messages = []
# Get message IDs from References and In-Reply-To headers
references = msg.get("References", "").split()
in_reply_to = msg.get("In-Reply-To", "").split()
message_ids = list(set(references + in_reply_to)) # Remove duplicates
if message_ids:
# Search for messages with these Message-IDs
search_criteria = ' OR '.join(f'HEADER MESSAGE-ID "{mid}"' for mid in message_ids)
result, data = mail.search(None, search_criteria)
if result == "OK":
thread_ids = data[0].split()
for thread_id in thread_ids:
result, msg_data = mail.fetch(thread_id, "(RFC822)")
if result == "OK":
thread_msg = email.message_from_bytes(msg_data[0][1])
# Extract body from thread message
thread_body = self._extract_body(thread_msg)
thread_messages.append(thread_body)
return thread_messages
def _extract_body(self, msg) -> str:
"""Extract body from an email message."""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition"))
try:
email_body = part.get_payload(decode=True).decode()
except:
email_body = ""
if content_type == "text/plain" and "attachment" not in content_disposition:
body += email_body
elif content_type == "text/html" and "attachment" not in content_disposition:
body += clean_email_body(email_body)
else:
try:
body = clean_email_body(msg.get_payload(decode=True).decode())
except Exception as e:
body = f"Error decoding body: {e}"
return body
class GetUnreadEmailsSchema(BaseModel):
"""Schema for GetUnreadEmailsTool input."""
limit: Optional[int] = Field(
default=5,
description="Maximum number of unread emails to retrieve. Defaults to 5.",
ge=1 # Ensures the limit is greater than or equal to 1
)
class GetUnreadEmailsTool(GmailToolBase):
"""Tool to get unread emails from Gmail."""
name: str = "get_unread_emails"
description: str = "Gets unread emails from Gmail"
args_schema: Type[BaseModel] = GetUnreadEmailsSchema
def _run(self, limit: Optional[int] = 5) -> List[Tuple[str, str, str, str, Dict]]:
mail = self._connect()
try:
print("DEBUG: Connecting to Gmail...")
mail.select("INBOX")
result, data = mail.search(None, 'UNSEEN')
print(f"DEBUG: Search result: {result}")
if result != "OK":
print("DEBUG: Error searching for unseen emails")
return []
email_ids = data[0].split()
print(f"DEBUG: Found {len(email_ids)} unread emails")
if not email_ids:
print("DEBUG: No unread emails found.")
return []
email_ids = list(reversed(email_ids))
email_ids = email_ids[:limit]
print(f"DEBUG: Processing {len(email_ids)} emails")
emails = []
for i, email_id in enumerate(email_ids):
print(f"DEBUG: Processing email {i+1}/{len(email_ids)}")
result, msg_data = mail.fetch(email_id, "(RFC822)")
if result != "OK":
print(f"Error fetching email {email_id}:", result)
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Decode headers properly (handles encoded characters)
subject = decode_header_safe(msg["Subject"])
sender = decode_header_safe(msg["From"])
# Extract and standardize the date
date_str = msg.get("Date", "")
received_date = self._parse_email_date(date_str)
# Get the current message body
current_body = self._extract_body(msg)
# Get thread messages
thread_messages = self._get_thread_messages(mail, msg)
# Combine current message with thread history
full_body = "\n\n--- Previous Messages ---\n".join([current_body] + thread_messages)
# Get thread metadata
thread_info = {
'message_id': msg.get('Message-ID', ''),
'in_reply_to': msg.get('In-Reply-To', ''),
'references': msg.get('References', ''),
'date': received_date, # Use standardized date
'raw_date': date_str, # Keep original date string
'email_id': email_id.decode('utf-8')
}
# Add a clear date indicator in the body for easier extraction
full_body = f"EMAIL DATE: {received_date}\n\n{full_body}"
# Print the structure of what we're appending
print(f"DEBUG: Email tuple structure: subject={subject}, sender={sender}, body_length={len(full_body)}, email_id={email_id.decode('utf-8')}, thread_info_keys={thread_info.keys()}")
emails.append((subject, sender, full_body, email_id.decode('utf-8'), thread_info))
print(f"DEBUG: Returning {len(emails)} email tuples")
return emails
except Exception as e:
print(f"DEBUG: Exception in GetUnreadEmailsTool: {e}")
import traceback
traceback.print_exc()
return []
finally:
self._disconnect(mail)
def _parse_email_date(self, date_str: str) -> str:
"""
Parse email date string into a standardized format.
Returns ISO format date string (YYYY-MM-DD) or empty string if parsing fails.
"""
if not date_str:
return ""
try:
# Try various date formats commonly found in emails
# Remove timezone name if present (like 'EDT', 'PST')
date_str = re.sub(r'\s+\([A-Z]{3,4}\)', '', date_str)
# Parse with email.utils
parsed_date = email.utils.parsedate_to_datetime(date_str)
if parsed_date:
return parsed_date.strftime("%Y-%m-%d")
except Exception as e:
print(f"Error parsing date '{date_str}': {e}")
return ""
class SaveDraftSchema(BaseModel):
"""Schema for SaveDraftTool input."""
subject: str = Field(..., description="Email subject")
body: str = Field(..., description="Email body content")
recipient: str = Field(..., description="Recipient email address")
thread_info: Optional[Dict[str, Any]] = Field(None, description="Thread information for replies")
class SaveDraftTool(BaseTool):
"""Tool to save an email as a draft using IMAP."""
name: str = "save_email_draft"
description: str = "Saves an email as a draft in Gmail"
args_schema: Type[BaseModel] = SaveDraftSchema
def _format_body(self, body: str) -> str:
"""Format the email body with signature."""
# Replace [Your name] or [Your Name] with Tony Kipkemboi
body = re.sub(r'\[Your [Nn]ame\]', 'Tony Kipkemboi', body)
# If no placeholder was found, append the signature
if '[Your' not in body and '[your' not in body:
body = f"{body}\n\nBest regards,\nTony Kipkemboi"
return body
def _connect(self):
"""Connect to Gmail using IMAP."""
# Get email credentials from environment
email_address = os.environ.get('EMAIL_ADDRESS')
app_password = os.environ.get('APP_PASSWORD')
if not email_address or not app_password:
raise ValueError("EMAIL_ADDRESS or APP_PASSWORD environment variables not set")
# Connect to Gmail's IMAP server
mail = imaplib.IMAP4_SSL('imap.gmail.com')
print(f"Connecting to Gmail with email: {email_address[:3]}...{email_address[-10:]}")
mail.login(email_address, app_password)
return mail, email_address
def _disconnect(self, mail):
"""Disconnect from Gmail."""
try:
mail.logout()
except:
pass
def _check_drafts_folder(self, mail):
"""Check available mailboxes to find the drafts folder."""
print("Checking available mailboxes...")
result, mailboxes = mail.list()
if result == 'OK':
drafts_folders = []
for mailbox in mailboxes:
if b'Drafts' in mailbox or b'Draft' in mailbox:
drafts_folders.append(mailbox.decode())
print(f"Found drafts folder: {mailbox.decode()}")
return drafts_folders
return []
def _verify_draft_saved(self, mail, subject, recipient):
"""Verify if the draft was actually saved by searching for it."""
try:
# Try different drafts folder names
drafts_folders = [
'"[Gmail]/Drafts"',
'Drafts',
'DRAFTS',
'"[Google Mail]/Drafts"',
'[Gmail]/Drafts'
]
for folder in drafts_folders:
try:
print(f"Checking folder: {folder}")
result, _ = mail.select(folder, readonly=True)
if result != 'OK':
continue
# Search for drafts with this subject
search_criteria = f'SUBJECT "{subject}"'
result, data = mail.search(None, search_criteria)
if result == 'OK' and data[0]:
draft_count = len(data[0].split())
print(f"Found {draft_count} drafts matching subject '{subject}' in folder {folder}")
return True, folder
else:
print(f"No drafts found matching subject '{subject}' in folder {folder}")
except Exception as e:
print(f"Error checking folder {folder}: {e}")
continue
return False, None
except Exception as e:
print(f"Error verifying draft: {e}")
return False, None
def _run(self, subject: str, body: str, recipient: str, thread_info: Optional[Dict[str, Any]] = None) -> str:
try:
mail, email_address = self._connect()
# Check available drafts folders
drafts_folders = self._check_drafts_folder(mail)
print(f"Available drafts folders: {drafts_folders}")
# Try with quoted folder name first
drafts_folder = '"[Gmail]/Drafts"'
print(f"Selecting drafts folder: {drafts_folder}")
result, _ = mail.select(drafts_folder)
# If that fails, try without quotes
if result != 'OK':
drafts_folder = '[Gmail]/Drafts'
print(f"First attempt failed. Trying: {drafts_folder}")
result, _ = mail.select(drafts_folder)
# If that also fails, try just 'Drafts'
if result != 'OK':
drafts_folder = 'Drafts'
print(f"Second attempt failed. Trying: {drafts_folder}")
result, _ = mail.select(drafts_folder)
if result != 'OK':
return f"Error: Could not select drafts folder. Available folders: {drafts_folders}"
print(f"Successfully selected drafts folder: {drafts_folder}")
# Format body and add signature
body_with_signature = self._format_body(body)
# Create the email message
message = email.message.EmailMessage()
message["From"] = email_address
message["To"] = recipient
message["Subject"] = subject
message.set_content(body_with_signature)
print(f"Created message with subject: {subject}")
# Add thread headers if this is a reply
if thread_info:
# References header should include all previous message IDs
references = []
if thread_info.get('references'):
references.extend(thread_info['references'].split())
if thread_info.get('message_id'):
references.append(thread_info['message_id'])
if references:
message["References"] = " ".join(references)
# In-Reply-To should point to the immediate parent message
if thread_info.get('message_id'):
message["In-Reply-To"] = thread_info['message_id']
# Make sure subject has "Re: " prefix
if not subject.lower().startswith('re:'):
message["Subject"] = f"Re: {subject}"
print(f"Added thread information for reply")
# Save to drafts
print(f"Attempting to save draft to {drafts_folder}...")
date = imaplib.Time2Internaldate(time.time())
result, data = mail.append(drafts_folder, '\\Draft', date, message.as_bytes())
if result != 'OK':
return f"Error saving draft: {result}, {data}"
print(f"Draft save attempt result: {result}")
# Verify the draft was actually saved
verified, folder = self._verify_draft_saved(mail, subject, recipient)
if verified:
return f"VERIFIED: Draft email saved with subject: '{subject}' in folder {folder}"
else:
# Try Gmail's API approach as a fallback
try:
# Try saving directly to All Mail and flagging as draft
result, data = mail.append('[Gmail]/All Mail', '\\Draft', date, message.as_bytes())
if result == 'OK':
return f"Draft saved to All Mail with subject: '{subject}' (flagged as draft)"
else:
return f"WARNING: Draft save attempt returned {result}, but verification failed. Please check your Gmail Drafts folder."
except Exception as e:
return f"WARNING: Draft may not have been saved properly: {str(e)}"
except Exception as e:
return f"Error saving draft: {str(e)}"
finally:
self._disconnect(mail)
class GmailOrganizeSchema(BaseModel):
"""Schema for GmailOrganizeTool input."""
email_id: str = Field(..., description="Email ID to organize")
category: str = Field(..., description="Category assigned by agent (Urgent/Response Needed/etc)")
priority: str = Field(..., description="Priority level (High/Medium/Low)")
should_star: bool = Field(default=False, description="Whether to star the email")
labels: List[str] = Field(default_list=[], description="Labels to apply")
class GmailOrganizeTool(GmailToolBase):
"""Tool to organize emails based on agent categorization."""
name: str = "organize_email"
description: str = "Organizes emails using Gmail's priority features based on category and priority"
args_schema: Type[BaseModel] = GmailOrganizeSchema
def _run(self, email_id: str, category: str, priority: str, should_star: bool = False, labels: List[str] = None) -> str:
"""Organize an email with the specified parameters."""
if labels is None:
# Provide a default empty list to avoid validation errors
labels = []
print(f"Organizing email {email_id} with category {category}, priority {priority}, star={should_star}, labels={labels}")
mail = self._connect()
try:
# Select inbox to ensure we can access the email
mail.select("INBOX")
# Apply organization based on category and priority
if category == "Urgent Response Needed" and priority == "High":
# Star the email
if should_star:
mail.store(email_id, '+FLAGS', '\\Flagged')
# Mark as important
mail.store(email_id, '+FLAGS', '\\Important')
# Apply URGENT label if it doesn't exist
if "URGENT" not in labels:
labels.append("URGENT")
# Apply all specified labels
for label in labels:
try:
# Create label if it doesn't exist
mail.create(label)
except:
pass # Label might already exist
# Apply label
mail.store(email_id, '+X-GM-LABELS', label)
return f"Email organized: Starred={should_star}, Labels={labels}"
except Exception as e:
return f"Error organizing email: {e}"
finally:
self._disconnect(mail)
class GmailDeleteSchema(BaseModel):
"""Schema for GmailDeleteTool input."""
email_id: str = Field(..., description="Email ID to delete")
reason: str = Field(..., description="Reason for deletion")
class GmailDeleteTool(BaseTool):
"""Tool to delete an email using IMAP."""
name: str = "delete_email"
description: str = "Deletes an email from Gmail"
def _run(self, email_id: str, reason: str) -> str:
"""
Delete an email by ID.
Parameters:
email_id: The email ID to delete
reason: The reason for deletion (for logging)
"""
try:
# Validate inputs - Add this validation
if not email_id or not isinstance(email_id, str):
return f"Error: Invalid email_id format: {email_id}"
if not reason or not isinstance(reason, str):
return f"Error: Invalid reason format: {reason}"
mail = self._connect()
try:
mail.select("INBOX")
# First verify the email exists and get its details for logging
result, data = mail.fetch(email_id, "(RFC822)")
if result != "OK" or not data or data[0] is None:
return f"Error: Email with ID {email_id} not found"
msg = email.message_from_bytes(data[0][1])
subject = decode_header_safe(msg["Subject"])
sender = decode_header_safe(msg["From"])
# Move to Trash
mail.store(email_id, '+X-GM-LABELS', '\\Trash')
mail.store(email_id, '-X-GM-LABELS', '\\Inbox')
return f"Email deleted: '{subject}' from {sender}. Reason: {reason}"
except Exception as e:
return f"Error deleting email: {e}"
finally:
self._disconnect(mail)
except Exception as e:
return f"Error deleting email: {str(e)}"
class EmptyTrashTool(BaseTool):
"""Tool to empty Gmail trash."""
name: str = "empty_gmail_trash"
description: str = "Empties the Gmail trash folder to free up space"
def _connect(self):
"""Connect to Gmail using IMAP."""
# Get email credentials from environment
email_address = os.environ.get('EMAIL_ADDRESS')
app_password = os.environ.get('APP_PASSWORD')
if not email_address or not app_password:
raise ValueError("EMAIL_ADDRESS or APP_PASSWORD environment variables not set")
# Connect to Gmail's IMAP server
mail = imaplib.IMAP4_SSL('imap.gmail.com')
print(f"Connecting to Gmail with email: {email_address[:3]}...{email_address[-10:]}")
mail.login(email_address, app_password)
return mail
def _disconnect(self, mail):
"""Disconnect from Gmail."""
try:
mail.logout()
except:
pass
def _run(self) -> str:
"""Empty the Gmail trash folder."""
try:
mail = self._connect()
# Try different trash folder names (Gmail can have different naming conventions)
trash_folders = [
'"[Gmail]/Trash"',
'[Gmail]/Trash',
'Trash',
'"[Google Mail]/Trash"',
'[Google Mail]/Trash'
]
success = False
trash_folder_used = None
for folder in trash_folders:
try:
print(f"Attempting to select trash folder: {folder}")
result, data = mail.select(folder)
if result == 'OK':
trash_folder_used = folder
print(f"Successfully selected trash folder: {folder}")
# Search for all messages in trash
result, data = mail.search(None, 'ALL')
if result == 'OK':
email_ids = data[0].split()
count = len(email_ids)
if count == 0:
print("No messages found in trash.")
return "Trash is already empty. No messages to delete."
print(f"Found {count} messages in trash.")
# Delete all messages in trash
for email_id in email_ids:
mail.store(email_id, '+FLAGS', '\\Deleted')
# Permanently remove messages marked for deletion
mail.expunge()
success = True
break
except Exception as e:
print(f"Error accessing trash folder {folder}: {e}")
continue
if success:
return f"Successfully emptied Gmail trash folder ({trash_folder_used}). Deleted {count} messages."
else:
return "Could not empty trash. No trash folder found or accessible."
except Exception as e:
return f"Error emptying trash: {str(e)}"
finally:
self._disconnect(mail)
================================================
FILE: src/gmail_crew_ai/tools/slack_tool.py
================================================
import os
import json
import requests
from typing import List, Dict, Optional, Type
from pydantic import BaseModel, Field
from crewai.tools import BaseTool
class SlackNotificationSchema(BaseModel):
"""Schema for SlackNotificationTool input."""
subject: str = Field(..., description="Email subject")
sender: str = Field(..., description="Email sender")
category: str = Field(..., description="Email category")
priority: str = Field(..., description="Email priority")
summary: str = Field(..., description="Brief summary of the email content")
action_needed: Optional[str] = Field(None, description="Action needed, if any")
headline: Optional[str] = Field(None, description="Custom headline for the notification")
intro: Optional[str] = Field(None, description="Custom intro phrase for the notification")
action_header: Optional[str] = Field(None, description="Custom header for the action section")
class SlackNotificationTool(BaseTool):
"""Tool to send notifications to Slack."""
name: str = "slack_notification"
description: str = "Sends notifications about important emails to Slack"
args_schema: Type[BaseModel] = SlackNotificationSchema
class Config:
arbitrary_types_allowed = True
def __init__(self):
super().__init__()
self._webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
if not self._webhook_url:
raise ValueError("SLACK_WEBHOOK_URL must be set in the environment.")
def _run(self, subject: str, sender: str, category: str,
priority: str, summary: str, action_needed: Optional[str] = None,
headline: Optional[str] = None, intro: Optional[str] = None,
action_header: Optional[str] = None) -> str:
"""Send a notification to Slack."""
# Format the message
blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": headline or f"Important Email: {subject}"
}
}
]
# Add intro if provided
if intro:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{intro}*"
}
})
# Add email details
blocks.append({
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*From:*\n{sender}"},
{"type": "mrkdwn", "text": f"*Category:*\n{category}"},
{"type": "mrkdwn", "text": f"*Priority:*\n{priority}"},
]
})
# Add summary
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Summary:*\n{summary}"
}
})
# Add action needed if provided
if action_needed:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{action_header or 'Action Needed:'}*\n{action_needed}"
}
})
# Add divider
blocks.append({"type": "divider"})
# Prepare the payload
payload = {
"blocks": blocks
}
# Send the notification
try:
response = requests.post(
self._webhook_url,
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
return f"Slack notification sent successfully for email: {subject}"
except Exception as e:
return f"Error sending Slack notification: {e}"
gitextract_9jx_yv_y/
├── .env_example
├── .gitignore
├── LICENSE
├── README.md
├── knowledge/
│ └── user_preference.txt
├── pyproject.toml
└── src/
└── gmail_crew_ai/
├── __init__.py
├── config/
│ ├── agents.yaml
│ └── tasks.yaml
├── crew.py
├── main.py
├── models.py
└── tools/
├── __init__.py
├── date_tools.py
├── gmail_tools.py
└── slack_tool.py
SYMBOL INDEX (64 symbols across 6 files)
FILE: src/gmail_crew_ai/crew.py
class GmailCrewAi (line 16) | class GmailCrewAi():
method fetch_emails (line 22) | def fetch_emails(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
method categorizer (line 69) | def categorizer(self) -> Agent:
method organizer (line 78) | def organizer(self) -> Agent:
method response_generator (line 87) | def response_generator(self) -> Agent:
method notifier (line 96) | def notifier(self) -> Agent:
method cleaner (line 105) | def cleaner(self) -> Agent:
method categorization_task (line 114) | def categorization_task(self) -> Task:
method organization_task (line 122) | def organization_task(self) -> Task:
method response_task (line 130) | def response_task(self) -> Task:
method notification_task (line 138) | def notification_task(self) -> Task:
method cleanup_task (line 146) | def cleanup_task(self) -> Task:
method crew (line 154) | def crew(self) -> Crew:
method _debug_callback (line 163) | def _debug_callback(self, event_type, payload):
method _validate_categorization_output (line 193) | def _validate_categorization_output(self, output):
FILE: src/gmail_crew_ai/main.py
function run (line 17) | def run():
FILE: src/gmail_crew_ai/models.py
class EmailDetails (line 5) | class EmailDetails(BaseModel):
method from_email_tuple (line 19) | def from_email_tuple(cls, email_tuple):
class CategorizedEmail (line 49) | class CategorizedEmail(BaseModel):
class OrganizedEmail (line 62) | class OrganizedEmail(BaseModel):
class EmailResponse (line 70) | class EmailResponse(BaseModel):
class SlackNotification (line 79) | class SlackNotification(BaseModel):
class EmailCleanupInfo (line 93) | class EmailCleanupInfo(BaseModel):
class SimpleCategorizedEmail (line 102) | class SimpleCategorizedEmail(BaseModel):
method from_email_tuple (line 115) | def from_email_tuple(cls, email_tuple):
FILE: src/gmail_crew_ai/tools/date_tools.py
class DateCalculationSchema (line 7) | class DateCalculationSchema(BaseModel):
class DateCalculationTool (line 13) | class DateCalculationTool(BaseTool):
method _run (line 19) | def _run(self, email_date: str, reference_date: Optional[str] = None) ...
FILE: src/gmail_crew_ai/tools/gmail_tools.py
function decode_header_safe (line 16) | def decode_header_safe(header):
function clean_email_body (line 38) | def clean_email_body(email_body: str) -> str:
class GmailToolBase (line 53) | class GmailToolBase(BaseTool):
class Config (line 56) | class Config:
method __init__ (line 62) | def __init__(self, description: str = ""):
method _connect (line 70) | def _connect(self):
method _disconnect (line 82) | def _disconnect(self, mail):
method _get_thread_messages (line 91) | def _get_thread_messages(self, mail: imaplib.IMAP4_SSL, msg) -> List[s...
method _extract_body (line 117) | def _extract_body(self, msg) -> str:
class GetUnreadEmailsSchema (line 141) | class GetUnreadEmailsSchema(BaseModel):
class GetUnreadEmailsTool (line 149) | class GetUnreadEmailsTool(GmailToolBase):
method _run (line 155) | def _run(self, limit: Optional[int] = 5) -> List[Tuple[str, str, str, ...
method _parse_email_date (line 235) | def _parse_email_date(self, date_str: str) -> str:
class SaveDraftSchema (line 257) | class SaveDraftSchema(BaseModel):
class SaveDraftTool (line 264) | class SaveDraftTool(BaseTool):
method _format_body (line 270) | def _format_body(self, body: str) -> str:
method _connect (line 281) | def _connect(self):
method _disconnect (line 296) | def _disconnect(self, mail):
method _check_drafts_folder (line 303) | def _check_drafts_folder(self, mail):
method _verify_draft_saved (line 316) | def _verify_draft_saved(self, mail, subject, recipient):
method _run (line 354) | def _run(self, subject: str, body: str, recipient: str, thread_info: O...
class GmailOrganizeSchema (line 449) | class GmailOrganizeSchema(BaseModel):
class GmailOrganizeTool (line 457) | class GmailOrganizeTool(GmailToolBase):
method _run (line 463) | def _run(self, email_id: str, category: str, priority: str, should_sta...
class GmailDeleteSchema (line 507) | class GmailDeleteSchema(BaseModel):
class GmailDeleteTool (line 512) | class GmailDeleteTool(BaseTool):
method _run (line 517) | def _run(self, email_id: str, reason: str) -> str:
class EmptyTrashTool (line 558) | class EmptyTrashTool(BaseTool):
method _connect (line 563) | def _connect(self):
method _disconnect (line 578) | def _disconnect(self, mail):
method _run (line 585) | def _run(self) -> str:
FILE: src/gmail_crew_ai/tools/slack_tool.py
class SlackNotificationSchema (line 8) | class SlackNotificationSchema(BaseModel):
class SlackNotificationTool (line 20) | class SlackNotificationTool(BaseTool):
class Config (line 26) | class Config:
method __init__ (line 29) | def __init__(self):
method _run (line 35) | def _run(self, subject: str, sender: str, category: str,
Condensed preview — 16 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (79K chars).
[
{
"path": ".env_example",
"chars": 231,
"preview": "# OpenAI Model\nMODEL=openai/gpt-4o-mini\nOPENAI_API_KEY=your_openai_api_key\n\n# Gmail credentials\nEMAIL_ADDRESS=your_email"
},
{
"path": ".gitignore",
"chars": 366,
"preview": "# Environment variables\n.env\n.env.*\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\n"
},
{
"path": "LICENSE",
"chars": 1071,
"preview": "MIT License\n\nCopyright (c) 2025 Tony Kipkemboi\n\nPermission is hereby granted, free of charge, to any person obtaining a "
},
{
"path": "README.md",
"chars": 7739,
"preview": "# Gmail Automation with CrewAI 📧✨\n\n[. The extraction includes 16 files (72.3 KB), approximately 16.6k tokens, and a symbol index with 64 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.