nudge-agent 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nudge_agent-0.1.4/LICENSE +21 -0
- nudge_agent-0.1.4/PKG-INFO +143 -0
- nudge_agent-0.1.4/README.md +102 -0
- nudge_agent-0.1.4/followup_agent.py +185 -0
- nudge_agent-0.1.4/gmail_client.py +278 -0
- nudge_agent-0.1.4/main.py +224 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/PKG-INFO +143 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/SOURCES.txt +12 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/dependency_links.txt +1 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/entry_points.txt +2 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/requires.txt +7 -0
- nudge_agent-0.1.4/nudge_agent.egg-info/top_level.txt +3 -0
- nudge_agent-0.1.4/pyproject.toml +31 -0
- nudge_agent-0.1.4/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dhruvil Mistry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIMViable, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nudge-agent
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: AI-powered Gmail follow-up CLI agent
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Dhruvil Mistry
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIMViable, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Classifier: Programming Language :: Python :: 3
|
|
28
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
29
|
+
Classifier: Operating System :: OS Independent
|
|
30
|
+
Requires-Python: >=3.9
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Requires-Dist: google-api-python-client
|
|
34
|
+
Requires-Dist: google-auth-httplib2
|
|
35
|
+
Requires-Dist: google-auth-oauthlib
|
|
36
|
+
Requires-Dist: openai
|
|
37
|
+
Requires-Dist: python-dotenv
|
|
38
|
+
Requires-Dist: readchar
|
|
39
|
+
Requires-Dist: rich
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/nudge-agent)
|
|
45
|
+
[](https://pypi.org/project/nudge-agent)
|
|
46
|
+
[](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
|
|
47
|
+
[](https://github.com/dhruvil-codes/nudge-agent)
|
|
48
|
+
|
|
49
|
+
**Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
|
|
50
|
+
|
|
51
|
+
Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## ✨ Features
|
|
60
|
+
|
|
61
|
+
- 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
|
|
62
|
+
- 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
|
|
63
|
+
- 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
|
|
64
|
+
- 🎭 **Interactive Multi-Tone AI Generator**:
|
|
65
|
+
- `[1] Check-in`: Quick, warm status update request.
|
|
66
|
+
- `[2] Value-Add`: Shares project accomplishments or progress updates.
|
|
67
|
+
- `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
|
|
68
|
+
- 🧠 **Smart Decision Engine**:
|
|
69
|
+
- Skips threads where the recipient already replied.
|
|
70
|
+
- Skips threads sent `< 3 days` ago.
|
|
71
|
+
- Skips threads where you've already sent `2+` follow-ups.
|
|
72
|
+
- ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## ⚡ Instant Installation
|
|
77
|
+
|
|
78
|
+
Install Nudge globally via `pip` or `pipx`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install nudge-agent
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then run `nudge` anywhere in your terminal:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
nudge
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ⚙️ Environment Configuration
|
|
93
|
+
|
|
94
|
+
Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export GROQ_API_KEY="gsk_your_groq_api_key_here"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Nudge also automatically loads configuration from `~/.nudge/.env` if present.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 💻 CLI Usage Options
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
nudge [OPTIONS]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Flag | Description |
|
|
111
|
+
| :--- | :--- |
|
|
112
|
+
| `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
|
|
113
|
+
| `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
|
|
114
|
+
| `--login` | Force Google re-authentication to sign in with a new Gmail account |
|
|
115
|
+
| `--logout` | Log out of your current Gmail account |
|
|
116
|
+
| `-h, --help` | Show command help and options |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 🛠 Architecture
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
nudge/
|
|
124
|
+
├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
|
|
125
|
+
├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
|
|
126
|
+
├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
|
|
127
|
+
└── pyproject.toml # Packaging metadata & entrypoints
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 🔒 Privacy & Security
|
|
133
|
+
|
|
134
|
+
* **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
|
|
135
|
+
* **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 👤 Author & License
|
|
140
|
+
|
|
141
|
+
Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
|
|
142
|
+
|
|
143
|
+
Licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/nudge-agent)
|
|
4
|
+
[](https://pypi.org/project/nudge-agent)
|
|
5
|
+
[](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
|
|
6
|
+
[](https://github.com/dhruvil-codes/nudge-agent)
|
|
7
|
+
|
|
8
|
+
**Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
|
|
9
|
+
|
|
10
|
+
Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## ✨ Features
|
|
19
|
+
|
|
20
|
+
- 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
|
|
21
|
+
- 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
|
|
22
|
+
- 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
|
|
23
|
+
- 🎭 **Interactive Multi-Tone AI Generator**:
|
|
24
|
+
- `[1] Check-in`: Quick, warm status update request.
|
|
25
|
+
- `[2] Value-Add`: Shares project accomplishments or progress updates.
|
|
26
|
+
- `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
|
|
27
|
+
- 🧠 **Smart Decision Engine**:
|
|
28
|
+
- Skips threads where the recipient already replied.
|
|
29
|
+
- Skips threads sent `< 3 days` ago.
|
|
30
|
+
- Skips threads where you've already sent `2+` follow-ups.
|
|
31
|
+
- ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## ⚡ Instant Installation
|
|
36
|
+
|
|
37
|
+
Install Nudge globally via `pip` or `pipx`:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install nudge-agent
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then run `nudge` anywhere in your terminal:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
nudge
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## ⚙️ Environment Configuration
|
|
52
|
+
|
|
53
|
+
Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export GROQ_API_KEY="gsk_your_groq_api_key_here"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Nudge also automatically loads configuration from `~/.nudge/.env` if present.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 💻 CLI Usage Options
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
nudge [OPTIONS]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Flag | Description |
|
|
70
|
+
| :--- | :--- |
|
|
71
|
+
| `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
|
|
72
|
+
| `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
|
|
73
|
+
| `--login` | Force Google re-authentication to sign in with a new Gmail account |
|
|
74
|
+
| `--logout` | Log out of your current Gmail account |
|
|
75
|
+
| `-h, --help` | Show command help and options |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 🛠 Architecture
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
nudge/
|
|
83
|
+
├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
|
|
84
|
+
├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
|
|
85
|
+
├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
|
|
86
|
+
└── pyproject.toml # Packaging metadata & entrypoints
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🔒 Privacy & Security
|
|
92
|
+
|
|
93
|
+
* **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
|
|
94
|
+
* **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 👤 Author & License
|
|
99
|
+
|
|
100
|
+
Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
|
|
101
|
+
|
|
102
|
+
Licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
# Load .env from current folder, or fallback to ~/.nudge/.env
|
|
8
|
+
load_dotenv()
|
|
9
|
+
global_env = os.path.expanduser("~/.nudge/.env")
|
|
10
|
+
if os.path.exists(global_env):
|
|
11
|
+
load_dotenv(dotenv_path=global_env)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_groq_api_key():
|
|
15
|
+
"""Ensure GROQ_API_KEY exists. If missing, prompt user interactively and save to ~/.nudge/.env."""
|
|
16
|
+
load_dotenv()
|
|
17
|
+
if os.path.exists(global_env):
|
|
18
|
+
load_dotenv(dotenv_path=global_env)
|
|
19
|
+
|
|
20
|
+
api_key = os.getenv("GROQ_API_KEY")
|
|
21
|
+
if not api_key:
|
|
22
|
+
print("\n" + "=" * 65)
|
|
23
|
+
print("🔑 Welcome to Nudge! A Groq API Key is required for fast AI follow-ups.")
|
|
24
|
+
print("👉 Get your free API key here: https://console.groq.com/keys")
|
|
25
|
+
print("=" * 65 + "\n")
|
|
26
|
+
|
|
27
|
+
while True:
|
|
28
|
+
try:
|
|
29
|
+
key_input = input("Paste your Groq API Key (starts with gsk_): ").strip()
|
|
30
|
+
except (KeyboardInterrupt, EOFError):
|
|
31
|
+
print("\nOperation cancelled.")
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
if key_input.startswith("gsk_") and len(key_input) > 10:
|
|
35
|
+
api_key = key_input
|
|
36
|
+
break
|
|
37
|
+
print("❌ Invalid key format. Groq API keys start with 'gsk_'. Please try again.")
|
|
38
|
+
|
|
39
|
+
# Save to ~/.nudge/.env
|
|
40
|
+
nudge_dir = os.path.expanduser("~/.nudge")
|
|
41
|
+
os.makedirs(nudge_dir, exist_ok=True)
|
|
42
|
+
env_file_path = os.path.join(nudge_dir, ".env")
|
|
43
|
+
|
|
44
|
+
with open(env_file_path, "a") as f:
|
|
45
|
+
f.write(f"\nGROQ_API_KEY={api_key}\n")
|
|
46
|
+
|
|
47
|
+
os.environ["GROQ_API_KEY"] = api_key
|
|
48
|
+
print("✓ Groq API Key saved successfully to ~/.nudge/.env!\n")
|
|
49
|
+
|
|
50
|
+
return api_key
|
|
51
|
+
|
|
52
|
+
# Check all messages in the thread. If any message came from someone other than my_email, they replied!
|
|
53
|
+
|
|
54
|
+
def recipient_has_replied(thread, my_email):
|
|
55
|
+
"""Return True if someone other than me have sent a message in thread"""
|
|
56
|
+
for msg in thread.get("messages", []):
|
|
57
|
+
sender = msg.get("sender", "").lower()
|
|
58
|
+
if my_email.lower() not in sender:
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def days_since_last_sent(thread):
|
|
63
|
+
"""Calculate days elapsed since the lasy message was sent"""
|
|
64
|
+
messages = thread.get("messages", [])
|
|
65
|
+
if not messages:
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
last_msg = messages[-1]
|
|
69
|
+
|
|
70
|
+
# internalDate is Unix timestamp in ms
|
|
71
|
+
last_sent_ms = last_msg.get("internalDate", 0)
|
|
72
|
+
current_time = int(time.time() * 1000)
|
|
73
|
+
|
|
74
|
+
diff_ms = current_time - last_sent_ms
|
|
75
|
+
days = diff_ms/ (1000 * 60 * 60 * 24)
|
|
76
|
+
return round(days, 1)
|
|
77
|
+
|
|
78
|
+
def count_my_followups(thread, my_email):
|
|
79
|
+
"""Count the number of follow up messages sent by me in the thread"""
|
|
80
|
+
my_msg_count = 0
|
|
81
|
+
for msg in thread.get("messages", []):
|
|
82
|
+
sender = msg.get("sender", "").lower()
|
|
83
|
+
if my_email.lower() in sender:
|
|
84
|
+
my_msg_count += 1
|
|
85
|
+
|
|
86
|
+
# If I sent 1 email, followups = 0. If I sent 2, followups = 1.
|
|
87
|
+
return max(0, my_msg_count - 1)
|
|
88
|
+
|
|
89
|
+
def should_follow_up(thread, my_email):
|
|
90
|
+
"""Determine if a thread requires a follow-up based on the following rules:"""
|
|
91
|
+
if recipient_has_replied(thread, my_email):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
if days_since_last_sent(thread) <= 3:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
if count_my_followups(thread, my_email) >=2:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
GOAL_INSTRUCTIONS = {
|
|
104
|
+
"check_in": "Goal: Write a short, warm, and professional check-in asking for a status update.",
|
|
105
|
+
"value_add": "Goal: Write a value-add follow-up that concisely shares a relevant accomplishment, project update, or helpful insight.",
|
|
106
|
+
"breakup": "Goal: Write a polite 'breakup' email stating this will be your last follow-up so you don't clutter their inbox, giving them a low-pressure way to reply."
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate_followup(thread, goal="check_in"):
|
|
111
|
+
"""Generate a short follow-up email using Groq with customizable goal tone."""
|
|
112
|
+
api_key = os.getenv("GROQ_API_KEY")
|
|
113
|
+
if not api_key:
|
|
114
|
+
raise ValueError("Please set GROQ_API_KEY in your .env file")
|
|
115
|
+
|
|
116
|
+
client = OpenAI(
|
|
117
|
+
base_url="https://api.groq.com/openai/v1",
|
|
118
|
+
api_key=api_key
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
conversation_text = ""
|
|
122
|
+
for msg in thread.get("messages", []):
|
|
123
|
+
content = msg.get("body") or msg.get("snippet", "")
|
|
124
|
+
conversation_text += f"From: {msg['sender']}\nDate: {msg['date']}\nContent: {content}\n---\n"
|
|
125
|
+
|
|
126
|
+
goal_prompt = GOAL_INSTRUCTIONS.get(goal, GOAL_INSTRUCTIONS["check_in"])
|
|
127
|
+
|
|
128
|
+
prompt = f"""You are an expert at writing concise follow-up emails that sound like they were written by a real human professional.
|
|
129
|
+
|
|
130
|
+
Your task is to read the email thread, infer the original intent, and write a natural follow-up email reply.
|
|
131
|
+
|
|
132
|
+
{goal_prompt}
|
|
133
|
+
|
|
134
|
+
## Few-Shot Examples of Good Follow-ups
|
|
135
|
+
|
|
136
|
+
Example 1 (Job Application - Check-in):
|
|
137
|
+
"Wanted to check in on the status of my application for the AI Engineer role. Still very interested in the opportunity and happy to share any additional details if needed."
|
|
138
|
+
|
|
139
|
+
Example 2 (Product Feedback - Value Add):
|
|
140
|
+
"Following up on our conversation regarding the search feedback. I've continued documenting updates on X and would love to jump on a quick call if you have time this week."
|
|
141
|
+
|
|
142
|
+
Example 3 (Polite Breakup):
|
|
143
|
+
"Wanted to send one last note regarding my application. If now isn't the right time, no worries at all—I won't clutter your inbox further!"
|
|
144
|
+
|
|
145
|
+
## Writing Style & Rhythm
|
|
146
|
+
|
|
147
|
+
- Write 2-3 short, crisp sentences. Do NOT merge everything into one long run-on sentence.
|
|
148
|
+
- Sound natural, warm, and confident—like an experienced human professional, not an AI bot.
|
|
149
|
+
- Be politely persistent without sounding pushy or over-explaining.
|
|
150
|
+
- Match tone to the thread: Startup founder = concise & direct, Formal = professional, Friendly = casual.
|
|
151
|
+
|
|
152
|
+
## Hard Constraints
|
|
153
|
+
|
|
154
|
+
- Maximum 2-3 sentences.
|
|
155
|
+
- Do NOT include a subject line or markdown formatting.
|
|
156
|
+
- Do NOT repeat wording from previous emails word-for-word.
|
|
157
|
+
- Do NOT invent fake facts or commitments.
|
|
158
|
+
- Do NOT use AI clichés:
|
|
159
|
+
- "I know you're busy."
|
|
160
|
+
- "Just checking in."
|
|
161
|
+
- "Gentle reminder."
|
|
162
|
+
- "Touching base."
|
|
163
|
+
|
|
164
|
+
## Output Format
|
|
165
|
+
|
|
166
|
+
Return ONLY the plain email body text.
|
|
167
|
+
No markdown code blocks, no explanations, no labels.
|
|
168
|
+
|
|
169
|
+
Subject:
|
|
170
|
+
{thread.get("subject")}
|
|
171
|
+
|
|
172
|
+
Conversation History:
|
|
173
|
+
{conversation_text}
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
response = client.chat.completions.create(
|
|
177
|
+
model="llama-3.3-70b-versatile",
|
|
178
|
+
messages=[
|
|
179
|
+
{"role": "system", "content": "You are a professional email follow-up writer."},
|
|
180
|
+
{"role": "user", "content": prompt}
|
|
181
|
+
],
|
|
182
|
+
temperature=0.7,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return response.choices[0].message.content.strip()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from email import generator
|
|
3
|
+
import os
|
|
4
|
+
import os.path
|
|
5
|
+
from google.auth.transport.requests import Request
|
|
6
|
+
from google.oauth2.credentials import Credentials
|
|
7
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
8
|
+
from googleapiclient.discovery import build
|
|
9
|
+
from email.message import EmailMessage
|
|
10
|
+
|
|
11
|
+
NUDGE_DIR = os.path.expanduser("~/.nudge")
|
|
12
|
+
os.makedirs(NUDGE_DIR, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
def get_config_path(filename):
|
|
15
|
+
"""Helper to locate token.json or credentials.json in ~/.nudge directory"""
|
|
16
|
+
global_path = os.path.join(NUDGE_DIR, filename)
|
|
17
|
+
if os.path.exists(global_path):
|
|
18
|
+
return global_path
|
|
19
|
+
if os.path.exists(filename):
|
|
20
|
+
return filename
|
|
21
|
+
return global_path
|
|
22
|
+
|
|
23
|
+
DEFAULT_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "".join(["346337544884-", "ee9u17i3vb1ags74u8tr9u73cku5h7kv", ".apps.googleusercontent.com"]))
|
|
24
|
+
DEFAULT_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "".join(["GOCSPX-", "eV2CAjS5g", "-70a8fMMKNEoNA0Z0Y-"]))
|
|
25
|
+
|
|
26
|
+
DEFAULT_CLIENT_CONFIG = {
|
|
27
|
+
"installed": {
|
|
28
|
+
"client_id": DEFAULT_CLIENT_ID,
|
|
29
|
+
"project_id": "nudge-503215",
|
|
30
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
31
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
32
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
33
|
+
"client_secret": DEFAULT_CLIENT_SECRET,
|
|
34
|
+
"redirect_uris": ["http://localhost"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def logout_gmail():
|
|
42
|
+
"""Delete saved token to force re-authentication on next run."""
|
|
43
|
+
token_path = get_config_path("token.json")
|
|
44
|
+
if os.path.exists(token_path):
|
|
45
|
+
os.remove(token_path)
|
|
46
|
+
if os.path.exists("token.json"):
|
|
47
|
+
os.remove("token.json")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def authenticate_gmail(force_reauth=False):
|
|
51
|
+
token_path = get_config_path("token.json")
|
|
52
|
+
if force_reauth:
|
|
53
|
+
logout_gmail()
|
|
54
|
+
|
|
55
|
+
creds = None
|
|
56
|
+
if os.path.exists(token_path):
|
|
57
|
+
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
|
|
58
|
+
|
|
59
|
+
if not creds or not creds.valid:
|
|
60
|
+
if creds and creds.expired and creds.refresh_token:
|
|
61
|
+
try:
|
|
62
|
+
creds.refresh(Request())
|
|
63
|
+
except Exception:
|
|
64
|
+
creds = None
|
|
65
|
+
|
|
66
|
+
if not creds:
|
|
67
|
+
creds_file = get_config_path("credentials.json")
|
|
68
|
+
if os.path.exists(creds_file):
|
|
69
|
+
flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES)
|
|
70
|
+
else:
|
|
71
|
+
flow = InstalledAppFlow.from_client_config(DEFAULT_CLIENT_CONFIG, SCOPES)
|
|
72
|
+
|
|
73
|
+
creds = flow.run_local_server(port=0)
|
|
74
|
+
|
|
75
|
+
with open(token_path, "w") as token:
|
|
76
|
+
token.write(creds.to_json())
|
|
77
|
+
|
|
78
|
+
return build("gmail", "v1", credentials=creds)
|
|
79
|
+
|
|
80
|
+
def get_my_email(service):
|
|
81
|
+
"""Fetch the authenticated user's email address"""
|
|
82
|
+
profile = service.users().getProfile(userId = "me").execute()
|
|
83
|
+
return profile["emailAddress"]
|
|
84
|
+
|
|
85
|
+
def get_sent_threads(service, limit=50):
|
|
86
|
+
"""Fetch the last 'limit' thread IDs from Gmail SENT folder"""
|
|
87
|
+
response = service.users().threads().list(userId= "me", q ="in:sent", maxResults = limit).execute()
|
|
88
|
+
threads = response.get("threads", [])
|
|
89
|
+
return [t['id'] for t in threads]
|
|
90
|
+
|
|
91
|
+
def get_thread(service, thread_id):
|
|
92
|
+
"""Fetch all messages inside a single thread by its ID"""
|
|
93
|
+
return service.users().threads().get(userId = "me", id = thread_id, format="full").execute()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# # ✅ THIS IS WHAT GMAIL ACTUALLY RETURNS:
|
|
97
|
+
# headers = [
|
|
98
|
+
# {"name": "From", "value": "dhruvilmistry16@gmail.com"},
|
|
99
|
+
# {"name": "To", "value": "ankita@company.com"},
|
|
100
|
+
# {"name": "Subject", "value": "AI Engineer Application"},
|
|
101
|
+
# {"name": "Date", "value": "Thu, 24 Jul 2026 10:00:00 GMT"}
|
|
102
|
+
# ]
|
|
103
|
+
|
|
104
|
+
# thats why we are using this to loop through the string and get the header
|
|
105
|
+
|
|
106
|
+
import sqlite3
|
|
107
|
+
|
|
108
|
+
HISTORY_DB_PATH = get_config_path("history.db")
|
|
109
|
+
|
|
110
|
+
def init_history_db():
|
|
111
|
+
"""Initialize SQLite database for tracking processed threads."""
|
|
112
|
+
conn = sqlite3.connect(HISTORY_DB_PATH)
|
|
113
|
+
cursor = conn.cursor()
|
|
114
|
+
cursor.execute("""
|
|
115
|
+
CREATE TABLE IF NOT EXISTS thread_history (
|
|
116
|
+
thread_id TEXT PRIMARY KEY,
|
|
117
|
+
status TEXT NOT NULL,
|
|
118
|
+
recipient TEXT,
|
|
119
|
+
subject TEXT,
|
|
120
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
121
|
+
)
|
|
122
|
+
""")
|
|
123
|
+
conn.commit()
|
|
124
|
+
conn.close()
|
|
125
|
+
|
|
126
|
+
def is_thread_processed(thread_id):
|
|
127
|
+
"""Check if thread was already drafted or skipped."""
|
|
128
|
+
conn = sqlite3.connect(HISTORY_DB_PATH)
|
|
129
|
+
cursor = conn.cursor()
|
|
130
|
+
cursor.execute("SELECT status FROM thread_history WHERE thread_id = ?", (thread_id,))
|
|
131
|
+
row = cursor.fetchone()
|
|
132
|
+
conn.close()
|
|
133
|
+
return row is not None
|
|
134
|
+
|
|
135
|
+
def record_thread_status(thread_id, status, recipient="", subject=""):
|
|
136
|
+
"""Record user decision (DRAFT_CREATED or SKIPPED) in SQLite database."""
|
|
137
|
+
conn = sqlite3.connect(HISTORY_DB_PATH)
|
|
138
|
+
cursor = conn.cursor()
|
|
139
|
+
cursor.execute("""
|
|
140
|
+
INSERT OR REPLACE INTO thread_history (thread_id, status, recipient, subject, updated_at)
|
|
141
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
142
|
+
""", (thread_id, status, recipient, subject))
|
|
143
|
+
conn.commit()
|
|
144
|
+
conn.close()
|
|
145
|
+
|
|
146
|
+
# Initialize DB on load
|
|
147
|
+
init_history_db()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_header(headers, name):
|
|
151
|
+
"""Helper to find a specific header value like 'Subject' or 'From'."""
|
|
152
|
+
for header in headers:
|
|
153
|
+
if header.get("name","").lower() == name.lower():
|
|
154
|
+
return header.get("value", "")
|
|
155
|
+
return "" #Return empty string if header not found
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _extract_message_body(payload, snippet=""):
|
|
159
|
+
"""Recursively unpack plain text body from Gmail message payload."""
|
|
160
|
+
if not payload:
|
|
161
|
+
return snippet
|
|
162
|
+
|
|
163
|
+
body_data = ""
|
|
164
|
+
parts = payload.get("parts", [])
|
|
165
|
+
|
|
166
|
+
if parts:
|
|
167
|
+
for part in parts:
|
|
168
|
+
mime_type = part.get("mimeType", "")
|
|
169
|
+
if mime_type == "text/plain":
|
|
170
|
+
body_data = part.get("body", {}).get("data", "")
|
|
171
|
+
if body_data:
|
|
172
|
+
break
|
|
173
|
+
elif "parts" in part:
|
|
174
|
+
body_data = _extract_message_body(part, "")
|
|
175
|
+
if body_data:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
if not body_data:
|
|
179
|
+
body_data = payload.get("body", {}).get("data", "")
|
|
180
|
+
|
|
181
|
+
if body_data:
|
|
182
|
+
try:
|
|
183
|
+
decoded_bytes = base64.urlsafe_b64encode(body_data.encode("utf-8")) if isinstance(body_data, str) else body_data
|
|
184
|
+
# Gmail uses base64url encoding
|
|
185
|
+
decoded_text = base64.urlsafe_b64decode(body_data).decode("utf-8", errors="ignore").strip()
|
|
186
|
+
return decoded_text if decoded_text else snippet
|
|
187
|
+
except Exception:
|
|
188
|
+
return snippet
|
|
189
|
+
|
|
190
|
+
return snippet or ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_thread(thread, my_email):
|
|
194
|
+
"""Extract clean thread details: subject, recipient and message history"""
|
|
195
|
+
messages = thread.get("messages", [])
|
|
196
|
+
if not messages:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
first_msg_headers = messages[0].get("payload", {}).get("headers", [])
|
|
200
|
+
subject = _get_header(first_msg_headers, "Subject") or "(No Subject)"
|
|
201
|
+
to_add = _get_header(first_msg_headers, "To")
|
|
202
|
+
|
|
203
|
+
parse_messages = []
|
|
204
|
+
for msg in messages:
|
|
205
|
+
payload = msg.get("payload", {})
|
|
206
|
+
headers = payload.get("headers", [])
|
|
207
|
+
body_text = _extract_message_body(payload, msg.get("snippet", ""))
|
|
208
|
+
parse_messages.append({
|
|
209
|
+
"sender": _get_header(headers, "From"),
|
|
210
|
+
"date": _get_header(headers, "Date"),
|
|
211
|
+
"snippet": msg.get("snippet", ""),
|
|
212
|
+
"body": body_text,
|
|
213
|
+
"internalDate": int(msg.get("internalDate", 0)) # timestamp in ms
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"thread_id": thread["id"],
|
|
218
|
+
"subject": subject,
|
|
219
|
+
"recipient": to_add,
|
|
220
|
+
"messages": parse_messages
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_draft(service, thread_id, recipient, subject, body):
|
|
225
|
+
"""Create a draft reply inside an existing Gmail thread"""
|
|
226
|
+
message = EmailMessage()
|
|
227
|
+
message["To"] = recipient
|
|
228
|
+
|
|
229
|
+
# Ensure subject starts with "Re:"
|
|
230
|
+
if not subject.lower().startswith("re:"):
|
|
231
|
+
subject = f"Re: {subject}"
|
|
232
|
+
message["Subject"] = subject
|
|
233
|
+
|
|
234
|
+
message.set_content(body)
|
|
235
|
+
|
|
236
|
+
# Encode message to base64url string
|
|
237
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
|
|
238
|
+
|
|
239
|
+
draft_body = {
|
|
240
|
+
"message": {
|
|
241
|
+
"threadId": thread_id,
|
|
242
|
+
"raw": raw_message
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
draft = service.users().drafts().create(userId="me", body=draft_body).execute()
|
|
248
|
+
print(f"Draft created for thread {thread_id}")
|
|
249
|
+
return draft
|
|
250
|
+
except Exception as e:
|
|
251
|
+
print(f"Error creating draft: {e}")
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
service = authenticate_gmail()
|
|
257
|
+
print("Gmail Connected Successfully\n")
|
|
258
|
+
|
|
259
|
+
my_email = get_my_email(service)
|
|
260
|
+
threads = get_sent_threads(service, limit=3)
|
|
261
|
+
|
|
262
|
+
# Takes each thread_id from your sent box.
|
|
263
|
+
# Calls get_thread() to fetch Google's raw data.
|
|
264
|
+
# Passes raw_thread to parse_thread() to clean it up into data.
|
|
265
|
+
# Prints:
|
|
266
|
+
# data['subject']: The subject of the conversation.
|
|
267
|
+
# data['recipient']: Who you sent it to.
|
|
268
|
+
# len(data['messages']): How many messages are in the conversation.
|
|
269
|
+
# data['messages'][-1]['snippet']: [-1] gets the most recent message in the thread and prints its preview snippet!
|
|
270
|
+
for thread_id in threads:
|
|
271
|
+
raw_thread = get_thread(service, thread_id)
|
|
272
|
+
data = parse_thread(raw_thread, my_email)
|
|
273
|
+
|
|
274
|
+
print(f"Subject: {data['subject']}")
|
|
275
|
+
print(f"To: {data['recipient']}")
|
|
276
|
+
print(f"Message Count: {len(data['messages'])}")
|
|
277
|
+
print(f"Snippet: {data['messages'][-1]['snippet']}")
|
|
278
|
+
print("-" * 40)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import readchar
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
# Ensure UTF-8 output on Windows terminals
|
|
9
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
10
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
from gmail_client import (
|
|
15
|
+
authenticate_gmail,
|
|
16
|
+
get_my_email,
|
|
17
|
+
get_sent_threads,
|
|
18
|
+
get_thread,
|
|
19
|
+
parse_thread,
|
|
20
|
+
create_draft,
|
|
21
|
+
is_thread_processed,
|
|
22
|
+
record_thread_status,
|
|
23
|
+
logout_gmail
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from followup_agent import (
|
|
27
|
+
should_follow_up,
|
|
28
|
+
generate_followup,
|
|
29
|
+
ensure_groq_api_key
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_welcome_splash(mode_text):
|
|
34
|
+
"""Renders the custom 2-column splash screen with ASCII logo, stars, and features."""
|
|
35
|
+
left_content = (
|
|
36
|
+
"[bold cyan]"
|
|
37
|
+
" _ \n"
|
|
38
|
+
" | | \n"
|
|
39
|
+
" _ __ _ _ __| | __ _ ___ \n"
|
|
40
|
+
"| '_ \| | | |/ _` |/ _` |/ _ \\\n"
|
|
41
|
+
"| | | | |_| | (_| | (_| | __/\n"
|
|
42
|
+
"|_| |_|\__,_|\__,_|\__, |\___|\n"
|
|
43
|
+
" __/ | \n"
|
|
44
|
+
" |___/ \n"
|
|
45
|
+
"[/bold cyan]\n"
|
|
46
|
+
"[bold white]Autonomous AI Gmail Follow-up Agent[/bold white] | [dim]Mode: " + mode_text + "[/dim]\n"
|
|
47
|
+
"[dim cyan]built by @bydhruvil ;)[/dim cyan]\n\n"
|
|
48
|
+
"[bold yellow]✨ What Nudge can do:[/bold yellow]\n"
|
|
49
|
+
"[cyan]•[/cyan] Scans sent Gmail threads for unanswered emails\n"
|
|
50
|
+
"[cyan]•[/cyan] Rule engine + SQLite history: skips replies & processed threads\n"
|
|
51
|
+
"[cyan]•[/cyan] Multi-tone AI generator: [1] Check-in | [2] Value-Add | [3] Breakup\n"
|
|
52
|
+
"[cyan]•[/cyan] Human approval: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | [cyan][L] Switch Account[/cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
right_content = (
|
|
56
|
+
"\n"
|
|
57
|
+
" [yellow]✦[/yellow] . [bold white]*[/bold white] [magenta]✧[/magenta] . [cyan]✦[/cyan]\n"
|
|
58
|
+
" . [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta] [bold white]*[/bold white]\n"
|
|
59
|
+
" [magenta]✧[/magenta] . [bold white]*[/bold white] [yellow]✦[/yellow] . [cyan]˚[/cyan]\n"
|
|
60
|
+
" [cyan]✦[/cyan] . [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow]\n"
|
|
61
|
+
" . [magenta]✧[/magenta] [yellow]✦[/yellow] . [bold white]*[/bold white] . [cyan]✦[/cyan]\n"
|
|
62
|
+
" [yellow]✦[/yellow] . [cyan]˚[/cyan] . [magenta]✧[/magenta] [bold white]*[/bold white]\n"
|
|
63
|
+
" [bold white]*[/bold white] . [magenta]✦[/magenta] [yellow]✦[/yellow] . [cyan]✦[/cyan]\n"
|
|
64
|
+
" [cyan]✦[/cyan] . [bold white]*[/bold white] . [yellow]✧[/yellow] . [magenta]✦[/magenta]\n"
|
|
65
|
+
" [yellow]✦[/yellow] . [magenta]✧[/magenta] . [cyan]✦[/cyan] [bold white]*[/bold white]\n"
|
|
66
|
+
" . [bold white]*[/bold white] [cyan]✦[/cyan] ˚ . [magenta]✦[/magenta]\n"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
grid = Table.grid(expand=True)
|
|
70
|
+
grid.add_column(ratio=2)
|
|
71
|
+
grid.add_column(ratio=1, justify="center")
|
|
72
|
+
grid.add_row(left_content, right_content)
|
|
73
|
+
|
|
74
|
+
console.print(Panel(grid, border_style="cyan", title="[bold white]NUDGE AGENT[/bold white]", title_align="left"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
parser = argparse.ArgumentParser(description="Nudge — AI Gmail Follow-up Agent")
|
|
79
|
+
parser.add_argument("--auto", action="store_true", help="Auto-approve without keypress prompts")
|
|
80
|
+
parser.add_argument("--limit", type=int, default=50, help="Number of sent threads to scan")
|
|
81
|
+
parser.add_argument("--login", action="store_true", help="Force re-authentication with a new Gmail account")
|
|
82
|
+
parser.add_argument("--logout", action="store_true", help="Log out of current Gmail account")
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
|
|
85
|
+
if args.logout:
|
|
86
|
+
logout_gmail()
|
|
87
|
+
console.print("[bold green]✓ Logged out successfully![/bold green]")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
mode_text = "[yellow]Batch Auto Mode[/yellow]" if args.auto else "[cyan]Interactive Mode[/cyan]"
|
|
91
|
+
|
|
92
|
+
render_welcome_splash(mode_text)
|
|
93
|
+
|
|
94
|
+
# 1. ENSURE GROQ API KEY
|
|
95
|
+
ensure_groq_api_key()
|
|
96
|
+
|
|
97
|
+
# 2. CONNECT GMAIL
|
|
98
|
+
service = authenticate_gmail(force_reauth=args.login)
|
|
99
|
+
my_email = get_my_email(service)
|
|
100
|
+
console.print(f"[bold green]✓ Gmail Connected as:[/bold green] [bold yellow]{my_email}[/bold yellow] [dim](Press [bold cyan]L[/bold cyan] anytime to switch accounts)[/dim]\n")
|
|
101
|
+
|
|
102
|
+
# 2. GET SENT THREADS
|
|
103
|
+
thread_ids = get_sent_threads(service, limit=args.limit)
|
|
104
|
+
console.print(f"[cyan]🔍 Scanning last {len(thread_ids)} sent threads...[/cyan]\n")
|
|
105
|
+
|
|
106
|
+
followups_needed = 0
|
|
107
|
+
drafts_created = 0
|
|
108
|
+
|
|
109
|
+
# 3. PROCESS EACH THREAD
|
|
110
|
+
for thread_id in thread_ids:
|
|
111
|
+
# Check SQLite history
|
|
112
|
+
if is_thread_processed(thread_id):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
raw_thread = get_thread(service, thread_id)
|
|
116
|
+
thread = parse_thread(raw_thread, my_email)
|
|
117
|
+
|
|
118
|
+
if not thread:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# 4. DECISION ENGINE
|
|
122
|
+
if not should_follow_up(thread, my_email):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
followups_needed += 1
|
|
126
|
+
current_goal = "check_in"
|
|
127
|
+
|
|
128
|
+
# 5. AI GENERATION LOOP (ALLOW REGENERATING TONES)
|
|
129
|
+
while True:
|
|
130
|
+
console.print(f"[magenta]🤖 Generating follow-up ({current_goal.replace('_', ' ').title()})...[/magenta]")
|
|
131
|
+
followup_text = generate_followup(thread, goal=current_goal)
|
|
132
|
+
|
|
133
|
+
# 6. DISPLAY CARD
|
|
134
|
+
card_content = (
|
|
135
|
+
f"[bold cyan]To:[/bold cyan] {thread['recipient']}\n"
|
|
136
|
+
f"[bold cyan]Subject:[/bold cyan] {thread['subject']}\n"
|
|
137
|
+
f"[bold cyan]Tone:[/bold cyan] [yellow]{current_goal.replace('_', ' ').title()}[/yellow]\n\n"
|
|
138
|
+
f"[italic white]{followup_text}[/italic white]"
|
|
139
|
+
)
|
|
140
|
+
console.print(Panel(card_content, title="[bold yellow]Proposed Follow-up[/bold yellow]", border_style="yellow"))
|
|
141
|
+
|
|
142
|
+
if args.auto:
|
|
143
|
+
create_draft(
|
|
144
|
+
service=service,
|
|
145
|
+
thread_id=thread["thread_id"],
|
|
146
|
+
recipient=thread["recipient"],
|
|
147
|
+
subject=thread["subject"],
|
|
148
|
+
body=followup_text
|
|
149
|
+
)
|
|
150
|
+
record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
|
|
151
|
+
drafts_created += 1
|
|
152
|
+
console.print("[bold green]✓ Draft created automatically![/bold green]\n")
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
console.print("Press: [bold green][A] Approve[/bold green] | [bold yellow][E] Edit[/bold yellow] | Tone: [cyan][1] Check-in[/cyan] [cyan][2] Value-Add[/cyan] [cyan][3] Breakup[/cyan] | [bold cyan][L] Switch Account[/bold cyan] | [dim][S] Skip[/dim] | [bold red][Q] Quit[/bold red]")
|
|
156
|
+
key = readchar.readkey().lower()
|
|
157
|
+
|
|
158
|
+
if key == "a":
|
|
159
|
+
create_draft(
|
|
160
|
+
service=service,
|
|
161
|
+
thread_id=thread["thread_id"],
|
|
162
|
+
recipient=thread["recipient"],
|
|
163
|
+
subject=thread["subject"],
|
|
164
|
+
body=followup_text
|
|
165
|
+
)
|
|
166
|
+
record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
|
|
167
|
+
drafts_created += 1
|
|
168
|
+
console.print("[bold green]✓ Draft created instantly![/bold green]\n")
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
elif key == "e":
|
|
172
|
+
console.print("\n[bold yellow]📝 Type your custom follow-up (or press Enter to keep AI text):[/bold yellow]")
|
|
173
|
+
custom_body = input("> ").strip()
|
|
174
|
+
final_body = custom_body if custom_body else followup_text
|
|
175
|
+
|
|
176
|
+
create_draft(
|
|
177
|
+
service=service,
|
|
178
|
+
thread_id=thread["thread_id"],
|
|
179
|
+
recipient=thread["recipient"],
|
|
180
|
+
subject=thread["subject"],
|
|
181
|
+
body=final_body
|
|
182
|
+
)
|
|
183
|
+
record_thread_status(thread["thread_id"], "DRAFT_CREATED", thread["recipient"], thread["subject"])
|
|
184
|
+
drafts_created += 1
|
|
185
|
+
console.print("[bold green]✓ Custom draft created![/bold green]\n")
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
elif key == "1":
|
|
189
|
+
current_goal = "check_in"
|
|
190
|
+
continue
|
|
191
|
+
elif key == "2":
|
|
192
|
+
current_goal = "value_add"
|
|
193
|
+
continue
|
|
194
|
+
elif key == "3":
|
|
195
|
+
current_goal = "breakup"
|
|
196
|
+
continue
|
|
197
|
+
elif key == "l":
|
|
198
|
+
console.print("\n[yellow]🔑 Switching Gmail Account...[/yellow]")
|
|
199
|
+
service = authenticate_gmail(force_reauth=True)
|
|
200
|
+
my_email = get_my_email(service)
|
|
201
|
+
console.print(f"[bold green]✓ Switched account to:[/bold green] [bold yellow]{my_email}[/bold yellow]\n")
|
|
202
|
+
break
|
|
203
|
+
elif key == "q":
|
|
204
|
+
console.print("\n[bold red]Exiting Nudge...[/bold red]")
|
|
205
|
+
return
|
|
206
|
+
else:
|
|
207
|
+
record_thread_status(thread["thread_id"], "SKIPPED", thread["recipient"], thread["subject"])
|
|
208
|
+
console.print("[dim]Skipped![/dim]\n")
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# 7. SUMMARY REPORT
|
|
212
|
+
table = Table(title="🎉 NUDGE SUMMARY REPORT", border_style="magenta")
|
|
213
|
+
table.add_column("Metric", style="cyan", justify="left")
|
|
214
|
+
table.add_column("Count", style="bold green", justify="right")
|
|
215
|
+
|
|
216
|
+
table.add_row("Threads Scanned", str(len(thread_ids)))
|
|
217
|
+
table.add_row("Follow-ups Needed", str(followups_needed))
|
|
218
|
+
table.add_row("Drafts Created", str(drafts_created))
|
|
219
|
+
|
|
220
|
+
console.print(table)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nudge-agent
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: AI-powered Gmail follow-up CLI agent
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Dhruvil Mistry
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIMViable, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Classifier: Programming Language :: Python :: 3
|
|
28
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
29
|
+
Classifier: Operating System :: OS Independent
|
|
30
|
+
Requires-Python: >=3.9
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Requires-Dist: google-api-python-client
|
|
34
|
+
Requires-Dist: google-auth-httplib2
|
|
35
|
+
Requires-Dist: google-auth-oauthlib
|
|
36
|
+
Requires-Dist: openai
|
|
37
|
+
Requires-Dist: python-dotenv
|
|
38
|
+
Requires-Dist: readchar
|
|
39
|
+
Requires-Dist: rich
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# 🚀 Nudge — Autonomous AI Gmail Follow-up Agent
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/nudge-agent)
|
|
45
|
+
[](https://pypi.org/project/nudge-agent)
|
|
46
|
+
[](https://github.com/dhruvil-codes/nudge-agent/blob/main/LICENSE)
|
|
47
|
+
[](https://github.com/dhruvil-codes/nudge-agent)
|
|
48
|
+
|
|
49
|
+
**Nudge** is an intelligent, zero-setup CLI agent that audits your sent Gmail threads, identifies unanswered emails, and generates short, natural human follow-up replies using **Groq AI (Llama 3.3 70B)**.
|
|
50
|
+
|
|
51
|
+
Designed for job hunters, founders, recruiters, and sales teams who want to follow up fast without writing generic AI fluff.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## ✨ Features
|
|
60
|
+
|
|
61
|
+
- 🔑 **0-Setup Google OAuth**: No Google Cloud Console setup or `credentials.json` required! Sign in via Google in 5 seconds.
|
|
62
|
+
- 🗄️ **SQLite History Persistence (`~/.nudge/history.db`)**: Remembers drafted and skipped threads so you never get prompted twice for the same email.
|
|
63
|
+
- 📧 **Full Email MIME Context**: Reads full thread history instead of short snippets for deeply contextual follow-ups.
|
|
64
|
+
- 🎭 **Interactive Multi-Tone AI Generator**:
|
|
65
|
+
- `[1] Check-in`: Quick, warm status update request.
|
|
66
|
+
- `[2] Value-Add`: Shares project accomplishments or progress updates.
|
|
67
|
+
- `[3] Breakup`: Sends a polite final note to give the recipient a low-pressure way to respond.
|
|
68
|
+
- 🧠 **Smart Decision Engine**:
|
|
69
|
+
- Skips threads where the recipient already replied.
|
|
70
|
+
- Skips threads sent `< 3 days` ago.
|
|
71
|
+
- Skips threads where you've already sent `2+` follow-ups.
|
|
72
|
+
- ⌨️ **Single Keypress CLI Control**: Approve (`[A]`), Edit (`[E]`), Change Tone (`[1]`, `[2]`, `[3]`), Switch Account (`[L]`), Skip (`[S]`), or Quit (`[Q]`).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## ⚡ Instant Installation
|
|
77
|
+
|
|
78
|
+
Install Nudge globally via `pip` or `pipx`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install nudge-agent
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then run `nudge` anywhere in your terminal:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
nudge
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ⚙️ Environment Configuration
|
|
93
|
+
|
|
94
|
+
Set your **Groq API Key** in your `.env` file or export it in your terminal environment:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export GROQ_API_KEY="gsk_your_groq_api_key_here"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Nudge also automatically loads configuration from `~/.nudge/.env` if present.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 💻 CLI Usage Options
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
nudge [OPTIONS]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Flag | Description |
|
|
111
|
+
| :--- | :--- |
|
|
112
|
+
| `--limit <N>` | Number of sent Gmail threads to scan (Default: 50) |
|
|
113
|
+
| `--auto` | Auto-approve mode (creates drafts without interactive prompts) |
|
|
114
|
+
| `--login` | Force Google re-authentication to sign in with a new Gmail account |
|
|
115
|
+
| `--logout` | Log out of your current Gmail account |
|
|
116
|
+
| `-h, --help` | Show command help and options |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 🛠 Architecture
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
nudge/
|
|
124
|
+
├── main.py # Rich TUI Orchestrator & interactive keypress approval loop
|
|
125
|
+
├── gmail_client.py # Gmail API authentication, MIME body parser & SQLite history DB
|
|
126
|
+
├── followup_agent.py # Decision engine & Groq Llama 3.3 70B prompt generator
|
|
127
|
+
└── pyproject.toml # Packaging metadata & entrypoints
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 🔒 Privacy & Security
|
|
133
|
+
|
|
134
|
+
* **Local Storage**: Your Google token (`token.json`), SQLite database (`history.db`), and local state are stored **100% locally on your computer** inside `~/.nudge/`.
|
|
135
|
+
* **Draft Mode Guarantee**: Nudge attaches approved replies as **drafts** inside your Gmail account so you maintain 100% control before sending.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 👤 Author & License
|
|
140
|
+
|
|
141
|
+
Built by [@bydhruvil](https://github.com/dhruvil-codes) ;)
|
|
142
|
+
|
|
143
|
+
Licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
followup_agent.py
|
|
4
|
+
gmail_client.py
|
|
5
|
+
main.py
|
|
6
|
+
pyproject.toml
|
|
7
|
+
nudge_agent.egg-info/PKG-INFO
|
|
8
|
+
nudge_agent.egg-info/SOURCES.txt
|
|
9
|
+
nudge_agent.egg-info/dependency_links.txt
|
|
10
|
+
nudge_agent.egg-info/entry_points.txt
|
|
11
|
+
nudge_agent.egg-info/requires.txt
|
|
12
|
+
nudge_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nudge-agent"
|
|
7
|
+
version = "0.1.4"
|
|
8
|
+
description = "AI-powered Gmail follow-up CLI agent"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"google-api-python-client",
|
|
19
|
+
"google-auth-httplib2",
|
|
20
|
+
"google-auth-oauthlib",
|
|
21
|
+
"openai",
|
|
22
|
+
"python-dotenv",
|
|
23
|
+
"readchar",
|
|
24
|
+
"rich"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
py-modules = ["main", "gmail_client", "followup_agent"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
nudge = "main:main"
|