Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
449f02b
chore: scaffold mailing_list_integration service (CM-1318)
themarolt Jul 14, 2026
1c9c16c
feat: add mailinglist schema tables (CM-1318)
themarolt Jul 14, 2026
1194e9c
feat: add mailing list database layer (CM-1318)
themarolt Jul 14, 2026
2cbb25f
feat: port noteren email parser into service (CM-1318)
themarolt Jul 14, 2026
93e5793
feat: add public-inbox mirror service (CM-1318)
themarolt Jul 14, 2026
3da5d5c
feat: add kafka queue producer (CM-1318)
themarolt Jul 14, 2026
f250f68
feat: add mailing list poll worker and server (CM-1318)
themarolt Jul 15, 2026
9fd947f
build: add mailing_list_integration dockerfile and wiring (CM-1318)
themarolt Jul 15, 2026
7e086b0
chore: add mailing list dev seed and docs (CM-1318)
themarolt Jul 15, 2026
bfc56b6
fix: ruff CI-breaking lint errors and wrong activity type (CM-1318)
themarolt Jul 15, 2026
212875a
chore: remove dead settings from mailing list integration (CM-1318)
themarolt Jul 15, 2026
f6b291c
fix: regenerate manifest after initial lore list clone (CM-1318)
themarolt Jul 15, 2026
04136f0
fix: correct base image and container paths for mailing-list-integrat…
themarolt Jul 15, 2026
1b9e96a
feat: add mailing list onboarding endpoint (CM-1318)
themarolt Jul 15, 2026
037a969
fix: harden mailing list bug fixes and add activity type wiring (CM-1…
themarolt Jul 19, 2026
cf4735c
Merge branch 'main' into feat/mailing-list-integration-CM-1318
themarolt Jul 20, 2026
f521e7a
Merge remote-tracking branch 'origin/main' into feat/mailing-list-int…
themarolt Jul 21, 2026
9985ca8
fix: address simple mailing list PR review comments (CM-1318)
themarolt Jul 22, 2026
d324ea1
fix: validate mailing list name to prevent path traversal (CM-1318)
themarolt Jul 22, 2026
3cacf06
fix: honor stored sourceUrl for mirror clone and permalinks (CM-1318)
themarolt Jul 22, 2026
28263ab
fix: skip unparseable commits instead of blocking list forever (CM-1318)
themarolt Jul 22, 2026
bfdcf1e
fix: reclaim stale locked mailing lists after crash (CM-1318)
themarolt Jul 22, 2026
0e43d27
fix: flush activities and checkpoint heads in batches during onboardi…
themarolt Jul 22, 2026
48c6de2
fix: make activity result inserts idempotent on retry (CM-1318)
themarolt Jul 22, 2026
1d2fcb8
fix: await kafka delivery futures for actual broker ack (CM-1318)
themarolt Jul 22, 2026
9a86494
fix: run read_email in a thread to avoid blocking event loop (CM-1318)
themarolt Jul 22, 2026
6287b8c
fix: use longer timeout for initial public-inbox clone (CM-1318)
themarolt Jul 22, 2026
435772b
fix: set score on mailing list message activities (CM-1318)
themarolt Jul 22, 2026
e993187
fix: avoid sourceId collision for messages without Message-ID (CM-1318)
themarolt Jul 22, 2026
694d244
refactor: convert ListWorker class to plain functions (CM-1318)
themarolt Jul 22, 2026
6461853
fix: soft-delete mailing lists removed from integration settings (CM-…
themarolt Jul 22, 2026
997b0d6
fix: reject mailing list connect if already owned by another project …
themarolt Jul 22, 2026
fac52d3
fix: normalize case in member/objectMember username comparisons (CM-1…
themarolt Jul 22, 2026
834aa40
fix: only strip first-line From: override, not later From: content (C…
themarolt Jul 22, 2026
b055aa7
fix: key mirror dir by list id, not user-supplied name (CM-1318)
themarolt Jul 22, 2026
d698cc9
fix: harden list-worker lock/checkpoint handling against races (CM-1318)
themarolt Jul 22, 2026
8064547
fix: harden mailing list connect request validation (CM-1318)
themarolt Jul 22, 2026
977d45e
fix: clear deletedAt when re-onboarding removed mailing list (CM-1318)
themarolt Jul 22, 2026
2df8842
fix: avoid collapsing distinct authors with missing From header (CM-1…
themarolt Jul 22, 2026
3a19d99
fix: emit compact JSONL from parse_id debug CLI (CM-1318)
themarolt Jul 22, 2026
9eafca1
fix: gate mailing list onboarding on integration.results backlog (CM-…
themarolt Jul 22, 2026
d640e6e
docs: fix example list name in mailing list integration README (CM-1318)
themarolt Jul 22, 2026
11332de
ci: run python lint/test on pyproject.toml/uv.lock changes too (CM-1318)
themarolt Jul 22, 2026
e46243a
fix: validate mailing list script input with shared zod schema (CM-1318)
themarolt Jul 22, 2026
42406aa
fix: skip onboarding/reprocessing lists of disconnected integrations …
themarolt Jul 22, 2026
12d147c
fix: canonicalize mailing list sourceUrl trailing slash (CM-1318)
themarolt Jul 22, 2026
1ee0dc9
fix: prevent concurrent mailing list connects from racing ownership (…
themarolt Jul 23, 2026
2c49057
fix: allow reconnect after integration disconnect (CM-1318)
themarolt Jul 23, 2026
05ac034
fix: reset processing state when reactivating soft-deleted mailing li…
themarolt Jul 23, 2026
caa9fc6
docs: clarify upsertMailingLists reactivation behavior (CM-1318)
themarolt Jul 23, 2026
d2a6465
fix: reject loopback/localhost mailing list sourceUrl (CM-1318)
themarolt Jul 23, 2026
71b7f96
perf: bound integration.results scan in can_onboard_more (CM-1318)
themarolt Jul 23, 2026
6262729
fix: clone lore mirror to temp dir, atomic rename on success (CM-1318)
themarolt Jul 23, 2026
929bdfc
fix: throw on empty lists in mailingListConnectOrUpdate (CM-1318)
themarolt Jul 23, 2026
c0c2b06
fix: percent-encode message-id in built activity url (CM-1318)
themarolt Jul 23, 2026
629c688
fix: annotate get_body's optional return type (CM-1318)
themarolt Jul 23, 2026
0268e9d
fix: don't checkpoint shard head past a transient git-read failure (C…
themarolt Jul 23, 2026
10f71da
fix: retry update_processed_heads on transient failure (CM-1318)
themarolt Jul 23, 2026
cfce316
fix: sync identities and segment on conflict-redirect owner attach (C…
themarolt Jul 23, 2026
2eeeec4
fix: retry missing identities on per-member constraint conflict (CM-1…
themarolt Jul 23, 2026
8698810
fix: soft-delete mailing lists when their integration is destroyed (C…
themarolt Jul 23, 2026
ec0ae0c
fix: set result-level segmentId so activities route to the correct li…
themarolt Jul 23, 2026
a3b8bc2
fix: canonicalize sourceUrl scheme/host case, default port, credentia…
themarolt Jul 23, 2026
c93b9fc
fix: persist LORE_MIRROR_DIR across container recreation (CM-1318)
themarolt Jul 23, 2026
e881aa9
fix: relax mailing-list name validation, mirror keys by list id not n…
themarolt Jul 23, 2026
db4b062
fix: don't fast-path create-conflict when identities resolve to multi…
themarolt Jul 23, 2026
736dbe2
fix: use result() not selectNone() for advisory lock query (CM-1318)
themarolt Jul 23, 2026
b23b108
fix: initialize Exception.args from error_message on CrowdMailError (…
themarolt Jul 23, 2026
f970b07
fix: mark list FAILED not COMPLETED after unretried shard read error …
themarolt Jul 23, 2026
de6a08c
fix: promote matching identity to verified before early-return (CM-1318)
themarolt Jul 23, 2026
19018a8
fix: use payload integrationId and promote verified identities on att…
themarolt Jul 23, 2026
6797104
fix: await cancelled worker task so shutdown finally runs (CM-1318)
themarolt Jul 23, 2026
53d8e80
fix: don't merge on ambiguous multi-owner identity conflict (CM-1318)
themarolt Jul 23, 2026
d952d18
chore: drop data_sink_worker fixes, split to separate branch (CM-1318)
themarolt Jul 23, 2026
a4bcebf
Merge remote-tracking branch 'origin/main' into feat/mailing-list-int…
themarolt Jul 23, 2026
d8e0d28
style: prettier format ADR files after merge (CM-1318)
themarolt Jul 23, 2026
a2ac5b9
style: fix prettier formatting in en.ts (CM-1318)
themarolt Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/backend-lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
- name: Check for Python file changes
id: changes
run: |
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -q "^services/apps/git_integration/.*\.py$"; then
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -qE "^services/apps/git_integration/.*(\.py|pyproject\.toml|uv\.lock)$"; then
echo "python_changed=true" >> $GITHUB_OUTPUT
else
echo "python_changed=false" >> $GITHUB_OUTPUT
Expand All @@ -104,6 +104,53 @@ jobs:
uv run ruff check src/ --output-format=github
Comment thread
themarolt marked this conversation as resolved.
uv run ruff format --check src/

lint-python-mailing-list-integration:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./services/apps/mailing_list_integration

steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check for Python file changes
id: changes
run: |
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -qE "^services/apps/mailing_list_integration/.*(\.py|pyproject\.toml|uv\.lock)$"; then
echo "python_changed=true" >> $GITHUB_OUTPUT
else
echo "python_changed=false" >> $GITHUB_OUTPUT
fi

- name: Install uv
if: steps.changes.outputs.python_changed == 'true'
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "services/apps/mailing_list_integration/uv.lock"

- name: Set up Python
if: steps.changes.outputs.python_changed == 'true'
run: uv python install 3.13

- name: Install dependencies
if: steps.changes.outputs.python_changed == 'true'
run: uv sync --group dev --frozen
Comment thread
themarolt marked this conversation as resolved.

- name: Check Python linting and formatting
if: steps.changes.outputs.python_changed == 'true'
run: |
uv run ruff check src/ --output-format=github
uv run ruff format --check src/

- name: Run tests
if: steps.changes.outputs.python_changed == 'true'
run: uv run pytest src/test/ -v

- name: Skip Python checks
if: steps.changes.outputs.python_changed == 'false'
run: echo "⏭️ No Python files changed, skipping linting checks"
6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
"script:fix-duplicate-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-duplicate-members.ts",
"script:fix-members-activities-after-unaffilation": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-members-activities-after-unaffilation.ts",
"script:process-bot-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/process-bot-members.ts",
"script:backfill-email-domain-member-organization-dates": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/backfill-email-domain-member-organization-dates.ts"
"script:backfill-email-domain-member-organization-dates": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/backfill-email-domain-member-organization-dates.ts",
"script:onboard-default-tenant": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/onboard-default-tenant.ts",
"script:onboard-default-tenant:local": "set -a && . ./.env.dist.local && . ./.env.override.local && set +a && pnpm run script:onboard-default-tenant",
"script:create-mailing-list-integration": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/create-mailing-list-integration.ts",
"script:create-mailing-list-integration:local": "set -a && . ./.env.dist.local && . ./.env.override.local && set +a && SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/create-mailing-list-integration.ts"
},
"lint-staged": {
"**/*.ts": [
Expand Down
93 changes: 93 additions & 0 deletions backend/src/api/integration/helpers/mailingListAuthenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { z } from 'zod'

import Permissions from '../../../security/permissions'
import IntegrationService from '../../../services/integrationService'
import PermissionChecker from '../../../services/user/permissionChecker'
import { validateOrThrow } from '../../../utils/validation'

const MAX_LIST_NAME_LENGTH = 255

// `name` is a display label only — the worker keys the mirror path by the
// DB-generated list id, not `name` (see mirror_service.py's
// list_mirror_dir/_validate_list_id), so it's free to contain path-like
// characters (lore lists are nested, e.g. "some-project/git"). Only reject
// the null byte, which breaks C-string-based tooling downstream regardless
// of context.
const isSafeListName = (name: string): boolean =>
name.length > 0 && name.length <= MAX_LIST_NAME_LENGTH && !name.includes('\0')

// public-inbox-clone in the worker fetches this URL as-is; restrict to
// https so a caller can't point the worker at file://, javascript:, or a
// bare non-URL string. Requiring https already blocks the classic SSRF
// target (cloud-metadata IMDS is http-only, per securityTxt.ts precedent);
// also reject obvious loopback/localhost literals.
const isBlockedHost = (h: string): boolean =>
h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.startsWith('127.')

const isSafeSourceUrl = (sourceUrl: string): boolean => {
try {
const url = new URL(sourceUrl)
return (
url.protocol === 'https:' &&
!isBlockedHost(url.hostname.toLowerCase()) &&
// Credentials/query/fragment have no meaning for a public-inbox archive
// URL; rejecting them keeps canonicalizeSourceUrl a lossless
// normalization instead of one that silently drops caller-supplied data.
url.username === '' &&
url.password === '' &&
url.search === '' &&
url.hash === ''
)
} catch {
return false
}
}

// "https://host/list" and "https://HOST/list/" and "https://host:443/list"
// must all resolve to the same DB row — scheme case, host case, the default
// https port, and a trailing slash are not meaningful differences for the
// same archive, but a plain trailing-slash strip left them as distinct
// strings, bypassing the cross-project ownership check and causing
// duplicate ingestion. The worker's ensure_mirror() already normalizes to
// this same form before cloning (mirror_service.py), so storage must match.
// Kept as a plain function (not a zod .transform()/.preprocess()) since
// either makes the field optional in z.infer with the installed zod v4 —
// reproduced in isolation, unrelated to this schema's nesting.
export const canonicalizeSourceUrl = (sourceUrl: string): string => {
const url = new URL(sourceUrl)
const port = url.port && url.port !== '443' ? `:${url.port}` : ''
const path = url.pathname.replace(/\/+$/, '') || '/'
return `https://${url.hostname.toLowerCase()}${port}${path}`
}

export const bodySchema = z.object({
lists: z
.array(
Comment thread
themarolt marked this conversation as resolved.
Comment thread
themarolt marked this conversation as resolved.
z.object({
name: z.string().trim().min(1).refine(isSafeListName, {
message: 'Invalid mailing list name',
}),
sourceUrl: z.string().trim().min(1).refine(isSafeSourceUrl, {
message: 'sourceUrl must be a valid https:// URL',
}),
}),
)
.min(1, 'lists must contain at least one mailing list')
.refine(
(lists) =>
new Set(lists.map((l) => canonicalizeSourceUrl(l.sourceUrl))).size === lists.length,
{ message: 'lists contains duplicate sourceUrl entries' },
),
})

export default async (req, res) => {
new PermissionChecker(req).validateHas(Permissions.values.tenantEdit)
const integrationData = validateOrThrow(bodySchema, req.body)
integrationData.lists = integrationData.lists.map((l) => ({
...l,
sourceUrl: canonicalizeSourceUrl(l.sourceUrl),
}))

const payload = await new IntegrationService(req).mailingListConnectOrUpdate(integrationData)
await req.responseHandler.success(req, res, payload)
}
2 changes: 2 additions & 0 deletions backend/src/api/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export default (app) => {

// Git
app.put(`/git-connect`, safeWrap(require('./helpers/gitAuthenticate').default))

app.put(`/mailing-list-connect`, safeWrap(require('./helpers/mailingListAuthenticate').default))
app.put(`/confluence-connect`, safeWrap(require('./helpers/confluenceAuthenticate').default))
app.put(`/gerrit-connect`, safeWrap(require('./helpers/gerritAuthenticate').default))
app.get('/devto-validate', safeWrap(require('./helpers/devtoValidators').default))
Expand Down
124 changes: 124 additions & 0 deletions backend/src/bin/scripts/create-mailing-list-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* eslint-disable no-console */

/* eslint-disable import/no-extraneous-dependencies */
import commandLineArgs from 'command-line-args'
import commandLineUsage from 'command-line-usage'
import * as fs from 'fs'

import { DEFAULT_TENANT_ID, generateUUIDv1 } from '@crowd/common'

import {
bodySchema,
canonicalizeSourceUrl,
} from '@/api/integration/helpers/mailingListAuthenticate'
import SegmentRepository from '@/database/repositories/segmentRepository'
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
import IntegrationService from '@/services/integrationService'
import { validateOrThrow } from '@/utils/validation'

const options = [
{
name: 'help',
alias: 'h',
type: Boolean,
description: 'Print this usage guide.',
},
{
name: 'file',
alias: 'f',
type: String,
description:
'Path to a JSON file with the lists array, each entry shaped as name + sourceUrl ' +
'(e.g. name "linux-serial", sourceUrl https://lore.kernel.org/linux-serial).',
},
{
name: 'segment',
alias: 's',
type: String,
description: "Segment id. Optional — defaults to the tenant's first subproject.",
},
]

const sections = [
{
header: 'Create mailing list integration',
content:
'Calls IntegrationService.mailingListConnectOrUpdate() directly to create/update the ' +
'mailinglist integration and onboard its lists for processing, until the frontend connect ' +
'UI ships (CM-1318).',
},
{
header: 'Options',
optionList: options,
},
]

const usage = commandLineUsage(sections)
// pnpm forwards a literal `--` separator when args are passed via `pnpm run ... -- <args>`;
// strip it so command-line-args doesn't choke on it.
const argv = process.argv.slice(2).filter((arg) => arg !== '--')
const parameters = commandLineArgs(options, { argv })

if (parameters.help || !parameters.file) {
console.log(usage)
process.exit(parameters.help ? 0 : 1)
} else {
setImmediate(async () => {
let fileContents: string
try {
fileContents = fs.readFileSync(parameters.file, 'utf-8')
} catch (err) {
console.error(`Could not read file at ${parameters.file}: ${(err as Error).message}`)
process.exit(1)
}

let parsed: unknown
try {
parsed = JSON.parse(fileContents)
} catch (err) {
console.error(`File at ${parameters.file} is not valid JSON: ${(err as Error).message}`)
process.exit(1)
}

// Same validation as the connect API endpoint (mailingListAuthenticate.ts),
// so a malformed file (non-array, missing fields, duplicate/non-https
// sourceUrl) fails fast here instead of reaching IntegrationService with
// whatever shape the file happened to contain.
const { lists: parsedLists } = validateOrThrow(bodySchema, { lists: parsed })
const lists = parsedLists.map((l) => ({ ...l, sourceUrl: canonicalizeSourceUrl(l.sourceUrl) }))

const repoOptions = await SequelizeRepository.getDefaultIRepositoryOptions()
repoOptions.currentTenant = { id: DEFAULT_TENANT_ID }
;(repoOptions as unknown as { requestId: string }).requestId = generateUUIDv1()

const adminUser = await repoOptions.database.user.findOne({ where: {} })
if (!adminUser) {
console.error('No user found — run script:onboard-default-tenant first.')
process.exit(1)
}
repoOptions.currentUser = adminUser

const segmentRepository = new SegmentRepository(repoOptions)
repoOptions.currentSegments = parameters.segment
? await segmentRepository.findInIds([parameters.segment])
: (await segmentRepository.querySubprojects({ limit: 1, offset: 0 })).rows

if (repoOptions.currentSegments.length === 0) {
console.error('No segment found/resolved — pass --segment explicitly.')
process.exit(1)
}

console.log(
`Segment: ${repoOptions.currentSegments[0].id} (${repoOptions.currentSegments[0].name})`,
)
console.log('Lists:', JSON.stringify(lists, null, 2))

const integration = await new IntegrationService(repoOptions).mailingListConnectOrUpdate(
{ lists },
repoOptions,
Comment thread
cursor[bot] marked this conversation as resolved.
)

console.log('Integration:', JSON.stringify(integration, null, 2))
process.exit(0)
})
}
74 changes: 74 additions & 0 deletions backend/src/bin/scripts/onboard-default-tenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* eslint-disable no-console */

/* eslint-disable import/no-extraneous-dependencies */
import commandLineArgs from 'command-line-args'
import commandLineUsage from 'command-line-usage'

import SequelizeRepository from '@/database/repositories/sequelizeRepository'
import AuthService from '@/services/auth/authService'

const options = [
{
name: 'help',
alias: 'h',
type: Boolean,
description: 'Print this usage guide.',
},
{
name: 'email',
alias: 'e',
type: String,
defaultValue: 'local-dev@example.com',
description: 'Email for the dev user driving the onboarding (default: local-dev@example.com)',
},
]

const sections = [
{
header: 'Onboard default tenant',
content:
'Runs the same onboarding logic as a real Auth0 signup (AuthService.signinFromSSO -> ' +
'handleOnboard -> TenantService.createOrJoinDefault), without needing a real Auth0 token. ' +
'Creates the default tenant, default segment, settings, and an admin user/tenantUser row. ' +
'Safe to re-run — createOrJoinDefault joins the existing tenant instead of duplicating it.',
},
{
header: 'Options',
optionList: options,
},
]

const usage = commandLineUsage(sections)
const parameters = commandLineArgs(options)

if (parameters.help) {
console.log(usage)
} else {
setImmediate(async () => {
const repoOptions = await SequelizeRepository.getDefaultIRepositoryOptions()

await AuthService.signinFromSSO(
'auth0',
`dev|${parameters.email}`,
parameters.email,
true,
'Local',
'Dev',
'Local Dev',
null,
null,
null,
repoOptions,
)

const tenant = await repoOptions.database.tenant.findOne({ where: {} })
const segment = await repoOptions.database.segment.findOne({
where: { tenantId: tenant.id },
})

console.log(`Tenant: ${tenant.id} (${tenant.name})`)
console.log(`Segment: ${segment.id} (${segment.name})`)

process.exit(0)
})
}
Loading
Loading