All projects

Personal · Automation · AI · Personal project · Sep 2026

Outbound AI: Sales CRM & Email Automation

Defined the workflow and business rules for an AI-assisted outreach CRM and built it with Claude Code.

ImportResearchDraftApproveSendFollow upReply shaded steps are done by a person
Diagram

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