Energy-sector news intelligence, contact discovery, and personalized B2B outreach.
The app monitors fresh energy news, prioritizes articles that mention founders / investors / funding rounds / startups / climate-tech, extracts people and companies, finds their professional email addresses via enrichment APIs, validates them, stores everything in PostgreSQL, and generates short contextual outreach emails with strong compliance and safety rails.
Runs in demo mode out of the box. With no API keys configured, every provider is mocked and the full pipeline still runs end-to-end with synthetic data. Add real keys to
.envto go live.
| Layer | Choice |
|---|---|
| Frontend | Next.js 15 (App Router) + TypeScript + Tailwind + shadcn-style UI |
| Backend | Next.js Route Handlers (full-stack, single repo) |
| Database | PostgreSQL + pgvector (lightweight RAG) |
| ORM | Prisma 6 |
| Background jobs | BullMQ + Redis |
| Email sending | Adapter-based (SMTP / console / stubs for SendGrid) |
| Tests | Vitest |
- Node.js ≥ 20
- Docker (for PostgreSQL + Redis)
npm installdocker compose up -dThis starts pgvector/pgvector:pg16 (Postgres with the vector extension) and Redis.
cp .env.example .envThe defaults in .env.example point at the local Docker services. No API keys are required for demo mode.
npm run db:setup # ensures pgvector extension exists
npm run db:migrate:deploy # applies the schema migration
# or during development: npm run db:pushnpm run db:seedRuns the real pipeline against mock news so the UI has articles, people, contact emails, and outreach drafts to show.
npm run devOpen http://localhost:3000.
The worker handles queued jobs (ingestion, extraction, enrichment, sending) on a schedule. Without it, the API falls back to running pipelines inline, which is fine for demo/dev:
npm run worker| Script | Description |
|---|---|
npm run dev |
Next.js dev server |
npm run build / start |
Production build / serve |
npm run worker |
Background worker (BullMQ consumer + cron) |
npm run db:setup |
Ensure pgvector extension |
npm run db:migrate:deploy |
Apply migrations |
npm run db:migrate |
Create + apply a new migration (dev) |
npm run db:push |
Push schema directly (dev, no migration files) |
npm run db:seed |
Seed demo data |
npm run db:studio |
Prisma Studio GUI |
npm test |
Run the test suite |
npm run typecheck |
tsc --noEmit |
News sources ──► Ingestion ──► Relevance scoring ──► Entity extraction (NER)
(GDELT, │ (0–100) │
NewsAPI, │ ▼
mock) │ Person + Company rows
│ │
▼ ▼
Article rows ──────────────────────► Enrichment waterfall
(Hunter → Dropcontact →
Snov → Apollo → PDL →
Lusha → RocketReach)
│
▼
Email validation
(ZeroBounce / NeverBounce)
│
▼
Outreach draft generation
│
▼
Human approval ──► Send
Each step is a function under src/lib/pipeline/ and records a JobLog entry for the Logs/Jobs UI. Jobs run either on the BullMQ worker or inline (API fallback).
A weighted keyword + role-detection algorithm (src/lib/utils/relevance.ts) scores each article 0–100 with a human-readable reason. Articles below the configurable threshold (default 30) are hidden from the feed. When an OpenAI key is present, the LLM provider overrides this with a classification call while keeping the same output shape.
Rule-based NER (src/lib/utils/ner.ts) extracts people + companies, detects roles (Founder / Investor / Executive / Analyst / …), and assigns a relevance reason. Deterministic and dependency-free.
Providers run in configurable order; the waterfall stops at the first high-confidence professional email. Every attempt is logged to EnrichmentAttempt. Personal domains (Gmail, iCloud, …) are skipped unless the contact is manually approved.
- ✅ Only professional emails are eligible to send
- ✅ Personal domains blocked unless manually approved (Gmail, Yahoo, Outlook, Hotmail, iCloud, Proton, AOL, Yandex, Mail.ru, and more)
- ✅ Per-day and per-domain rate limits
- ✅ No duplicate sends (dedup by email)
- ✅ Opt-out / unsubscribe line in every email +
/unsubscribeendpoint - ✅ Source article URL stored on every contact
- ✅ Provider + confidence score stored on every email
- ✅ Human approval required before sending — default ON
- ✅ Only
VALIDemails auto-send;catch_all/risky/unknownrequire manual approval;INVALIDnever sends - ✅ Automatic sending locked until sender identity + limits + compliance footer are configured
- ✅ Full audit log (
EmailEvent+JobLog) of every generated / approved / sent / failed email - ✅ Stop all sending emergency switch in Settings — blocks ALL sends (manual + automatic) instantly
- Articles are ingested and scored → relevant ones appear in Fresh Energy News.
- Click Extract contacts on an article → people + companies are extracted, the enrichment waterfall runs, emails are validated, and outreach drafts are generated.
- Review drafts on the Outreach page. Edit, Approve, then Send (or Reject).
- Every send passes the compliance gate. With Human approval required on (default), nothing sends until you click Approve & Send.
- To enable automatic sending: configure sender identity + limits + footer in Settings, then flip the toggle. The Stop all sending switch halts everything at any time.
All external integrations are behind clean interfaces so they're easy to replace:
| Layer | Interface | Built-in | Mock |
|---|---|---|---|
| News | NewsProvider |
GDELT, NewsAPI, RSS | ✅ MockNewsProvider |
| Enrichment | EnrichmentProvider |
Hunter, Dropcontact, Snov.io, Apollo, People Data Labs, Lusha, RocketReach | ✅ MockEnrichmentProvider |
| Validation | ValidationProvider |
ZeroBounce, NeverBounce | ✅ MockValidationProvider |
| Sending | EmailSender |
SMTP, Console, SendGrid, Resend | ✅ ConsoleSender |
| LLM | LlmProvider |
Gemini (default), OpenAI (fallback) — both with embedding similarity | ✅ MockLlmProvider (deterministic rules) |
To enable a provider, set its API key in .env. To replace one: open the provider file (e.g. src/lib/providers/enrichment/apollo-provider.ts) and edit the request/response handling — the interface contract stays the same, so no other code needs to change.
The app uses Gemini by default for relevance classification, entity extraction, outreach generation, and embeddings. OpenAI remains available as an optional fallback. Selection is controlled by LLM_PROVIDER and falls through gracefully:
LLM_PROVIDER |
Keys set | Active provider |
|---|---|---|
gemini (default) |
GEMINI_API_KEY |
Gemini |
gemini |
only OPENAI_API_KEY |
OpenAI (auto fallback) |
gemini |
neither | deterministic mock |
openai |
OPENAI_API_KEY |
OpenAI |
openai |
only GEMINI_API_KEY |
Gemini (auto fallback) |
openai |
neither | deterministic mock |
Use Gemini (recommended): set GEMINI_API_KEY (get one at https://aistudio.google.com/apikey). Override models via GEMINI_MODEL (default gemini-2.5-flash) and GEMINI_EMBEDDING_MODEL (default gemini-embedding-2).
Switch back to OpenAI: set LLM_PROVIDER=openai + OPENAI_API_KEY. Override the model via OPENAI_MODEL (default gpt-4o-mini).
All runtime behavior lives in the Setting table and is editable from the Settings page:
- News sources + keywords + relevance threshold
- Enrichment waterfall order (drag to reorder)
- Validation provider
- Sender identity (name, email, company)
- Outreach limits (per-day, per-domain, min confidence)
- Compliance footer + excluded domains + personal-email providers
- Human approval required (default on)
- Automatic sending (locked until requirements met)
Defaults are defined in src/lib/config/settings.ts.
See .env.example for the full list. Highlights:
DATABASE_URL— PostgreSQL connection stringREDIS_URL— Redis for BullMQOPENAI_API_KEY— enables OpenAI as the LLM (whenLLM_PROVIDER=openai) or as the optional fallbackGEMINI_API_KEY— enables Gemini as the LLM (default). Get a key at https://aistudio.google.com/apikeyLLM_PROVIDER—gemini(default) |openaiHUNTER_API_KEY,APOLLO_API_KEY,PEOPLE_DATA_LABS_API_KEY,SNOV_API_KEY,DROPCONTACT_API_KEY,LUSHA_API_KEY,ROCKETREACH_API_KEY— enrichment (any subset; waterfall runs enabled ones in your configured order)ZEROBOUNCE_API_KEY,NEVERBOUNCE_API_KEY— validationEMAIL_PROVIDER(smtp|sendgrid|resend|console),SMTP_*,RESEND_API_KEY,SENDGRID_API_KEY— sending
No keys are ever hardcoded. Missing keys → that provider is disabled and a mock/fallback is used.
| Capability | Env var(s) | Notes |
|---|---|---|
| GDELT news | GDELT_ENABLED=true |
No key needed |
| NewsAPI news | NEWS_API_KEY |
|
| RSS news | add feed URLs in Settings → News | No key needed |
| Gemini LLM + embeddings (recommended) | LLM_PROVIDER=gemini, GEMINI_API_KEY |
Default; also set GEMINI_MODEL / GEMINI_EMBEDDING_MODEL to override |
| OpenAI LLM + embeddings (optional fallback) | LLM_PROVIDER=openai, OPENAI_API_KEY |
Improves scoring, NER, outreach + adds similarity |
| Hunter | HUNTER_API_KEY |
|
| Dropcontact | DROPCONTACT_API_KEY |
|
| Snov.io | SNOV_API_KEY |
Two-step poll; needs a company domain |
| Apollo | APOLLO_API_KEY |
|
| People Data Labs | PEOPLE_DATA_LABS_API_KEY |
|
| Lusha | LUSHA_API_KEY |
|
| RocketReach | ROCKETREACH_API_KEY |
|
| ZeroBounce | ZEROBOUNCE_API_KEY |
|
| NeverBounce | NEVERBOUNCE_API_KEY (access token) |
|
| SMTP send | SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL |
|
| SendGrid send | SENDGRID_API_KEY |
|
| Resend send | RESEND_API_KEY |
npm testUnit tests cover the deterministic core:
tests/relevance.test.ts— scoring thresholds, categories, generic detectiontests/email.test.ts— personal/professional classification, syntax, exclusionstests/ner.test.ts— person/company extraction, role assignment, deduptests/outreach.test.ts— email structure, role-specific wording, opt-outtests/settings.test.ts— automatic-send compliance gate
src/
├── app/
│ ├── api/ # Route Handlers (dashboard, articles, contacts,
│ │ # outreach, settings, jobs, ingest, actions)
│ ├── news/ # Fresh Energy News + article detail
│ ├── contacts/ # Contacts / emails database
│ ├── outreach/ # Outreach drafts + send flow
│ ├── jobs/ # Logs / Jobs
│ ├── settings/ # Settings
│ ├── unsubscribe/ # Public opt-out page
│ └── page.tsx # Dashboard
├── components/
│ ├── layout/ # Sidebar, demo banner
│ └── ui/ # shadcn-style primitives + pagination
├── lib/
│ ├── config/ # Settings store + compliance gate
│ ├── pipeline/ # ingestion, extraction, enrichment, outreach, sending, jobs
│ ├── providers/ # news / enrichment / validation / sending / llm
│ │ └── <layer>/registry.ts
│ ├── utils/ # relevance, ner, outreach, email classification
│ ├── db.ts # Prisma singleton
│ ├── env.ts # validated env access
│ ├── queue.ts # BullMQ queues
│ └── types.ts # shared pipeline types
prisma/
├── schema.prisma
└── migrations/ # init SQL (with pgvector)
scripts/
├── worker.ts # background worker entry
├── seed.ts # demo data
└── setup-db.ts # pgvector extension setup
tests/ # Vitest unit tests
- Demo mode is the default: deterministic mocks power the full pipeline, generated emails aren't real, and sending logs to the console. Add real provider keys to enable live behavior. The "Demo mode" toggle in Settings forces mocks even when keys exist.
- Embedding similarity requires
GEMINI_API_KEY(default) orOPENAI_API_KEY; without either, relevance scoring falls back to keyword + entity scoring (still effective). - Snov.io uses a two-step search-and-poll flow and requires a company domain; it silently no-ops when the domain is unknown.
- Provider responses are normalized but each external API has its own quirks; verify against the provider's current docs before relying on production sends.