email-content-compose/src/index.ts

146 lines
4.5 KiB
TypeScript
Raw Normal View History

2026-03-11 23:36:44 +00:00
import type { EmailDraft, ExportResult, ExportedFile, FetchResult } from './types.js';
import { createEnvConfig as createBaseEnvConfig, getAccessToken } from '@clawd/auth-runtime';
import { fetchLeadDataset } from './lead-dataset.js';
import { draftToEml, emlFilename } from './eml.js';
import { loadR2Config, uploadToR2 } from '@clawd/r2-upload';
/**
* Fetch lead-dataset from completed workflow.
* Returns businesses with emails and reviews for LLM to compose emails.
*/
export async function fetchLeads(
workflowId: string,
dryRun: boolean = false,
): Promise<FetchResult> {
if (!workflowId) {
return failedFetch('', 'missing workflow-id');
}
if (dryRun) {
return {
status: 'success',
error: null,
workflowId,
workflowStatus: 'dry_run',
query: 'dry-run-query',
country: 'us',
productKeywords: null,
completedAt: null,
summary: { businesses_count: 3, contacts_count: 5, businesses_with_reviews: 2, businesses_with_email: 3 },
businesses: [],
};
}
const config = createBaseEnvConfig();
let accessToken: string;
try {
accessToken = await getAccessToken(false, config);
} catch (err) {
return failedFetch(workflowId, err instanceof Error ? err.message : 'failed to exchange token');
}
const { data, error } = await fetchLeadDataset(config, accessToken, workflowId, false);
if (!data) {
return failedFetch(workflowId, error);
}
return {
status: 'success',
error: null,
workflowId: data.workflow_id,
workflowStatus: data.status,
query: data.query,
country: data.country,
productKeywords: data.product_keywords,
completedAt: data.completed_at,
summary: data.summary,
businesses: data.businesses,
};
}
/**
* Export drafts: convert to EML, upload to R2, return URLs.
*/
export async function exportDrafts(
drafts: EmailDraft[],
workflowId: string,
options: {
from?: string;
prefix?: string;
dryRun?: boolean;
} = {},
): Promise<ExportResult> {
const from = options.from || process.env.SENDER_EMAIL || 'outreach@company.com';
const prefix = options.prefix || `outreach/eml/${workflowId}/${Date.now()}`;
if (!drafts.length) {
return { status: 'failed', error: 'no drafts to export', workflowId, totalDrafts: 0, exportedCount: 0, bundleUrl: null, files: [] };
}
// Convert all drafts to EML
const emlFiles: { filename: string; content: string; draft: EmailDraft }[] = [];
for (let i = 0; i < drafts.length; i++) {
const draft = drafts[i];
const filename = emlFilename(draft, i);
const content = draftToEml(draft, from);
emlFiles.push({ filename, content, draft });
}
if (options.dryRun) {
const files: ExportedFile[] = emlFiles.map(f => ({
filename: f.filename,
url: `(dry-run) ${prefix}/${f.filename}`,
recipient: f.draft.recipient_email,
subject: f.draft.subject,
}));
return { status: 'success', error: null, workflowId, totalDrafts: drafts.length, exportedCount: files.length, bundleUrl: `(dry-run) ${prefix}/bundle.zip`, files };
}
// Upload to R2
const r2Config = loadR2Config();
const files: ExportedFile[] = [];
for (const eml of emlFiles) {
const key = `${prefix}/${eml.filename}`;
const url = await uploadToR2(r2Config, key, eml.content, 'message/rfc822');
files.push({
filename: eml.filename,
url,
recipient: eml.draft.recipient_email,
subject: eml.draft.subject,
});
}
// Create and upload zip bundle
let bundleUrl: string | null = null;
try {
const { createZipBundle } = await import('./zip.js');
const zipBuffer = await createZipBundle(emlFiles.map(f => ({ name: f.filename, content: f.content })));
bundleUrl = await uploadToR2(r2Config, `${prefix}/bundle.zip`, zipBuffer, 'application/zip');
} catch {
// zip is optional
}
return { status: 'success', error: null, workflowId, totalDrafts: drafts.length, exportedCount: files.length, bundleUrl, files };
}
/**
* Read drafts from a JSON file.
*/
export async function readDraftsFile(path: string): Promise<EmailDraft[]> {
const file = Bun.file(path);
if (!await file.exists()) {
throw new Error(`file not found: ${path}`);
}
const raw = await file.json();
return Array.isArray(raw) ? raw : (raw?.drafts ?? []);
}
function failedFetch(workflowId: string, error: string): FetchResult {
return {
status: 'failed', error, workflowId, workflowStatus: '', query: '', country: '',
productKeywords: null, completedAt: null, summary: null, businesses: [],
};
}