draftpilot 0.1.0__py3-none-any.whl

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.
draftpilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DraftPilot - Dead man's switch email outreach with AI personalization."""
2
+
3
+ __version__ = "0.1.0"
draftpilot/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m draftpilot`."""
2
+
3
+ from draftpilot.cli import app
4
+
5
+ app()
draftpilot/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """DraftPilot CLI - Dead man's switch email outreach with AI personalization."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ app = typer.Typer(
7
+ name="draftpilot",
8
+ help="Dead man's switch email outreach with AI personalization.",
9
+ no_args_is_help=True,
10
+ )
11
+ console = Console()
12
+
13
+
14
+ @app.command()
15
+ def draft(
16
+ batch: int = typer.Option(50, help="Number of emails per batch"),
17
+ csv: str = typer.Option("companies.csv", help="Path to companies CSV"),
18
+ delay: int = typer.Option(6, help="Hours before auto-send"),
19
+ dry_run: bool = typer.Option(False, "--dry-run", help="Generate without creating drafts"),
20
+ ):
21
+ """Generate personalized email drafts from a CSV of companies."""
22
+ from draftpilot.drafts import run
23
+ run(batch, csv, delay, dry_run)
24
+
25
+
26
+ @app.command()
27
+ def send():
28
+ """Check drafts and auto-send those past the validation window."""
29
+ from draftpilot.sender import run
30
+ run()
31
+
32
+
33
+ @app.command()
34
+ def status(
35
+ today: bool = typer.Option(False, "--today", help="Show today's stats only"),
36
+ list_all: bool = typer.Option(False, "--list", help="List all emails"),
37
+ check_drafts: bool = typer.Option(False, "--check-drafts", help="Check drafts for issues"),
38
+ ):
39
+ """Display outreach pipeline statistics."""
40
+ from draftpilot.dashboard import show_stats, list_emails, check_draft_issues
41
+ show_stats(today_only=today)
42
+ if list_all:
43
+ list_emails()
44
+ if check_drafts:
45
+ check_draft_issues()
46
+
47
+
48
+ @app.command()
49
+ def find(
50
+ csv: str = typer.Option(..., help="CSV with company_name column"),
51
+ output: str = typer.Option(None, help="Output CSV path"),
52
+ no_rekrute: bool = typer.Option(False, "--no-rekrute", help="Skip Rekrute.com"),
53
+ no_hunter: bool = typer.Option(False, "--no-hunter", help="Skip Hunter.io"),
54
+ no_llm: bool = typer.Option(False, "--no-llm", help="Skip LLM descriptions"),
55
+ ):
56
+ """Find recruitment emails for companies via web scraping."""
57
+ from draftpilot.finder import run
58
+ run(csv, output, not no_rekrute, not no_hunter, not no_llm)
59
+
60
+
61
+ @app.command()
62
+ def validate(
63
+ csv: str = typer.Option("companies.csv", help="Path to companies CSV"),
64
+ ):
65
+ """Validate email addresses (syntax + MX record check)."""
66
+ from draftpilot.validator import run
67
+ run(csv)
68
+
69
+
70
+ @app.command()
71
+ def auth():
72
+ """Authenticate with Gmail (first-time OAuth setup)."""
73
+ from draftpilot.gmail import setup_auth
74
+ setup_auth()
75
+
76
+
77
+ if __name__ == "__main__":
78
+ app()
draftpilot/config.py ADDED
@@ -0,0 +1,168 @@
1
+ """Central configuration for DraftPilot.
2
+
3
+ Resolves config directory, loads profile/templates, provides LLM client
4
+ and signature rendering. All personal info comes from profile.yaml.
5
+ """
6
+
7
+ import os
8
+ from string import Template
9
+ from pathlib import Path
10
+ import yaml
11
+
12
+ _profile_cache = None
13
+ _templates_cache = None
14
+ _llm_client = None
15
+
16
+
17
+ def _resolve_config_dir() -> Path:
18
+ """Find the config directory. Priority: env var > ./config/ > ~/.draftpilot/"""
19
+ env = os.environ.get("DRAFTPILOT_CONFIG")
20
+ if env:
21
+ return Path(env).expanduser()
22
+
23
+ local = Path("config")
24
+ if local.is_dir() and (local / "profile.yaml").exists():
25
+ return local
26
+
27
+ home = Path.home() / ".draftpilot"
28
+ return home
29
+
30
+
31
+ def get_config_dir() -> Path:
32
+ return _resolve_config_dir()
33
+
34
+
35
+ def get_profile() -> dict:
36
+ global _profile_cache
37
+ if _profile_cache is not None:
38
+ return _profile_cache
39
+
40
+ config_dir = _resolve_config_dir()
41
+ profile_path = config_dir / "profile.yaml"
42
+
43
+ if not profile_path.exists():
44
+ raise FileNotFoundError(
45
+ f"Profile not found at {profile_path}. "
46
+ f"Copy examples/profile.yaml to {config_dir}/ and fill in your info."
47
+ )
48
+
49
+ with open(profile_path, "r", encoding="utf-8") as f:
50
+ _profile_cache = yaml.safe_load(f)
51
+
52
+ return _profile_cache
53
+
54
+
55
+ def get_templates() -> dict:
56
+ global _templates_cache
57
+ if _templates_cache is not None:
58
+ return _templates_cache
59
+
60
+ config_dir = _resolve_config_dir()
61
+ templates_path = config_dir / "templates.yaml"
62
+
63
+ if not templates_path.exists():
64
+ raise FileNotFoundError(
65
+ f"Templates not found at {templates_path}. "
66
+ f"Copy examples/templates.yaml to {config_dir}/."
67
+ )
68
+
69
+ with open(templates_path, "r", encoding="utf-8") as f:
70
+ _templates_cache = yaml.safe_load(f)
71
+
72
+ return _templates_cache
73
+
74
+
75
+ def get_db_path() -> str:
76
+ config_dir = _resolve_config_dir()
77
+ return str(config_dir / "state.db")
78
+
79
+
80
+ def get_llm_client():
81
+ global _llm_client
82
+ if _llm_client is not None:
83
+ return _llm_client
84
+
85
+ from openai import OpenAI
86
+
87
+ profile = get_profile()
88
+ llm = profile.get("llm", {})
89
+
90
+ base_url = llm.get("base_url", "https://api.cerebras.ai/v1")
91
+ api_key_env = llm.get("api_key_env", "CEREBRAS_API_KEY")
92
+ api_key = os.environ.get(api_key_env)
93
+
94
+ if not api_key:
95
+ raise ValueError(
96
+ f"{api_key_env} not set. Export it: export {api_key_env}='your-key-here'"
97
+ )
98
+
99
+ _llm_client = OpenAI(base_url=base_url, api_key=api_key)
100
+ return _llm_client
101
+
102
+
103
+ def get_gmail_paths() -> tuple:
104
+ """Returns (credentials_path, token_path)."""
105
+ profile = get_profile()
106
+ gmail = profile.get("gmail", {})
107
+ config_dir = _resolve_config_dir()
108
+
109
+ creds = gmail.get("credentials_path", "credentials.json")
110
+ token = gmail.get("token_path", "token.json")
111
+
112
+ creds_path = Path(creds).expanduser()
113
+ token_path = Path(token).expanduser()
114
+
115
+ if not creds_path.is_absolute():
116
+ creds_path = config_dir / creds_path
117
+ if not token_path.is_absolute():
118
+ token_path = config_dir / token_path
119
+
120
+ return str(creds_path), str(token_path)
121
+
122
+
123
+ def _build_template_context(profile: dict) -> dict:
124
+ """Build a flat dict for Template substitution."""
125
+ edu = profile.get("education", {})
126
+ return {
127
+ "name": profile.get("name", ""),
128
+ "phone": profile.get("phone", ""),
129
+ "portfolio": profile.get("portfolio", ""),
130
+ "cv": profile.get("cv", ""),
131
+ "school_short": edu.get("school_short", ""),
132
+ "degree_short_fr": edu.get("degree_short_fr", ""),
133
+ "degree_short_en": edu.get("degree_short_en", ""),
134
+ }
135
+
136
+
137
+ def render_signature(language: str, profile: dict = None) -> str:
138
+ """Render the email signature from profile config."""
139
+ if profile is None:
140
+ profile = get_profile()
141
+
142
+ sig_templates = profile.get("signature", {})
143
+ lang_key = "fr" if language.lower() == "fr" else "en"
144
+ sig_template = sig_templates.get(lang_key, "$name")
145
+
146
+ ctx = _build_template_context(profile)
147
+ return Template(sig_template).safe_substitute(ctx)
148
+
149
+
150
+ def render_subject_fallback(language: str, profile: dict = None) -> str:
151
+ """Render the subject line fallback from profile config."""
152
+ if profile is None:
153
+ profile = get_profile()
154
+
155
+ fallbacks = profile.get("subject_fallback", {})
156
+ lang_key = "fr" if language.lower() == "fr" else "en"
157
+ fallback = fallbacks.get(lang_key, "")
158
+
159
+ ctx = _build_template_context(profile)
160
+ return Template(fallback).safe_substitute(ctx)
161
+
162
+
163
+ def reset_cache():
164
+ """Reset all cached config. Useful for testing."""
165
+ global _profile_cache, _templates_cache, _llm_client
166
+ _profile_cache = None
167
+ _templates_cache = None
168
+ _llm_client = None
@@ -0,0 +1,173 @@
1
+ """Status dashboard for the outreach pipeline.
2
+
3
+ Displays statistics, lists emails, and checks drafted emails for common issues.
4
+ """
5
+
6
+ from datetime import datetime
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from draftpilot import db
13
+
14
+ console = Console()
15
+
16
+
17
+ def show_stats(today_only: bool = False):
18
+ stats = db.get_today_stats() if today_only else db.get_stats()
19
+ period = "Today" if today_only else "All time"
20
+
21
+ total = sum(stats.values())
22
+ drafted = stats.get("drafted", 0)
23
+ sent = stats.get("sent", 0)
24
+ cancelled = stats.get("cancelled", 0)
25
+ errors = stats.get("error", 0)
26
+ pending = stats.get("pending", 0)
27
+
28
+ lines = [
29
+ f"Period: {period}",
30
+ f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
31
+ "",
32
+ f"Total: {total}",
33
+ f"Pending: {pending}",
34
+ f"Drafted: {drafted} (awaiting review)",
35
+ f"Sent: {sent}",
36
+ f"Cancelled: {cancelled} (you deleted the draft)",
37
+ f"Errors: {errors}",
38
+ ]
39
+ if total > 0:
40
+ lines.append(f"Send rate: {sent / total * 100:.1f}%")
41
+ if cancelled > 0:
42
+ lines.append(f"Veto rate: {cancelled / total * 100:.1f}%")
43
+
44
+ console.print(Panel("\n".join(lines), title="Email Outreach Dashboard", border_style="blue"))
45
+
46
+
47
+ def list_emails():
48
+ conn = db.get_conn()
49
+ rows = conn.execute("""
50
+ SELECT id, company_name, email, status, template_used, subject,
51
+ created_at, sent_at
52
+ FROM emails
53
+ ORDER BY created_at DESC
54
+ LIMIT 100
55
+ """).fetchall()
56
+ conn.close()
57
+
58
+ if not rows:
59
+ console.print("\nNo emails in the database yet.\n")
60
+ return
61
+
62
+ table = Table(title="Recent Emails (last 100)")
63
+ table.add_column("ID", style="dim", width=5)
64
+ table.add_column("Status", width=11)
65
+ table.add_column("Company", width=25)
66
+ table.add_column("Email", width=30)
67
+ table.add_column("Subject", width=40)
68
+
69
+ status_labels = {
70
+ "pending": "[yellow]PENDING[/yellow]",
71
+ "drafted": "[cyan]DRAFTED[/cyan]",
72
+ "sent": "[green]SENT[/green]",
73
+ "cancelled": "[red]CANCELLED[/red]",
74
+ "error": "[bold red]ERROR[/bold red]",
75
+ }
76
+
77
+ for row in rows:
78
+ label = status_labels.get(row["status"], row["status"])
79
+ subject = (row["subject"] or "")[:38]
80
+ company = (row["company_name"] or "")[:23]
81
+ email = (row["email"] or "")[:28]
82
+
83
+ table.add_row(str(row["id"]), label, company, email, subject)
84
+
85
+ console.print()
86
+ console.print(table)
87
+ console.print()
88
+
89
+
90
+ def check_draft_issues():
91
+ """Check drafted emails for common issues.
92
+
93
+ Looks for:
94
+ - Empty or very short body text
95
+ - Wrong call-to-action dates (e.g. dates in the past)
96
+ - Duplicate or misplaced signatures
97
+ """
98
+ from draftpilot.config import get_profile
99
+ profile = get_profile()
100
+ user_name = profile.get("name", "")
101
+
102
+ conn = db.get_conn()
103
+ rows = conn.execute("""
104
+ SELECT id, company_name, email, subject, body
105
+ FROM emails
106
+ WHERE status = 'drafted'
107
+ ORDER BY created_at DESC
108
+ """).fetchall()
109
+ conn.close()
110
+
111
+ if not rows:
112
+ console.print("\nNo drafted emails to check.\n")
113
+ return
114
+
115
+ issues_found = 0
116
+
117
+ table = Table(title="Draft Issues Report")
118
+ table.add_column("ID", style="dim", width=5)
119
+ table.add_column("Company", width=25)
120
+ table.add_column("Issue", width=50)
121
+
122
+ for row in rows:
123
+ row_issues = []
124
+ body = row["body"] or ""
125
+ subject = row["subject"] or ""
126
+
127
+ # Check for empty or very short body
128
+ if len(body.strip()) < 50:
129
+ row_issues.append("Body is empty or very short (under 50 chars)")
130
+
131
+ # Check for empty subject
132
+ if len(subject.strip()) == 0:
133
+ row_issues.append("Subject line is empty")
134
+
135
+ # Check for wrong call dates (dates in the past)
136
+ now = datetime.now()
137
+ import re
138
+ date_patterns = re.findall(r'\d{1,2}/\d{1,2}/\d{4}', body)
139
+ for date_str in date_patterns:
140
+ try:
141
+ d = datetime.strptime(date_str, "%d/%m/%Y")
142
+ if d < now:
143
+ row_issues.append(f"Past date found in body: {date_str}")
144
+ except ValueError:
145
+ pass
146
+
147
+ # Check for duplicate signatures
148
+ if user_name:
149
+ name_count = body.lower().count(user_name.lower())
150
+ if name_count > 1:
151
+ row_issues.append(
152
+ f"Possible duplicate signature ({user_name} appears {name_count} times)"
153
+ )
154
+
155
+ for issue in row_issues:
156
+ company = (row["company_name"] or "")[:23]
157
+ table.add_row(str(row["id"]), company, issue)
158
+ issues_found += 1
159
+
160
+ console.print()
161
+ if issues_found == 0:
162
+ console.print(Panel(
163
+ f"Checked {len(rows)} drafted email(s) - no issues found.",
164
+ title="Draft Check",
165
+ border_style="green",
166
+ ))
167
+ else:
168
+ console.print(table)
169
+ console.print(Panel(
170
+ f"Found {issues_found} issue(s) across {len(rows)} drafted email(s).",
171
+ title="Draft Check",
172
+ border_style="yellow",
173
+ ))
draftpilot/db.py ADDED
@@ -0,0 +1,159 @@
1
+ """SQLite state management for the email pipeline.
2
+
3
+ Tracks every email through its lifecycle:
4
+ pending -> drafted -> sent/cancelled/error
5
+ """
6
+
7
+ import sqlite3
8
+ from datetime import datetime, timezone
9
+
10
+ _db_path = None
11
+ _initialized = False
12
+
13
+
14
+ def set_db_path(path: str):
15
+ """Set the database file path."""
16
+ global _db_path
17
+ _db_path = path
18
+
19
+
20
+ def _get_path():
21
+ if _db_path:
22
+ return _db_path
23
+ from draftpilot.config import get_db_path
24
+ return get_db_path()
25
+
26
+
27
+ def get_conn():
28
+ path = _get_path()
29
+ conn = sqlite3.connect(path)
30
+ conn.row_factory = sqlite3.Row
31
+ conn.execute("PRAGMA journal_mode=WAL")
32
+ _ensure_initialized(conn)
33
+ return conn
34
+
35
+
36
+ def _ensure_initialized(conn):
37
+ global _initialized
38
+ if _initialized:
39
+ return
40
+ conn.execute("""
41
+ CREATE TABLE IF NOT EXISTS emails (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ company_name TEXT NOT NULL,
44
+ email TEXT NOT NULL,
45
+ language TEXT DEFAULT 'fr',
46
+ subject TEXT,
47
+ body TEXT,
48
+ template_used TEXT,
49
+ gmail_draft_id TEXT,
50
+ gmail_message_id TEXT,
51
+ status TEXT DEFAULT 'pending',
52
+ created_at TEXT DEFAULT (datetime('now')),
53
+ drafted_at TEXT,
54
+ send_after TEXT,
55
+ sent_at TEXT,
56
+ error_msg TEXT
57
+ )
58
+ """)
59
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON emails(status)")
60
+ conn.commit()
61
+ _initialized = True
62
+
63
+
64
+ def already_processed(email: str) -> bool:
65
+ """Check if we already drafted/sent to this email address."""
66
+ conn = get_conn()
67
+ row = conn.execute(
68
+ "SELECT 1 FROM emails WHERE email = ? AND status IN ('drafted', 'sent', 'cancelled')",
69
+ (email,)
70
+ ).fetchone()
71
+ conn.close()
72
+ return row is not None
73
+
74
+
75
+ def insert_pending(company_name: str, email: str, language: str) -> int:
76
+ conn = get_conn()
77
+ cur = conn.execute(
78
+ "INSERT INTO emails (company_name, email, language) VALUES (?, ?, ?)",
79
+ (company_name, email, language)
80
+ )
81
+ conn.commit()
82
+ row_id = cur.lastrowid
83
+ conn.close()
84
+ return row_id
85
+
86
+
87
+ def mark_drafted(row_id: int, subject: str, body: str, template_used: str,
88
+ gmail_draft_id: str, send_after: str, gmail_message_id: str = None):
89
+ conn = get_conn()
90
+ conn.execute("""
91
+ UPDATE emails
92
+ SET status = 'drafted', subject = ?, body = ?, template_used = ?,
93
+ gmail_draft_id = ?, gmail_message_id = ?,
94
+ drafted_at = datetime('now'), send_after = ?
95
+ WHERE id = ?
96
+ """, (subject, body, template_used, gmail_draft_id, gmail_message_id, send_after, row_id))
97
+ conn.commit()
98
+ conn.close()
99
+
100
+
101
+ def mark_sent(row_id: int, gmail_message_id: str = None):
102
+ conn = get_conn()
103
+ conn.execute("""
104
+ UPDATE emails
105
+ SET status = 'sent', sent_at = datetime('now'),
106
+ gmail_message_id = COALESCE(?, gmail_message_id)
107
+ WHERE id = ?
108
+ """, (gmail_message_id, row_id))
109
+ conn.commit()
110
+ conn.close()
111
+
112
+
113
+ def mark_cancelled(row_id: int):
114
+ conn = get_conn()
115
+ conn.execute("UPDATE emails SET status = 'cancelled' WHERE id = ?", (row_id,))
116
+ conn.commit()
117
+ conn.close()
118
+
119
+
120
+ def mark_error(row_id: int, error_msg: str):
121
+ conn = get_conn()
122
+ conn.execute(
123
+ "UPDATE emails SET status = 'error', error_msg = ? WHERE id = ?",
124
+ (error_msg, row_id)
125
+ )
126
+ conn.commit()
127
+ conn.close()
128
+
129
+
130
+ def get_ready_to_send() -> list:
131
+ """Get all drafted emails whose validation window has passed."""
132
+ conn = get_conn()
133
+ rows = conn.execute("""
134
+ SELECT * FROM emails
135
+ WHERE status = 'drafted'
136
+ AND REPLACE(REPLACE(send_after, '+00:00', ''), 'T', ' ') <= datetime('now')
137
+ """).fetchall()
138
+ conn.close()
139
+ return rows
140
+
141
+
142
+ def get_stats() -> dict:
143
+ conn = get_conn()
144
+ rows = conn.execute(
145
+ "SELECT status, COUNT(*) as count FROM emails GROUP BY status"
146
+ ).fetchall()
147
+ conn.close()
148
+ return {row["status"]: row["count"] for row in rows}
149
+
150
+
151
+ def get_today_stats() -> dict:
152
+ conn = get_conn()
153
+ rows = conn.execute("""
154
+ SELECT status, COUNT(*) as count FROM emails
155
+ WHERE DATE(created_at) = DATE('now')
156
+ GROUP BY status
157
+ """).fetchall()
158
+ conn.close()
159
+ return {row["status"]: row["count"] for row in rows}