Outbound AI: Sales CRM & Email Automation
Defined the workflow and business rules for an AI-assisted outreach CRM and built it with Claude Code.
Context
A personal project from September 2026. I wanted personalised B2B outreach without writing every email by hand and without sending generic mass emails.
Problem
Personalised first emails take a lot of research, and follow-ups get forgotten or, worse, sent to someone who has already replied.
What I did
- Defined the daily workflow and the ten rules the system must never break (e.g. a first email is never sent automatically).
- Decided what is automatic and what needs a person.
- Tested it and use it with real contacts. The code was written with Claude Code; this is not professional software-engineering experience.
Solution
- Research and drafting
- AI agents research each company, find a sourced reason to write, draft the email and reject unsupported claims.
- CRM and campaigns
- Contacts, companies, pipeline stages and campaigns compared by close rate.
- Follow-ups
- I approve and send each first email; follow-ups run automatically and stop as soon as someone replies.
How it works: step through an example (fictitious prospect)
Result
- Working app: Python/FastAPI backend, React/TypeScript interface, 18 automated test files.
- In early use: 400+ contacts imported, first emails sent and replies detected.
Evidence
View code: The gates before any follow-up is sent
Abridged from backend/app/services/followup_engine.py. Every follow-up passes these checks, cheapest and most decisive first; the reply check runs twice.
def _process_link(session, link, grace_days, at):
"""All gates for one follow-up, cheapest and most decisive first."""
contact = session.get(Contact, link.contact_id)
# 1. Block state and contactability.
contactable, reason = contact_service.is_contactable(contact)
if not contactable:
sequence_service.stop(session, link, reason=reason)
return outcome(False, True, reason)
# 2. Has the prospect replied? Re-checked here, never assumed from earlier.
if response_service.has_replied_since(session, contact.id):
sequence_service.stop(session, link, reason="Prospect replied — follow-up cancelled")
return outcome(False, True, "Prospect had already replied")
# 3. The previous email must be a verified send.
previous = _previous_sent_emails(session, link)
if not previous:
link.next_follow_up_at = utcnow() + timedelta(hours=6)
return outcome(False, False, "No verified sent email in this thread yet")
# … 4–7: grace period, sequence step, sender pinned to the original mailbox,
# daily capacity, reuse of a draft that survived a crash
# 8. Last look before handing anything to Mail.
if response_service.has_replied_since(session, contact.id):
email.status = str(EmailStatus.CANCELLED)
sequence_service.stop(session, link, reason="Prospect replied while the follow-up was being written")
return outcome(False, True, "Reply arrived during generation")
sent, error = mail_service.send_follow_up(session, email)The contact database stays on my computer and is never committed. The repository is private and available on request.
Tools
Claude Code · Python · FastAPI · SQLAlchemy · SQLite · React · TypeScript · Anthropic API · AppleScript · Git