An Odoo 17 module that gives you a fast, prioritized outreach queue over your CRM campaigns.
Pick a campaign and work the list top-to-bottom by priority. For each contact, without opening the card, you see the key labels and click straight through to email, LinkedIn, Discord, or website. One click on Contacted stamps the time and logs a chatter note, so the queue reorders itself.
Available as a dense tree view and a kanban view, under CRM → Outreach Runner.
Built entirely on the Odoo addon system; no core files are modified. It adds two
structured fields to contacts (LinkedIn, Discord) plus read-only related/display
fields and a last_outreach_date field on crm.lead, and one action button.
The module already lives in the addons path (/opt/odoo/custom-addons). To
install it:
- In Odoo, enable developer mode (Settings → Developer Tools) if not already on.
- Go to Apps → Update Apps List and confirm.
- Search for CRM Outreach Runner and click Install.
Then open CRM → Outreach Runner.
This addon is a component provider. It serves
static/src/embed/crm-reachout.js from the team's own Odoo host, registering
<crm-reachout> and <crm-heard> — the dashboard's reach-out card:
<script src="https://crm-vc.workers.vc/crm_outreach_runner/static/src/embed/crm-reachout.js" defer></script>
<crm-reachout data-up="https://crm-vc.workers.vc" data-limit="5"></crm-reachout>The org is the hostname, so <crm-reachout> takes no org attribute: one addon
serves every per-team Odoo database. The Odoo session cookie is the auth.
govkit/docs/COMPOSITION.md is the master document for the composition — the
diagram, the component catalog across every repo, the mount / auth / expand-link /
config contracts, and how to run the set locally.
Note for reuse and for local development: the allowed origins are a literal tuple,
ALLOWED_ORIGINS in controllers/dashboard.py, and the same two origins gate the
?next= of /outreach/connect. A second dashboard, or a shell on localhost, cannot
read the queue until those are configurable — the addon already reads
cohort_nav.src from ir.config_parameter, which is the mechanism to follow.
GET /outreach/api/queue?limit=5 — auth='user' (Odoo session cookie),
JSON. Feeds the cohort dashboard's reach-out card (see
PLAN-cohort-dash.md). Response:
{"leads": [{"id": 7, "name": "Pilot kitchens intro",
"partner_name": "R. Okafor", "email": "…",
"linkedin": "…", "last_outreach_date": null,
"outreach_score": 85,
"outreach_score_reason": "tier+45 known+25 reachable+10",
"url": "https://…/web#id=7&model=crm.lead&view_type=form"}],
"count_to_reach": 3}- Only opportunities (
type = 'opportunity'), in two tiers within the limit:- the session user's own leads (
user_id= the caller), ordered like the Outreach Runner tree view:outreach_pinned desc, outreach_seq asc, outreach_score desc; - if fewer than
limit, a fill from the rest of the team — unassigned leads first, then leads assigned to others — each group in that same tree order (so a pinned team lead still outranks unpinned ones within its group, and an unassigned lead beats an assigned-to-someone-else lead even at equal score).
- the session user's own leads (
count_to_reach= opportunities with nolast_outreach_date, counted across the whole queue universe (own + unassigned + assigned-to-others — everything the caller's record rules let them read), independent oflimit.limitdefaults to 5, max 100; invalid values fall back to the default.last_outreach_dateis ISO 8601 UTC (…Z) ornull.urlis built from the request host — one addon serves every per-team Odoo host.- CORS: the handler echoes the
Originheader only when it is exactlyhttps://workers.vcorhttps://www.workers.vc, withAccess-Control-Allow-Credentials: true, and answersOPTIONSpreflight on the same route (auth='none'— preflights carry no credentials). All other origins get no CORS grant.
For the dash's SSO-once chain (sign in once on the dash, every card's
app session gets warmed by chained redirects). auth='public':
- Anonymous, exactly one enabled OAuth provider on the DB → silent
hop: 302 straight into that provider's auth flow, no login page. The
controller inherits
auth_oauth'sOAuthLogin(standard controller extension; the addon depends onauth_oauth) and reuses its stocklist_providers/get_state— the provider is discovered from the DB, the return URL (…/auth_oauth/signin) is built from the request, and the OAuthstatecarries/outreach/connect?next=…(path + query) as the post-auth redirect. After auth the browser lands back here with a fresh Odoo session and the external redirect below fires. - Anonymous, zero or multiple enabled providers → normal login page
(
/web/login?redirect=/outreach/connect?next=…, redirect preserved);auth_oauthpasses that sameredirectthrough the OAuth round-trip when a provider button is clicked. - Authenticated → 302 to
next, but only whennextparses (urllib) to scheme+host exactlyhttps://workers.vcorhttps://www.workers.vc— any path is fine; other origins, protocol-relative (//…), userinfo/port variants, and non-http schemes all fall through. Missing/invalidnext→ 302 to the Outreach Runner tree view (/web#action=…). No open redirect.
The endpoint reads crm.lead as the session user, so normal Odoo access
control applies:
- Required group:
sales_team.group_sale_salesman_all_leads("Sales / User: All Documents"). The fill tier shows teammates' and unassigned leads, so the all-documents record rule is needed. It impliessales_team.group_sale_salesman(which carries the crm.lead read ACL) andbase.group_user, so the caller must be an internal user; portal users have nocrm.leadaccess. Cohort users provisioned via OIDC must be created as internal users with this group. - With only
sales_team.group_sale_salesman("User: Own Documents Only") the endpoint still responds, but its record rule (user_id in (uid, False)) silently hides teammates' leads — the fill tier degrades to unassigned leads only, andcount_to_reachshrinks the same way. Not the intended team queue.
static/src/embed/crm-reachout.js — vanilla JS custom element, no
dependencies, served unauthenticated at
{origin}/crm_outreach_runner/static/src/embed/crm-reachout.js
(standard Odoo module static serving).
<script src="https://crm-<team>.workers.vc/crm_outreach_runner/static/src/embed/crm-reachout.js"></script>
<crm-reachout data-up="https://crm-<team>.workers.vc" data-limit="5"></crm-reachout>data-up(required): the team CRM origin. The component fetches{data-up}/outreach/api/queuewithcredentials: 'include'.data-limit(optional): rows to show, default 5.- Rows: contact name, a one-line why (
outreach_score_reason, or "no reply in N days" once contacted), and an "open" link to the lead form. - Non-200, network error, or empty queue → renders nothing and sets
hidden. Never placeholder data. - On success it sets
data-count="<count_to_reach>"and dispatches a bubblingcrm-reachout-loadedCustomEvent (detail: {count_to_reach, shown}) for the host page's "CRM · N to reach" pill. - All DOM writes are
textContent/attribute based; lead links are checked to be http(s) URLs.
tests/test_queue_endpoint.py (HttpCase, tagged post_install) covers
auth, ordering, count_to_reach, the limit param, CORS echo for
allowed origins only, and preflight. Run against a scratch database only
— never a live one:
odoo-bin -c <conf> -d <fresh_test_db> --http-port=<free_port> \
--workers=0 --max-cron-threads=0 --stop-after-init \
--test-enable --test-tags /crm_outreach_runner -i crm_outreach_runnerThis module is licensed under the MIT License (see LICENSE).
The team's NoHarmV0 do-no-harm license (https://github.com/CivicWorks/noharm-license) is used for our larger shared repositories; this smaller standalone module uses plain MIT.