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 +3 -0
- draftpilot/__main__.py +5 -0
- draftpilot/cli.py +78 -0
- draftpilot/config.py +168 -0
- draftpilot/dashboard.py +173 -0
- draftpilot/db.py +159 -0
- draftpilot/drafts.py +153 -0
- draftpilot/finder.py +611 -0
- draftpilot/generator.py +244 -0
- draftpilot/gmail.py +142 -0
- draftpilot/sender.py +94 -0
- draftpilot/validator.py +108 -0
- draftpilot-0.1.0.dist-info/METADATA +343 -0
- draftpilot-0.1.0.dist-info/RECORD +17 -0
- draftpilot-0.1.0.dist-info/WHEEL +4 -0
- draftpilot-0.1.0.dist-info/entry_points.txt +2 -0
- draftpilot-0.1.0.dist-info/licenses/LICENSE +189 -0
draftpilot/__init__.py
ADDED
draftpilot/__main__.py
ADDED
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
|
draftpilot/dashboard.py
ADDED
|
@@ -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}
|