-
Notifications
You must be signed in to change notification settings - Fork 730
feat: integrate mailing list service (CM-1318) #4346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
themarolt
wants to merge
77
commits into
main
Choose a base branch
from
feat/mailing-list-integration-CM-1318
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 1c9c16c
feat: add mailinglist schema tables (CM-1318)
themarolt 1194e9c
feat: add mailing list database layer (CM-1318)
themarolt 2cbb25f
feat: port noteren email parser into service (CM-1318)
themarolt 93e5793
feat: add public-inbox mirror service (CM-1318)
themarolt 3da5d5c
feat: add kafka queue producer (CM-1318)
themarolt f250f68
feat: add mailing list poll worker and server (CM-1318)
themarolt 9fd947f
build: add mailing_list_integration dockerfile and wiring (CM-1318)
themarolt 7e086b0
chore: add mailing list dev seed and docs (CM-1318)
themarolt bfc56b6
fix: ruff CI-breaking lint errors and wrong activity type (CM-1318)
themarolt 212875a
chore: remove dead settings from mailing list integration (CM-1318)
themarolt f6b291c
fix: regenerate manifest after initial lore list clone (CM-1318)
themarolt 04136f0
fix: correct base image and container paths for mailing-list-integrat…
themarolt 1b9e96a
feat: add mailing list onboarding endpoint (CM-1318)
themarolt 037a969
fix: harden mailing list bug fixes and add activity type wiring (CM-1…
themarolt cf4735c
Merge branch 'main' into feat/mailing-list-integration-CM-1318
themarolt f521e7a
Merge remote-tracking branch 'origin/main' into feat/mailing-list-int…
themarolt 9985ca8
fix: address simple mailing list PR review comments (CM-1318)
themarolt d324ea1
fix: validate mailing list name to prevent path traversal (CM-1318)
themarolt 3cacf06
fix: honor stored sourceUrl for mirror clone and permalinks (CM-1318)
themarolt 28263ab
fix: skip unparseable commits instead of blocking list forever (CM-1318)
themarolt bfdcf1e
fix: reclaim stale locked mailing lists after crash (CM-1318)
themarolt 0e43d27
fix: flush activities and checkpoint heads in batches during onboardi…
themarolt 48c6de2
fix: make activity result inserts idempotent on retry (CM-1318)
themarolt 1d2fcb8
fix: await kafka delivery futures for actual broker ack (CM-1318)
themarolt 9a86494
fix: run read_email in a thread to avoid blocking event loop (CM-1318)
themarolt 6287b8c
fix: use longer timeout for initial public-inbox clone (CM-1318)
themarolt 435772b
fix: set score on mailing list message activities (CM-1318)
themarolt e993187
fix: avoid sourceId collision for messages without Message-ID (CM-1318)
themarolt 694d244
refactor: convert ListWorker class to plain functions (CM-1318)
themarolt 6461853
fix: soft-delete mailing lists removed from integration settings (CM-…
themarolt 997b0d6
fix: reject mailing list connect if already owned by another project …
themarolt fac52d3
fix: normalize case in member/objectMember username comparisons (CM-1…
themarolt 834aa40
fix: only strip first-line From: override, not later From: content (C…
themarolt b055aa7
fix: key mirror dir by list id, not user-supplied name (CM-1318)
themarolt d698cc9
fix: harden list-worker lock/checkpoint handling against races (CM-1318)
themarolt 8064547
fix: harden mailing list connect request validation (CM-1318)
themarolt 977d45e
fix: clear deletedAt when re-onboarding removed mailing list (CM-1318)
themarolt 2df8842
fix: avoid collapsing distinct authors with missing From header (CM-1…
themarolt 3a19d99
fix: emit compact JSONL from parse_id debug CLI (CM-1318)
themarolt 9eafca1
fix: gate mailing list onboarding on integration.results backlog (CM-…
themarolt d640e6e
docs: fix example list name in mailing list integration README (CM-1318)
themarolt 11332de
ci: run python lint/test on pyproject.toml/uv.lock changes too (CM-1318)
themarolt e46243a
fix: validate mailing list script input with shared zod schema (CM-1318)
themarolt 42406aa
fix: skip onboarding/reprocessing lists of disconnected integrations …
themarolt 12d147c
fix: canonicalize mailing list sourceUrl trailing slash (CM-1318)
themarolt 1ee0dc9
fix: prevent concurrent mailing list connects from racing ownership (…
themarolt 2c49057
fix: allow reconnect after integration disconnect (CM-1318)
themarolt 05ac034
fix: reset processing state when reactivating soft-deleted mailing li…
themarolt caa9fc6
docs: clarify upsertMailingLists reactivation behavior (CM-1318)
themarolt d2a6465
fix: reject loopback/localhost mailing list sourceUrl (CM-1318)
themarolt 71b7f96
perf: bound integration.results scan in can_onboard_more (CM-1318)
themarolt 6262729
fix: clone lore mirror to temp dir, atomic rename on success (CM-1318)
themarolt 929bdfc
fix: throw on empty lists in mailingListConnectOrUpdate (CM-1318)
themarolt c0c2b06
fix: percent-encode message-id in built activity url (CM-1318)
themarolt 629c688
fix: annotate get_body's optional return type (CM-1318)
themarolt 0268e9d
fix: don't checkpoint shard head past a transient git-read failure (C…
themarolt 10f71da
fix: retry update_processed_heads on transient failure (CM-1318)
themarolt cfce316
fix: sync identities and segment on conflict-redirect owner attach (C…
themarolt 2eeeec4
fix: retry missing identities on per-member constraint conflict (CM-1…
themarolt 8698810
fix: soft-delete mailing lists when their integration is destroyed (C…
themarolt ec0ae0c
fix: set result-level segmentId so activities route to the correct li…
themarolt a3b8bc2
fix: canonicalize sourceUrl scheme/host case, default port, credentia…
themarolt c93b9fc
fix: persist LORE_MIRROR_DIR across container recreation (CM-1318)
themarolt e881aa9
fix: relax mailing-list name validation, mirror keys by list id not n…
themarolt db4b062
fix: don't fast-path create-conflict when identities resolve to multi…
themarolt 736dbe2
fix: use result() not selectNone() for advisory lock query (CM-1318)
themarolt b23b108
fix: initialize Exception.args from error_message on CrowdMailError (…
themarolt f970b07
fix: mark list FAILED not COMPLETED after unretried shard read error …
themarolt de6a08c
fix: promote matching identity to verified before early-return (CM-1318)
themarolt 19018a8
fix: use payload integrationId and promote verified identities on att…
themarolt 6797104
fix: await cancelled worker task so shutdown finally runs (CM-1318)
themarolt 53d8e80
fix: don't merge on ambiguous multi-owner identity conflict (CM-1318)
themarolt d952d18
chore: drop data_sink_worker fixes, split to separate branch (CM-1318)
themarolt a4bcebf
Merge remote-tracking branch 'origin/main' into feat/mailing-list-int…
themarolt d8e0d28
style: prettier format ADR files after merge (CM-1318)
themarolt a2ac5b9
style: fix prettier formatting in en.ts (CM-1318)
themarolt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
93 changes: 93 additions & 0 deletions
93
backend/src/api/integration/helpers/mailingListAuthenticate.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
themarolt marked this conversation as resolved.
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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
124 changes: 124 additions & 0 deletions
124
backend/src/bin/scripts/create-mailing-list-integration.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| ) | ||
|
|
||
| console.log('Integration:', JSON.stringify(integration, null, 2)) | ||
| process.exit(0) | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.