Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions backend/src/events/guestFirstContact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
if (!APPROVED_GUEST_EVENT_TYPES.has(input.type)) throw new Error('This event type has no enabled guest invitation');
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/events/guestFirstContactDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
16 changes: 15 additions & 1 deletion backend/src/events/guestIntakePolicy.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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');
Expand All @@ -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;
}
4 changes: 2 additions & 2 deletions backend/src/events/guestIntakeWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`);
Expand Down Expand Up @@ -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);
Expand Down
93 changes: 92 additions & 1 deletion backend/src/podcast/firstContact.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
42 changes: 38 additions & 4 deletions backend/src/routes/podcastFirstContact.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,14 +27,46 @@ export async function handlePodcastFirstContactRoutes(
event: LambdaEvent,
client: DynamoDBDocumentClient,
): Promise<LambdaResponse> {
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;

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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
Loading