diff --git a/backend/src/events/guestFirstContact.ts b/backend/src/events/guestFirstContact.ts index 5268231c..ffb1c5f9 100644 --- a/backend/src/events/guestFirstContact.ts +++ b/backend/src/events/guestFirstContact.ts @@ -2,11 +2,12 @@ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { getTextTemplatesRecord } from '../db/textTemplates'; import { renderTextTemplate } from '../textTemplates/render'; import { PODCAST_FIRST_CONTACT_FROM, formatSlotLabel } from '../podcast/firstContact'; -import { APPROVED_GUEST_EVENT_TYPES } from './guestIntakePolicy'; +import { APPROVED_GUEST_EVENT_TYPES, guestFirstContactCc } from './guestIntakePolicy'; export interface GuestFirstContactInput { type: string; templateId: string; guestName?: string; topic?: string; startIso: string; guestTimezone?: string; formUrl: string; accessCode: string; + cc?: readonly string[]; } export function guestFirstContactValues(input: GuestFirstContactInput): Record { if (!APPROVED_GUEST_EVENT_TYPES.has(input.type)) throw new Error('This event type has no enabled guest invitation'); @@ -29,7 +30,8 @@ export function renderGuestFirstContact(template: string, input: GuestFirstConta if (rendered.missing.length) throw new Error(`Invitation template is missing values: ${rendered.missing.join(', ')}`); const match = /^Subject: ([^\r\n]+)\r?\n\r?\n([\s\S]+)$/.exec(rendered.text); if (!match || !match[1].trim() || !match[2].trim()) throw new Error('Invitation template must contain Subject: followed by a blank line and body'); - return { from: PODCAST_FIRST_CONTACT_FROM, subject: match[1], body: match[2].replace(/\r\n/g, '\n').replace(/\n$/, ''), formUrl: input.formUrl, topicSeed: input.topic || '', accessCode: input.accessCode }; + const cc = guestFirstContactCc(input.type, input.cc); + return { from: PODCAST_FIRST_CONTACT_FROM, cc, subject: match[1], body: match[2].replace(/\r\n/g, '\n').replace(/\n$/, ''), formUrl: input.formUrl, topicSeed: input.topic || '', accessCode: input.accessCode }; } export async function loadGuestFirstContact(db: DynamoDBDocumentClient, input: GuestFirstContactInput) { const record = await getTextTemplatesRecord(db); diff --git a/backend/src/events/guestFirstContactDelivery.ts b/backend/src/events/guestFirstContactDelivery.ts index 24ff22ed..eb035d12 100644 --- a/backend/src/events/guestFirstContactDelivery.ts +++ b/backend/src/events/guestFirstContactDelivery.ts @@ -27,7 +27,7 @@ export async function deliverGuestFirstContact(db: DynamoDBDocumentClient, input claimedAt: new Date().toISOString(), attempts: prior ? [...(prior.attempts || []), { claimedAt: prior.claimedAt, delivery: prior.delivery! }] : [] }; await updateTask(db, task.id, { currentTask: task, expectedVersion: task.version, patch: { podcastFirstContact: claim }, actorId: input.actorId, triggerKind: 'guest-first-contact-claim' }); const result = await deliverRenderedEmail(db, { draft: email, recipientEmail: input.recipient, recipientName: input.guestName || '', taskId: task.id, - connectionId: process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID?.trim() || 'google-calendar-orin', cc: [] }); + connectionId: process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID?.trim() || 'google-calendar-orin', cc: email.cc }); const delivery = { ...result, templateId: email.templateId, templateRevision: email.templateUpdatedAt }; const current = await getTaskConsistent(db, task.id); if (!current) throw new Error('Invitation Task disappeared after provider delivery'); diff --git a/backend/src/events/guestIntakePolicy.ts b/backend/src/events/guestIntakePolicy.ts index 0bf4cc21..c3adf7b0 100644 --- a/backend/src/events/guestIntakePolicy.ts +++ b/backend/src/events/guestIntakePolicy.ts @@ -1,3 +1,5 @@ +import { PODCAST_GUEST_OBSERVER_CC, normalizePodcastGuestCc } from '../podcast/firstContact'; + export const APPROVED_GUEST_EVENT_TYPES = new Set(['podcast', 'webinar', 'workshop']); export interface GuestIntakePolicy { enabled: boolean; @@ -8,13 +10,18 @@ export interface GuestIntakePolicy { preferredWindows?: string[]; followUpDays: number; firstContactTaskRefs?: string[]; + cc?: string[]; +} +export function guestFirstContactCc(type: string, policyCc?: readonly string[], recipientEmail?: string): string[] { + if (type === 'podcast') return normalizePodcastGuestCc(policyCc, recipientEmail); + return policyCc ? policyCc.map(v => String(v || '').trim().toLowerCase()).filter(Boolean) : []; } export function guestIntakePolicyIssues(raw: unknown, type: string): string[] { if (raw === undefined) return []; const p = raw as GuestIntakePolicy; if (!p || typeof p !== 'object' || Array.isArray(p)) return ['guestIntake must be an object']; const issues: string[] = []; - for (const key of Object.keys(p)) if (!['enabled','emailTemplateId','formTemplate','weekdays','minimumNoticeDays','preferredWindows','followUpDays','firstContactTaskRefs'].includes(key)) issues.push(`guestIntake.${key} is not supported`); + for (const key of Object.keys(p)) if (!['enabled','emailTemplateId','formTemplate','weekdays','minimumNoticeDays','preferredWindows','followUpDays','firstContactTaskRefs','cc'].includes(key)) issues.push(`guestIntake.${key} is not supported`); if (p.enabled && p.formTemplate !== type) issues.push('guestIntake.formTemplate must match the event type'); if (typeof p.enabled !== 'boolean') issues.push('guestIntake.enabled must be boolean'); if (p.enabled && !APPROVED_GUEST_EVENT_TYPES.has(type)) issues.push('Guest intake is approved only for podcast, webinar and workshop'); @@ -24,5 +31,12 @@ export function guestIntakePolicyIssues(raw: unknown, type: string): string[] { if (p.weekdays && (!Array.isArray(p.weekdays) || !p.weekdays.length || p.weekdays.some(v => !Number.isInteger(v) || v < 0 || v > 6))) issues.push('guestIntake.weekdays must contain weekday numbers 0..6'); if (p.minimumNoticeDays !== undefined && (!Number.isInteger(p.minimumNoticeDays) || p.minimumNoticeDays < 0 || p.minimumNoticeDays > 180)) issues.push('guestIntake.minimumNoticeDays must be 0..180'); for (const key of ['preferredWindows', 'firstContactTaskRefs'] as const) if (p[key] && (!Array.isArray(p[key]) || p[key]!.some(v => typeof v !== 'string' || !v))) issues.push(`guestIntake.${key} must be string values`); + if (p.cc !== undefined) { + if (!Array.isArray(p.cc) || p.cc.some(v => typeof v !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()))) { + issues.push('guestIntake.cc must be valid email addresses'); + } else if (type === 'podcast' && (p.cc.length !== 1 || p.cc[0].trim().toLowerCase() !== PODCAST_GUEST_OBSERVER_CC)) { + issues.push(`guestIntake.cc for podcast must be [${PODCAST_GUEST_OBSERVER_CC}]`); + } + } return issues; } diff --git a/backend/src/events/guestIntakeWorker.ts b/backend/src/events/guestIntakeWorker.ts index bd8bae42..35204da3 100644 --- a/backend/src/events/guestIntakeWorker.ts +++ b/backend/src/events/guestIntakeWorker.ts @@ -82,7 +82,7 @@ export async function processGuestIntake(db: DynamoDBDocumentClient, id: string) const slot = slots[0]; job.selectedDate = slot.date; job.selectedTime = slot.time; job.selectedWindow = slot.window; job.startIso = slot.startIso; await saveGuestIntakeJob(db, job); // Validate the editable copy before the first guest-facing Calendar operation. - if (!job.skipEmail) await loadGuestFirstContact(db, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: slot.startIso, guestTimezone: job.guestTimezone, formUrl, accessCode: created.accessCode }); + if (!job.skipEmail) await loadGuestFirstContact(db, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: slot.startIso, guestTimezone: job.guestTimezone, formUrl, accessCode: created.accessCode, cc: job.policy.cc }); const result = await proposeEvent(db, card.id, { date: slot.date, time: slot.time, title: card.title, topic: job.topic, guestNames: job.guestName, guestTimezone: job.guestTimezone, guestEmails: [job.guestEmail], documentUrl: formUrl, operationKey: `guest-intake-${id.slice(0,32)}`, actor: job.actorId, suppressGuestNotifications: job.skipEmail }); if (result.sync.state !== 'synced') throw new Error(`Calendar invitation is pending: ${result.sync.reason || 'provider unavailable'}`); @@ -112,7 +112,7 @@ export async function processGuestIntake(db: DynamoDBDocumentClient, id: string) job.status = 'skipped'; await saveGuestIntakeJob(db, job); await projectJob(db, job); return; } const delivery = await deliverGuestFirstContact(db, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: event.startIso, guestTimezone: job.guestTimezone, - formUrl, accessCode: created.accessCode, taskId: job.taskId, holdId: event.id, docId: job.docId, recipient: job.guestEmail, actorId: job.actorId }); + formUrl, accessCode: created.accessCode, taskId: job.taskId, holdId: event.id, docId: job.docId, recipient: job.guestEmail, actorId: job.actorId, cc: job.policy.cc }); if (delivery.state !== 'sent' || !delivery.messageId) throw new Error(`First-contact email is pending: ${delivery.reason || delivery.state}`); job.messageId = delivery.messageId; job.sentAt = delivery.sentAt || new Date().toISOString(); delete job.error; const task = await getTaskConsistent(db, job.taskId); diff --git a/backend/src/podcast/firstContact.ts b/backend/src/podcast/firstContact.ts index cc3b9ec1..ea774008 100644 --- a/backend/src/podcast/firstContact.ts +++ b/backend/src/podcast/firstContact.ts @@ -1,8 +1,99 @@ -/** Approved first-contact copy. Rendering creates no records and sends no mail. */ +/** Approved first-contact copy and guest-thread reply helpers. Rendering creates no records and sends no mail. */ export const PODCAST_FIRST_CONTACT_FROM = 'orin@datatalks.club'; +export const PODCAST_GUEST_OBSERVER_CC = 'alexey@datatalks.club'; +export const PODCAST_FIRST_CONTACT_CC = [PODCAST_GUEST_OBSERVER_CC] as const; export const PODCAST_DOCS_URL = 'https://datatalksclub.github.io/docs/activities/podcast/'; const BERLIN_TZ = 'Europe/Berlin'; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const FORBIDDEN_PODCAST_RECIPIENTS = new Set([ + 'grace@datatalks.club', + 'alexey.s.grigoriev@gmail.com', + 'valeriia@datatalks.club', +]); + +/** + * Enforce the single podcast guest observer Cc (`alexey@datatalks.club`). + * Strips sender/guest duplicates and rejects forbidden extra recipients. + */ +export function normalizePodcastGuestCc( + cc?: readonly string[], + recipientEmail?: string, +): string[] { + const guest = (recipientEmail || '').trim().toLowerCase(); + for (const raw of cc || []) { + const addr = String(raw || '').trim().toLowerCase(); + if (!addr) continue; + if (!EMAIL_PATTERN.test(addr) || /[\r\n]/.test(addr)) { + throw new Error(`Invalid podcast Cc email address: ${raw}`); + } + if (FORBIDDEN_PODCAST_RECIPIENTS.has(addr)) { + throw new Error(`Forbidden podcast guest recipient in Cc: ${addr}`); + } + if (addr !== PODCAST_GUEST_OBSERVER_CC && addr !== PODCAST_FIRST_CONTACT_FROM && addr !== guest) { + throw new Error(`Unexpected extra podcast Cc recipient: ${addr}; only ${PODCAST_GUEST_OBSERVER_CC} is allowed`); + } + } + return [PODCAST_GUEST_OBSERVER_CC]; +} + +export interface PodcastGuestReplyInput { + to: string; + subject: string; + body: string; + threadId?: string; + inReplyTo?: string; + cc?: readonly string[]; +} + +export interface PreparedPodcastGuestReply { + from: string; + to: string; + cc: string[]; + subject: string; + body: string; + threadId?: string; + inReplyTo?: string; +} + +/** + * Prepare an Orin-driven reply in a podcast guest thread. + * Preserves the guest `To:`, enforces `Cc: alexey@datatalks.club` with no extra + * recipients, and normalizes the `Re:` subject prefix without sending mail. + */ +export function preparePodcastGuestReply(input: PodcastGuestReplyInput): PreparedPodcastGuestReply { + const to = String(input.to || '').trim().toLowerCase(); + if (!EMAIL_PATTERN.test(to) || /[\r\n]/.test(to)) { + throw new Error('A valid single-line guest recipient email is required'); + } + if (FORBIDDEN_PODCAST_RECIPIENTS.has(to) || to === PODCAST_FIRST_CONTACT_FROM || to === PODCAST_GUEST_OBSERVER_CC) { + throw new Error(`Invalid podcast guest recipient: ${to}`); + } + const rawSubject = String(input.subject || '').trim(); + if (!rawSubject || /[\r\n]/.test(rawSubject)) { + throw new Error('A single-line thread subject is required'); + } + const body = String(input.body || '').replace(/\r\n/g, '\n').trim(); + if (!body) { + throw new Error('A non-empty reply body is required'); + } + const cc = normalizePodcastGuestCc(input.cc, to); + const subject = /^re:\s*/i.test(rawSubject) ? rawSubject.replace(/^re:\s*/i, 'Re: ') : `Re: ${rawSubject}`; + const threadId = input.threadId?.trim() || undefined; + const inReplyTo = input.inReplyTo?.trim() || undefined; + if ((threadId && /[\r\n]/.test(threadId)) || (inReplyTo && /[\r\n]/.test(inReplyTo))) { + throw new Error('Thread identifiers must be single-line'); + } + return { + from: PODCAST_FIRST_CONTACT_FROM, + to, + cc, + subject, + body, + ...(threadId ? { threadId } : {}), + ...(inReplyTo ? { inReplyTo } : {}), + }; +} /** Berlin wall-clock label for a hold instant, e.g. "Tuesday, November 4 at 12:30 CET". */ export function formatSlotLabel(startIso: string, timeZone = BERLIN_TZ): string { diff --git a/backend/src/routes/cards.ts b/backend/src/routes/cards.ts index 32d0f0d0..3ef28518 100644 --- a/backend/src/routes/cards.ts +++ b/backend/src/routes/cards.ts @@ -398,9 +398,9 @@ async function handleCardRoutes( const [loaded, hold] = await Promise.all([getSponsorDocWithAuthMaterial(client, job.docId), getEventRecord(client, job.holdId)]); const code = loaded ? decryptSponsorDocAccessCode(job.docId, loaded.accessCodeCiphertext) : null; if (loaded && hold && code) email = await loadGuestFirstContact(client, { type: job.type, templateId: job.policy.emailTemplateId, guestName: job.guestName, topic: job.topic, startIso: hold.startIso, guestTimezone: job.guestTimezone, - formUrl: `${(process.env.DATAOPS_PORTAL_URL || 'https://ops.dtcdev.click').replace(/\/$/, '')}/${job.policy.formTemplate}-docs/${encodeURIComponent(job.docId)}?code=${encodeURIComponent(code)}`, accessCode: code }); + formUrl: `${(process.env.DATAOPS_PORTAL_URL || 'https://ops.dtcdev.click').replace(/\/$/, '')}/${job.policy.formTemplate}-docs/${encodeURIComponent(job.docId)}?code=${encodeURIComponent(code)}`, accessCode: code, cc: job.policy.cc }); } - return json(200, { invitation: job, ...(email ? { preview: true, email: { ...email, to: job.guestEmail, cc: [] } } : {}) }); + return json(200, { invitation: job, ...(email ? { preview: true, email: { ...email, to: job.guestEmail, cc: email.cc } } : {}) }); } if (!mayAdministerCard(actor.actor, card)) return workAdminForbidden('Sending this invitation requires the Card owner or an admin'); if (action === 'send' && (job.status !== 'skipped' || !job.skipEmail)) return json(409, { error: 'Only a skipped initial invitation can be sent here; existing delivery attempts must be reconciled' }); diff --git a/backend/src/routes/podcastFirstContact.ts b/backend/src/routes/podcastFirstContact.ts index 8b9c90e2..eb271e22 100644 --- a/backend/src/routes/podcastFirstContact.ts +++ b/backend/src/routes/podcastFirstContact.ts @@ -1,6 +1,8 @@ import { loadGuestFirstContact } from '../events/guestFirstContact'; +import { preparePodcastGuestReply, type PodcastGuestReplyInput } from '../podcast/firstContact'; /** * POST /api/podcast-first-contact/email: render Orin's first-contact email. + * POST /api/podcast-first-contact/reply: prepare or send an Orin-driven reply in a podcast guest thread. * * The render endpoint is side-effect free. The send endpoint reads an existing * tentative proposal, guest materials document and owning Task, then delivers @@ -25,7 +27,7 @@ export async function handlePodcastFirstContactRoutes( event: LambdaEvent, client: DynamoDBDocumentClient, ): Promise { - if (!['/api/podcast-first-contact/email', '/api/podcast-first-contact/send'].includes(path)) return json(404, { error: 'Not found' }); + if (!['/api/podcast-first-contact/email', '/api/podcast-first-contact/send', '/api/podcast-first-contact/reply'].includes(path)) return json(404, { error: 'Not found' }); if (method !== 'POST') return json(405, { error: 'Method not allowed' }); const resolved = await resolveInteractiveActor(client, event, 'work-write'); if (!resolved.ok) return resolved.response; @@ -33,6 +35,38 @@ export async function handlePodcastFirstContactRoutes( const body = parse(event); if (!body) return json(400, { error: 'Invalid JSON' }); + if (path.endsWith('/reply')) { + try { + const prepared = preparePodcastGuestReply({ + to: String(body.to ?? body.recipientEmail ?? ''), + subject: String(body.subject ?? ''), + body: String(body.body ?? ''), + threadId: typeof body.threadId === 'string' ? body.threadId : undefined, + inReplyTo: typeof body.inReplyTo === 'string' ? body.inReplyTo : undefined, + cc: Array.isArray(body.cc) ? (body.cc as string[]) : undefined, + } satisfies PodcastGuestReplyInput); + if (body.send !== true || body.preview === true) { + return json(200, { preview: true, email: prepared }); + } + const taskId = typeof body.taskId === 'string' ? body.taskId.trim() : ''; + if (!taskId || !prepared.threadId) { + return json(400, { error: 'taskId and threadId are required when sending a podcast guest reply' }); + } + const connectionId = process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID?.trim() || 'google-calendar-orin'; + const delivery = await deliverRenderedEmail(client, { + draft: { subject: prepared.subject, body: prepared.body }, + recipientEmail: prepared.to, + taskId, + connectionId, + cc: prepared.cc, + threadId: prepared.threadId, + }); + return json(200, { emailDelivery: delivery, email: prepared, taskId, threadId: prepared.threadId }); + } catch (error) { + return json(400, { error: (error as Error).message }); + } + } + if (path.endsWith('/send')) { const { holdId, docId, taskId, guestName } = body; if (![holdId, docId, taskId, guestName].every(value => typeof value === 'string' && value.trim() && value.length <= 200)) { @@ -74,13 +108,13 @@ export async function handlePodcastFirstContactRoutes( const formUrl = `${sponsorDocPortalBaseUrl(event)}/podcast-docs/${encodeURIComponent(doc.id)}?code=${encodeURIComponent(accessCode)}`; let email; try { - email = await loadGuestFirstContact(client, { type: 'podcast', templateId: card.templateDefinitionSnapshot?.guestIntake?.emailTemplateId || 'podcast.first-contact', guestName, startIso: hold.startIso, topic: hold.topic, formUrl, accessCode }); + email = await loadGuestFirstContact(client, { type: 'podcast', templateId: card.templateDefinitionSnapshot?.guestIntake?.emailTemplateId || 'podcast.first-contact', guestName, startIso: hold.startIso, topic: hold.topic, formUrl, accessCode, cc: card.templateDefinitionSnapshot?.guestIntake?.cc }); } catch (error) { return json(422, { error: (error as Error).message }); } const recipient = hold.guestRef!.trim().toLowerCase(); // Use the existing Orin Google connection with verified Gmail scopes, also // used by contract delivery. No new account, connection or grant is created. const connectionId = process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID?.trim() || 'google-calendar-orin'; - if (body.preview === true) return json(200, { preview: true, email: { ...email, to: recipient, cc: [] }, connectionId, retryEligible: Boolean(retryEligible), taskId, holdId, docId }); + if (body.preview === true) return json(200, { preview: true, email: { ...email, to: recipient, cc: email.cc }, connectionId, retryEligible: Boolean(retryEligible), taskId, holdId, docId }); const attempts = prior ? [...(prior.attempts || []), { claimedAt: prior.claimedAt, delivery: prior.delivery! }] : []; const claim = { holdId, docId, recipient, state: 'sending' as const, claimedAt: new Date().toISOString(), attempts }; try { @@ -90,7 +124,7 @@ export async function handlePodcastFirstContactRoutes( throw error; } // Only the explicit, proven pre-Gmail recovery above can repeat an attempt. - const delivery = await deliverRenderedEmail(client, { draft: email, recipientEmail: recipient, recipientName: guestName, taskId, connectionId, cc: [] }); + const delivery = await deliverRenderedEmail(client, { draft: email, recipientEmail: recipient, recipientName: guestName, taskId, connectionId, cc: email.cc }); for (let attempt = 0; attempt < 3; attempt += 1) { const current = await getTaskConsistent(client, taskId); if (!current) throw new Error('Owning Task disappeared after invitation delivery'); diff --git a/backend/src/sponsorDocs/emailDelivery.ts b/backend/src/sponsorDocs/emailDelivery.ts index 337eeb73..a91dace4 100644 --- a/backend/src/sponsorDocs/emailDelivery.ts +++ b/backend/src/sponsorDocs/emailDelivery.ts @@ -36,6 +36,7 @@ import { renderSponsorEmailDraft, type SponsorDocTemplateKey, } from './templates'; +import { PODCAST_FIRST_CONTACT_CC } from '../podcast/firstContact'; export const SPONSOR_DOC_EMAIL_FROM = 'orin@datatalks.club'; export const SPONSOR_DOC_EMAIL_CC = 'valeriia@datatalks.club'; @@ -316,7 +317,7 @@ export async function deliverRenderedEmail( state: 'deferred', from: SPONSOR_DOC_EMAIL_FROM, to: resolvedRecipient.to, - cc: [SPONSOR_DOC_EMAIL_CC], + cc: options.cc ?? [SPONSOR_DOC_EMAIL_CC], ...(resolvedRecipient.recipientName ? { recipientName: resolvedRecipient.recipientName } : {}), subject, reason: 'task_required', @@ -649,6 +650,16 @@ export async function renderSponsorDocEmailDraft(options: { return renderSponsorEmailDraft(options); } +export function defaultCcForSponsorDocTemplate(template: SponsorDocTemplateKey): string[] { + if (template === 'podcast' || template.startsWith('podcast-')) { + return [...PODCAST_FIRST_CONTACT_CC]; + } + if (template === 'webinar' || template === 'workshop') { + return []; + } + return [SPONSOR_DOC_EMAIL_CC]; +} + export async function deliverSponsorDocEmail( client: DynamoDBDocumentClient, options: { @@ -662,6 +673,8 @@ export async function deliverSponsorDocEmail( taskId?: string | null; organizationId?: string | null; connectionId?: string | null; + cc?: string[]; + threadId?: string; }, ): Promise { const resolvedRecipient = await resolveSponsorDocRecipient(client, { @@ -670,6 +683,7 @@ export async function deliverSponsorDocEmail( organizationId: options.organizationId, sponsorName: options.sponsorName, }); + const cc = options.cc ?? defaultCcForSponsorDocTemplate(options.template); let draft: TemplatedEmailDraft & { templateId?: string; templateRevision?: string }; try { @@ -685,7 +699,7 @@ export async function deliverSponsorDocEmail( return { state: 'deferred', from: SPONSOR_DOC_EMAIL_FROM, - ...(resolvedRecipient.to ? { to: resolvedRecipient.to, cc: [SPONSOR_DOC_EMAIL_CC] } : {}), + ...(resolvedRecipient.to ? { to: resolvedRecipient.to, ...(cc.length > 0 ? { cc } : {}) } : {}), ...(resolvedRecipient.recipientName ? { recipientName: resolvedRecipient.recipientName } : {}), subject: '', reason: error instanceof KnowledgeEmailTemplateError ? 'knowledge_template_unavailable' : 'template_render_failed', @@ -701,6 +715,8 @@ export async function deliverSponsorDocEmail( organizationId: options.organizationId, sponsorName: options.sponsorName, connectionId: options.connectionId, + cc, + ...(options.threadId ? { threadId: options.threadId } : {}), }); return { ...delivery, diff --git a/backend/tests/guest-intake-worker.test.ts b/backend/tests/guest-intake-worker.test.ts index 301bd49a..77cd1324 100644 --- a/backend/tests/guest-intake-worker.test.ts +++ b/backend/tests/guest-intake-worker.test.ts @@ -27,6 +27,7 @@ let db: DynamoDBDocumentClient; let google = createMockGoogleCalendar(); const checklist = createTaskChecklistFixture(); let mails = 0; +let lastMailMime = ''; let ownerEvents: unknown[] = []; let failMail = false; let onMailSend: (() => Promise) | undefined; @@ -37,8 +38,8 @@ before(async () => { await saveCalendarOverlaySettings(db, { connectionId: 'synthetic-google' }, 'synthetic-actor'); setCalendarHoldGoogleDepsForTests({ credential: 'synthetic', connectionId: 'synthetic-google', fetchImpl: google.fetchImpl }); setOwnerCalendarProviderClientsForTests({ readSecret: async () => 'synthetic', fetch: async (url) => new Response(JSON.stringify(String(url).endsWith('/api/agent/token') ? { access_token: 'synthetic' } : { items: ownerEvents }), { status: 200 }) }); - setSponsorDocEmailDepsForTests({ credential: 'synthetic', fetchImpl: async (url) => { - if (String(url).endsWith('/messages/send')) { mails++; if (onMailSend) { const callback = onMailSend; onMailSend = undefined; await callback(); } if (failMail) throw new Error('synthetic ambiguous timeout'); return new Response(JSON.stringify({ id: `synthetic-mail-${mails}` }), { status: 200 }); } + setSponsorDocEmailDepsForTests({ credential: 'synthetic', fetchImpl: async (url, init) => { + if (String(url).endsWith('/messages/send')) { mails++; lastMailMime = Buffer.from(JSON.parse(String(init?.body || '{}')).raw || '', 'base64url').toString('utf8'); if (onMailSend) { const callback = onMailSend; onMailSend = undefined; await callback(); } if (failMail) throw new Error('synthetic ambiguous timeout'); return new Response(JSON.stringify({ id: `synthetic-mail-${mails}` }), { status: 200 }); } if (String(url).includes('userinfo')) return new Response(JSON.stringify({ email: 'orin@datatalks.club' }), { status: 200 }); return new Response(JSON.stringify({ access_token: 'synthetic', provider_account_id: 'orin@datatalks.club', scopes: ['https://www.googleapis.com/auth/gmail.send'] }), { status: 200 }); } }); @@ -68,6 +69,8 @@ test('creation queues a durable job with email alone; one worker sends both chan await handler({ Records: [{ eventSource: 'aws:dynamodb', eventName: 'INSERT', dynamodb: { NewImage: { guestIntake: { M: { jobId: { S: created.card.guestIntake.jobId } } } } } }] }); const job = (await getGuestIntakeJob(db, created.card.guestIntake.jobId))!; assert.equal(job.status, 'sent'); assert.equal(mails, beforeMails + 1); + assert.match(lastMailMime, /Cc: alexey@datatalks\.club\r\n/); + assert.doesNotMatch(lastMailMime, /grace@datatalks\.club|alexey\.s\.grigoriev@gmail\.com|valeriia@datatalks\.club/i); const event = (await getEventRecord(db, job.holdId!))!; assert.equal(event.durationMinutes, 60); assert.equal(event.operations[0].desired.startIso, new Date(Date.parse(event.startIso)-5*60000).toISOString()); assert.match(event.operations[0].desired.summary, /^\[Proposed\] /); diff --git a/backend/tests/podcast-doc-email.test.ts b/backend/tests/podcast-doc-email.test.ts index 6c754782..043d9ccc 100644 --- a/backend/tests/podcast-doc-email.test.ts +++ b/backend/tests/podcast-doc-email.test.ts @@ -1,10 +1,17 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { renderSponsorEmailDraft } from '../src/sponsorDocs/templates'; +import { defaultCcForSponsorDocTemplate } from '../src/sponsorDocs/emailDelivery'; const base = { sponsorName: 'Sofiia Hordiienko', publicationDate: '2026-11-20 15:00', shareUrl: 'https://example.test/doc', accessCode: 'code-1' }; describe('podcast doc emails', () => { + it('uses alexey@datatalks.club as the single observer Cc for podcast doc emails', () => { + assert.deepEqual(defaultCcForSponsorDocTemplate('podcast'), ['alexey@datatalks.club']); + assert.deepEqual(defaultCcForSponsorDocTemplate('podcast-episode-brief'), ['alexey@datatalks.club']); + assert.deepEqual(defaultCcForSponsorDocTemplate('webinar'), []); + assert.deepEqual(defaultCcForSponsorDocTemplate('workshop'), []); + }); it('renders a guest email for the episode brief', () => { const { body } = renderSponsorEmailDraft({ ...base, template: 'podcast-episode-brief', recipientName: 'Sofiia Hordiienko' }); assert.match(body, /Hi Sofiia,/); diff --git a/backend/tests/podcast-first-contact.test.ts b/backend/tests/podcast-first-contact.test.ts index 2da19e7c..51201438 100644 --- a/backend/tests/podcast-first-contact.test.ts +++ b/backend/tests/podcast-first-contact.test.ts @@ -18,7 +18,9 @@ import { templateLink } from './helpers/cardTemplate'; import { setSponsorDocEmailDepsForTests } from '../src/sponsorDocs/emailDelivery'; import { PODCAST_FIRST_CONTACT_FROM, + PODCAST_FIRST_CONTACT_CC, formatSlotLabel, + preparePodcastGuestReply, } from '../src/podcast/firstContact'; const approvedTemplate = `Subject: DataTalks.Club Podcast\n\n @@ -90,6 +92,7 @@ describe('podcast first-contact email (Orin ask)', () => { it('renders the exact approved first-contact subject and body', () => { const email = renderGuestFirstContact(approvedTemplate, { ...renderInput, type: 'podcast', templateId: 'podcast.first-contact', guestName: renderInput.guestFirstName, topic: renderInput.topicSeed }); assert.equal(email.from, PODCAST_FIRST_CONTACT_FROM); + assert.deepEqual(email.cc, [...PODCAST_FIRST_CONTACT_CC]); assert.equal(email.subject, 'Synthetic at DataTalks.Club Podcast'); assert.equal(email.body, `Hi Synthetic, @@ -146,7 +149,7 @@ Orin Vale`); assert.equal(res.statusCode, 200, res.body); const output = JSON.parse(res.body); assert.equal(output.email.to, 'guest@example.test'); - assert.deepEqual(output.email.cc, []); + assert.deepEqual(output.email.cc, ['alexey@datatalks.club']); assert.equal(output.connectionId, 'google-calendar-orin'); assert.match(output.email.body, /Monday 4 November 2030 at 17:00 CET/); assert.match(output.email.body, /Reliable data agents/); @@ -172,11 +175,13 @@ Orin Vale`); assert.equal(results.filter(result => result.statusCode === 409).length, 1); assert.equal(sends, 1); assert.match(mime, /To: guest@example.test/); - assert.ok(!mime.includes('Cc:')); + assert.match(mime, /Cc: alexey@datatalks\.club\r\n/); + assert.doesNotMatch(mime, /grace@datatalks\.club|alexey\.s\.grigoriev@gmail\.com|valeriia@datatalks\.club/i); assert.match(mime, /Best regards,\r\nOrin Vale/); const saved = (await getTaskConsistent(client, input.taskId))!; assert.equal(saved.podcastFirstContact!.state, 'sent'); assert.equal(saved.podcastFirstContact!.delivery!.messageId, 'synthetic-message'); + assert.deepEqual(saved.podcastFirstContact!.delivery!.cc, ['alexey@datatalks.club']); assert.ok(saved.notesLog!.some(note => note.text.includes('synthetic-message'))); const replay = await staffReq('POST', '/api/podcast-first-contact/send', input); assert.equal(replay.statusCode, 200, replay.body); @@ -185,6 +190,78 @@ Orin Vale`); setSponsorDocEmailDepsForTests({}); }); + it('prepares and delivers Orin-driven guest thread replies with alexey@datatalks.club in Cc and no extra recipients', async () => { + const prepared = preparePodcastGuestReply({ + to: 'guest@example.test', + subject: 'Sofiia at DataTalks.Club Podcast', + body: 'Thanks! Tuesday at 17:00 CET works well.', + threadId: 'thread-guest-1', + inReplyTo: '', + cc: ['orin@datatalks.club', 'guest@example.test'], + }); + assert.equal(prepared.from, 'orin@datatalks.club'); + assert.equal(prepared.to, 'guest@example.test'); + assert.deepEqual(prepared.cc, ['alexey@datatalks.club']); + assert.equal(prepared.subject, 'Re: Sofiia at DataTalks.Club Podcast'); + assert.equal(prepared.threadId, 'thread-guest-1'); + assert.equal(prepared.inReplyTo, ''); + + for (const forbidden of ['grace@datatalks.club', 'alexey.s.grigoriev@gmail.com', 'valeriia@datatalks.club', 'extra@example.test']) { + assert.throws(() => preparePodcastGuestReply({ + to: 'guest@example.test', + subject: 'Re: Sofiia at DataTalks.Club Podcast', + body: 'Confirming slot.', + cc: ['alexey@datatalks.club', forbidden], + }), /Forbidden|Unexpected extra/); + } + + const previewRes = await staffReq('POST', '/api/podcast-first-contact/reply', { + to: 'guest@example.test', + subject: 'Re: Sofiia at DataTalks.Club Podcast', + body: 'Confirming Tuesday at 17:00 CET.', + threadId: 'thread-guest-1', + }); + assert.equal(previewRes.statusCode, 200, previewRes.body); + const previewBody = JSON.parse(previewRes.body); + assert.equal(previewBody.preview, true); + assert.deepEqual(previewBody.email.cc, ['alexey@datatalks.club']); + assert.equal(previewBody.email.subject, 'Re: Sofiia at DataTalks.Club Podcast'); + + const input = await invitation(); + let replyMime = ''; + let sentThreadId = ''; + setSponsorDocEmailDepsForTests({ credential: 'synthetic-credential', fetchImpl: async (url, init) => { + if (String(url).includes('/threads/thread-guest-1')) { + return new Response(JSON.stringify({ + messages: [{ payload: { headers: [{ name: 'Subject', value: 'Sofiia at DataTalks.Club Podcast' }, { name: 'Message-ID', value: '' }] } }], + }), { status: 200 }); + } + if (String(url).endsWith('/messages/send')) { + const parsed = JSON.parse(String(init?.body)); + sentThreadId = parsed.threadId; + replyMime = Buffer.from(parsed.raw, 'base64url').toString('utf8'); + return new Response(JSON.stringify({ id: 'reply-message-1', threadId: 'thread-guest-1' }), { status: 200 }); + } + return new Response(JSON.stringify({ access_token: 'synthetic-access', provider_account_id: 'orin@datatalks.club' }), { status: 200 }); + } }); + const sendRes = await staffReq('POST', '/api/podcast-first-contact/reply', { + to: 'guest@example.test', + subject: 'Sofiia at DataTalks.Club Podcast', + body: 'Confirming Tuesday at 17:00 CET.', + threadId: 'thread-guest-1', + taskId: input.taskId, + send: true, + }); + assert.equal(sendRes.statusCode, 200, sendRes.body); + assert.equal(sentThreadId, 'thread-guest-1'); + assert.match(replyMime, /From: Orin \r\n/); + assert.match(replyMime, /To: guest@example\.test\r\n/); + assert.match(replyMime, /Cc: alexey@datatalks\.club\r\n/); + assert.match(replyMime, /In-Reply-To: \r\n/); + assert.doesNotMatch(replyMime, /grace@datatalks\.club|alexey\.s\.grigoriev@gmail\.com|valeriia@datatalks\.club/i); + setSponsorDocEmailDepsForTests({}); + }); + it('rejects an unrelated guest form before attempting delivery', async () => { const input = await invitation(); const other = await invitation(); diff --git a/cli/src/commands/cards.mjs b/cli/src/commands/cards.mjs index 0b4bccd1..5c43d70a 100644 --- a/cli/src/commands/cards.mjs +++ b/cli/src/commands/cards.mjs @@ -568,6 +568,9 @@ export async function invitation(args, io) { } const preview = result.email || (typeof result.preview === "object" ? result.preview : undefined); if (preview?.subject && typeof preview.body === "string") { + if (Array.isArray(preview.cc) && preview.cc.length > 0) { + io.print(`Cc: ${preview.cc.join(", ")}`); + } io.print(`Subject: ${preview.subject}`); io.print(""); io.print(preview.body); diff --git a/cli/src/commands/podcast.mjs b/cli/src/commands/podcast.mjs index 6c48eb46..ee343c24 100644 --- a/cli/src/commands/podcast.mjs +++ b/cli/src/commands/podcast.mjs @@ -599,6 +599,9 @@ export async function invite(args, io) { io.print(""); io.print(`From: ${rendered.from}`); io.print(`To: ${email}`); + if (Array.isArray(rendered.cc) && rendered.cc.length > 0) { + io.print(`Cc: ${rendered.cc.join(", ")}`); + } io.print(`Subject: ${rendered.subject}`); io.print(""); io.print(rendered.body); @@ -623,7 +626,8 @@ export async function sendInvite(args, io) { const result = await apiFor(args).post('/api/podcast-first-contact/send', payload); if (args.json) return result; if (result.preview) { - io.print(`From: ${result.email.from}\nTo: ${result.email.to}\nSubject: ${result.email.subject}\n\n${result.email.body}`); + const ccLine = Array.isArray(result.email.cc) && result.email.cc.length > 0 ? `\nCc: ${result.email.cc.join(', ')}` : ''; + io.print(`From: ${result.email.from}\nTo: ${result.email.to}${ccLine}\nSubject: ${result.email.subject}\n\n${result.email.body}`); io.print('Preview only; no email sent.'); } else { const delivery = result.emailDelivery || {}; diff --git a/cli/test/cards-invitation.test.mjs b/cli/test/cards-invitation.test.mjs index b97f1422..9bd4bf66 100644 --- a/cli/test/cards-invitation.test.mjs +++ b/cli/test/cards-invitation.test.mjs @@ -6,13 +6,14 @@ const originalFetch = globalThis.fetch; const { run } = await import('../src/cli.mjs'); afterEach(() => { globalThis.fetch = originalFetch; }); test('invitation status and preview are read-only, preserve evidence and show rendered copy', async () => { - const calls = fakePortal({ 'GET /api/cards/synthetic-card/guest-intake': ({ query }) => ({ invitation: { status: 'sent', recipient: 'guest@example.test', taskId: 'task-test', messageId: 'gmail-test' }, ...(query.preview ? { preview: true, email: { subject: 'Synthetic guest', body: 'Synthetic invitation body.' } } : {}) }) }); + const calls = fakePortal({ 'GET /api/cards/synthetic-card/guest-intake': ({ query }) => ({ invitation: { status: 'sent', recipient: 'guest@example.test', taskId: 'task-test', messageId: 'gmail-test' }, ...(query.preview ? { preview: true, email: { cc: ['alexey@datatalks.club'], subject: 'Synthetic guest', body: 'Synthetic invitation body.' } } : {}) }) }); const status = captureIo(); assert.equal(await run(['cards', 'invitation', 'synthetic-card'], status), 0); assert.match(status.text(), /First invitation: sent/); assert.match(status.text(), /Gmail message ID: gmail-test/); const preview = captureIo(); assert.equal(await run(['cards', 'invitation', 'synthetic-card', '--preview'], preview), 0); + assert.match(preview.text(), /Cc: alexey@datatalks\.club/); assert.match(preview.text(), /Subject: Synthetic guest/); assert.match(preview.text(), /Synthetic invitation body/); assert.equal(calls.at(-1).query.preview, '1'); diff --git a/cli/test/podcast.test.mjs b/cli/test/podcast.test.mjs index b0965013..7d4b162f 100644 --- a/cli/test/podcast.test.mjs +++ b/cli/test/podcast.test.mjs @@ -394,6 +394,7 @@ describe("podcast publication evidence flags", () => { }; const email = { from: "orin@datatalks.club", + cc: ["alexey@datatalks.club"], subject: "Sofiia at DataTalks.Club Podcast", body: "Hi Sofiia,\n\nFor the interview, will Wednesday 4 November at 12:30 CET work for you?\n\nPlease do not invent alternate times in email.\n\nhttps://datatalksclub.github.io/docs/activities/podcast/", docsUrl: "https://datatalksclub.github.io/docs/activities/podcast/", @@ -433,6 +434,7 @@ describe("podcast publication evidence flags", () => { // Printed email: from orin@, one slot, docs URL, send reminder. assert.match(io.text(), /From: orin@datatalks\.club/); assert.match(io.text(), /To: sofiia@example\.com/); + assert.match(io.text(), /Cc: alexey@datatalks\.club/); assert.match(io.text(), /Sofiia at DataTalks\.Club Podcast/); assert.match(io.text(), /datatalksclub\.github\.io\/docs\/activities\/podcast\//); assert.match(io.text(), /No Card created/); @@ -489,11 +491,12 @@ describe("podcast publication evidence flags", () => { test("send-invite delegates saved records to DataOps and supports a non-sending preview", async () => { const calls = fakePortal({ - 'POST /api/podcast-first-contact/send': { preview: true, email: { from: 'orin@datatalks.club', to: 'guest@example.test', subject: 'Guest at DataTalks.Club Podcast', body: 'Synthetic preview' } }, + 'POST /api/podcast-first-contact/send': { preview: true, email: { from: 'orin@datatalks.club', to: 'guest@example.test', cc: ['alexey@datatalks.club'], subject: 'Guest at DataTalks.Club Podcast', body: 'Synthetic preview' } }, }); const io = captureIo(); assert.equal(await run(['podcast', 'send-invite', '--hold', 'hold-1', '--doc', 'doc-1', '--task', 'task-1', '--guest', 'Synthetic Guest', '--preview'], io), 0); assert.deepEqual(calls[0].body, { holdId: 'hold-1', docId: 'doc-1', taskId: 'task-1', guestName: 'Synthetic Guest', preview: true }); + assert.match(io.text(), /Cc: alexey@datatalks\.club/); assert.match(io.text(), /Preview only; no email sent/); });