fraim-framework 2.0.34 → 2.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/fraim.js +52 -5
  2. package/dist/registry/scripts/cleanup-branch.js +62 -33
  3. package/dist/registry/scripts/generate-engagement-emails.js +119 -44
  4. package/dist/registry/scripts/newsletter-helpers.js +208 -268
  5. package/dist/registry/scripts/profile-server.js +387 -0
  6. package/dist/tests/test-chalk-regression.js +18 -2
  7. package/dist/tests/test-client-scripts-validation.js +133 -0
  8. package/dist/tests/test-markdown-to-pdf.js +454 -0
  9. package/dist/tests/test-script-location-independence.js +76 -28
  10. package/package.json +5 -2
  11. package/registry/agent-guardrails.md +62 -62
  12. package/registry/rules/communication.md +121 -121
  13. package/registry/rules/continuous-learning.md +54 -54
  14. package/registry/rules/hitl-ppe-record-analysis.md +302 -302
  15. package/registry/rules/software-development-lifecycle.md +104 -104
  16. package/registry/scripts/cleanup-branch.ts +341 -0
  17. package/registry/scripts/code-quality-check.sh +559 -559
  18. package/registry/scripts/detect-tautological-tests.sh +38 -38
  19. package/registry/scripts/generate-engagement-emails.ts +830 -0
  20. package/registry/scripts/markdown-to-pdf.js +395 -0
  21. package/registry/scripts/newsletter-helpers.ts +777 -0
  22. package/registry/scripts/profile-server.ts +424 -0
  23. package/registry/scripts/run-thank-you-workflow.ts +122 -0
  24. package/registry/scripts/send-newsletter-simple.ts +102 -0
  25. package/registry/scripts/send-thank-you-emails.ts +57 -0
  26. package/registry/scripts/validate-openapi-limits.ts +366 -366
  27. package/registry/scripts/validate-test-coverage.ts +280 -280
  28. package/registry/scripts/verify-pr-comments.sh +70 -70
  29. package/registry/templates/bootstrap/ARCHITECTURE-TEMPLATE.md +53 -53
  30. package/registry/templates/evidence/Implementation-BugEvidence.md +85 -85
  31. package/registry/templates/evidence/Implementation-FeatureEvidence.md +120 -120
  32. package/registry/workflows/convert-to-pdf.md +235 -0
  33. package/registry/workflows/customer-development/insight-analysis.md +156 -156
  34. package/registry/workflows/customer-development/interview-preparation.md +421 -421
  35. package/registry/workflows/customer-development/strategic-brainstorming.md +146 -146
  36. package/registry/workflows/quality-assurance/iterative-improvement-cycle.md +562 -562
  37. package/registry/workflows/reviewer/review-implementation-vs-feature-spec.md +669 -669
  38. package/dist/registry/scripts/build-scripts-generator.js +0 -205
  39. package/dist/registry/scripts/fraim-config.js +0 -61
  40. package/dist/registry/scripts/generic-issues-api.js +0 -100
  41. package/dist/registry/scripts/openapi-generator.js +0 -664
  42. package/dist/registry/scripts/performance/profile-server.js +0 -390
  43. package/dist/test-utils.js +0 -96
  44. package/dist/tests/esm-compat.js +0 -11
  45. package/dist/tests/test-chalk-esm-issue.js +0 -159
  46. package/dist/tests/test-chalk-real-world.js +0 -265
  47. package/dist/tests/test-chalk-resolution-issue.js +0 -304
  48. package/dist/tests/test-fraim-install-chalk-issue.js +0 -254
  49. package/dist/tests/test-npm-resolution-diagnostic.js +0 -140
@@ -0,0 +1,777 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ /**
4
+ * Newsletter Helper Functions
5
+ *
6
+ * DETERMINISTIC functions for AI agents to use when creating newsletters.
7
+ * AI agents do the CREATIVE work (categorization, content writing).
8
+ * These functions provide TOOLS (data fetching, email sending).
9
+ */
10
+
11
+ import { execSync } from 'child_process';
12
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
13
+ import { MongoClient } from 'mongodb';
14
+ import { join } from 'path';
15
+
16
+ // Self-contained utility functions (no FRAIM internal imports)
17
+ function determineDatabaseName(): string {
18
+ const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
19
+ // If explicitly set to ppe/staging, use that; otherwise default to prod
20
+ if (env === 'ppe' || env === 'staging') {
21
+ return 'fraim_ppe';
22
+ }
23
+ // Default to production
24
+ return process.env.MONGO_DB_NAME || 'fraim_prod';
25
+ }
26
+
27
+ function determineSchema(branchName: string): string {
28
+ if (branchName.includes('ppe') || branchName.includes('staging')) return 'ppe';
29
+ return 'prod';
30
+ }
31
+
32
+ function getCurrentGitBranch(): string {
33
+ try {
34
+ return execSync('git branch --show-current', { encoding: 'utf8' }).trim();
35
+ } catch (error) {
36
+ return 'master';
37
+ }
38
+ }
39
+
40
+ interface FraimConfig {
41
+ persona: { name: string; displayNamePattern: string; emailSignature: string };
42
+ git: { repoOwner: string; repoName: string };
43
+ database: { identityCollection: string; executiveCollection: string };
44
+ marketing: { websiteUrl?: string; chatUrl?: string };
45
+ }
46
+
47
+ function loadClientConfig(): FraimConfig {
48
+ const configPath = join(process.cwd(), '.fraim', 'config.json');
49
+ if (!existsSync(configPath)) {
50
+ throw new Error('.fraim/config.json not found. Run fraim init first.');
51
+ }
52
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
53
+ }
54
+
55
+ function getEnvOr(keys: string[], fallback: string): string {
56
+ for (const key of keys) {
57
+ const value = process.env[key];
58
+ if (value && value.length) return value;
59
+ }
60
+ return fallback;
61
+ }
62
+
63
+ // Load configuration
64
+ const config = loadClientConfig();
65
+
66
+ const fraimConfig = {
67
+ repoOwner: config.git.repoOwner || 'mathursrus',
68
+ repoName: config.git.repoName || 'fraim-repo',
69
+ personaName: config.persona.name,
70
+ identityCollection: config.database?.identityCollection || 'Identity',
71
+ executiveCollection: config.database?.executiveCollection || 'Executive',
72
+ defaultEmail: getEnvOr(['FRAIM_DEFAULT_EMAIL'], 'agent@example.com'),
73
+ prodDefaultEmail: getEnvOr(['PROD_FRAIM_DEFAULT_EMAIL'], 'agent@example.com'),
74
+ defaultAccessToken: getEnvOr(['FRAIM_DEFAULT_ACCESS_TOKEN'], ''),
75
+ defaultRefreshToken: getEnvOr(['FRAIM_DEFAULT_REFRESH_TOKEN'], ''),
76
+ prodAccessToken: getEnvOr(['PROD_FRAIM_DEFAULT_ACCESS_TOKEN'], ''),
77
+ prodRefreshToken: getEnvOr(['PROD_FRAIM_DEFAULT_REFRESH_TOKEN'], ''),
78
+ defaultOAuthClientId: getEnvOr(['FRAIM_DEFAULT_OAUTH_CLIENT_ID'], ''),
79
+ defaultOAuthClientSecret: getEnvOr(['FRAIM_DEFAULT_OAUTH_CLIENT_SECRET'], ''),
80
+ prodOAuthClientId: getEnvOr(['PROD_FRAIM_DEFAULT_OAUTH_CLIENT_ID'], ''),
81
+ prodOAuthClientSecret: getEnvOr(['PROD_FRAIM_DEFAULT_OAUTH_CLIENT_SECRET'], ''),
82
+ webAppUrl: config.marketing?.websiteUrl || getEnvOr(['FRAIM_WEB_APP_URL'], 'http://localhost:3000'),
83
+ chatUrl: config.marketing?.chatUrl || getEnvOr(['FRAIM_CHAT_URL'], ''),
84
+ };
85
+
86
+ // Helper functions for database operations
87
+ function getProductionDatabase() {
88
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
89
+ if (!mongoUrl) {
90
+ throw new Error('PROD_MONGO_DATABASE_URL or MONGO_DATABASE_URL environment variable is required');
91
+ }
92
+ return new MongoClient(mongoUrl);
93
+ }
94
+
95
+ /**
96
+ * Get database name, defaulting to production
97
+ */
98
+ function getDatabaseName(): string {
99
+ const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
100
+ // If explicitly set to ppe/staging, use that; otherwise default to prod
101
+ if (env === 'ppe' || env === 'staging') {
102
+ return determineDatabaseName();
103
+ }
104
+ // Default to production
105
+ return process.env.MONGO_DB_NAME || 'fraim_prod';
106
+ }
107
+
108
+ /**
109
+ * Get collection name with schema prefix (defaults to prod schema)
110
+ */
111
+ function getCollectionName(baseName: string): string {
112
+ const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
113
+ // If explicitly set to ppe/staging, use that schema; otherwise default to prod
114
+ if (env === 'ppe' || env === 'staging') {
115
+ const schema = determineSchema(getCurrentGitBranch());
116
+ return `${schema}_${baseName}`;
117
+ }
118
+ // Default to prod schema
119
+ return `prod_${baseName}`;
120
+ }
121
+ // Reuse existing email infrastructure - implemented inline to avoid FRAIM internal imports
122
+
123
+ /**
124
+ * Find executive by email in database (defaults to production)
125
+ */
126
+ async function findExecutiveByEmail(email: string): Promise<Executive | null> {
127
+ const client = getProductionDatabase();
128
+ try {
129
+ await client.connect();
130
+ const dbName = getDatabaseName();
131
+ const db = client.db(dbName);
132
+ const executive = await db.collection(getCollectionName('Executive')).findOne({ email: email.toLowerCase() });
133
+ return executive as unknown as Executive;
134
+ } finally {
135
+ await client.close();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Get the persona email for an executive from the identity collection
141
+ */
142
+ async function getPersonaEmailForExecutive(executiveId: string): Promise<string> {
143
+ const { MongoClient } = await import('mongodb');
144
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
145
+ if (!mongoUrl) {
146
+ console.warn(`⚠️ PROD_MONGO_DATABASE_URL not set, using default ${fraimConfig.personaName} email`);
147
+ return fraimConfig.defaultEmail;
148
+ }
149
+
150
+ const client = new MongoClient(mongoUrl);
151
+ try {
152
+ await client.connect();
153
+ const dbName = getDatabaseName();
154
+ const db = client.db(dbName);
155
+ const collectionName = getCollectionName(fraimConfig.identityCollection);
156
+
157
+ // Query the identity collection (defaults to prod)
158
+ let identity = await db.collection(collectionName).findOne({
159
+ executive_id: executiveId,
160
+ status: 'active'
161
+ });
162
+
163
+ if (!identity) {
164
+ identity = await db.collection(collectionName).findOne({
165
+ executive_id: executiveId
166
+ });
167
+ }
168
+
169
+ if (identity && identity.email) {
170
+ return identity.email;
171
+ }
172
+
173
+ return fraimConfig.defaultEmail;
174
+ } catch (error) {
175
+ console.warn(`⚠️ Could not get ${fraimConfig.personaName} email for executive ${executiveId}:`, error);
176
+ return fraimConfig.defaultEmail;
177
+ } finally {
178
+ await client.close();
179
+ }
180
+ }
181
+
182
+ interface ResolvedIssue {
183
+ number: number;
184
+ title: string;
185
+ body: string;
186
+ closedAt: string;
187
+ author: {
188
+ login: string;
189
+ };
190
+ labels: Array<{
191
+ name: string;
192
+ }>;
193
+ }
194
+
195
+ interface Executive {
196
+ id?: string;
197
+ email: string;
198
+ name: string;
199
+ }
200
+
201
+ interface PotentialCustomer {
202
+ email: string;
203
+ name: string;
204
+ addedDate?: string;
205
+ source?: string; // e.g., "linkedin", "referral", "website", etc.
206
+ }
207
+
208
+ interface Newsletter {
209
+ metadata: {
210
+ generatedOn: string;
211
+ weekStart: string;
212
+ weekEnd: string;
213
+ totalIssues: number;
214
+ [key: string]: any;
215
+ };
216
+ content: {
217
+ weekTitle: string;
218
+ [key: string]: any;
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Get the latest date from existing newsletter files
224
+ * Returns the most recent date when newsletter was sent, or null if no files exist
225
+ */
226
+ export function getLatestNewsletterDate(): string | null {
227
+ const newslettersDir = 'docs/customer-development/newsletters';
228
+ if (!existsSync(newslettersDir)) {
229
+ return null;
230
+ }
231
+
232
+ const files = readdirSync(newslettersDir).filter(f => f.startsWith('newsletter-') && f.endsWith('.json'));
233
+ if (files.length === 0) {
234
+ return null;
235
+ }
236
+
237
+ // Extract dates from filenames (e.g., newsletter-2025-11-01.json)
238
+ const dates: string[] = [];
239
+ for (const file of files) {
240
+ const dateMatch = file.match(/newsletter-(\d{4}-\d{2}-\d{2})\.json/);
241
+ if (dateMatch) {
242
+ const filePath = join(newslettersDir, file);
243
+ // Verify content is valid if needed, but for date extraction filename is enough
244
+ dates.push(dateMatch[1]);
245
+ }
246
+ }
247
+
248
+ if (dates.length === 0) {
249
+ return null;
250
+ }
251
+
252
+ // Return the most recent date
253
+ dates.sort();
254
+ const latestDate = dates[dates.length - 1];
255
+ console.log(`📅 Latest newsletter date found: ${latestDate}`);
256
+ return latestDate;
257
+ }
258
+
259
+ /**
260
+ * Get resolved issues for newsletter
261
+ * DETERMINISTIC: Just fetches issues, doesn't categorize or filter
262
+ * AI agent decides what to include and how to present it
263
+ *
264
+ * @param date Optional date string (YYYY-MM-DD). If not provided, uses latest newsletter date or last 7 days
265
+ */
266
+ export async function getResolvedIssuesForNewsletter(date?: string): Promise<ResolvedIssue[]> {
267
+ if (!date) {
268
+ // Try to get the latest newsletter date
269
+ const latestNewsletterDate = getLatestNewsletterDate();
270
+ if (latestNewsletterDate) {
271
+ date = latestNewsletterDate;
272
+ console.log(`📋 Using latest newsletter date as starting point: ${date}`);
273
+ } else {
274
+ // Fallback to last 7 days if no previous newsletters exist
275
+ const fallbackDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
276
+ date = fallbackDate;
277
+ console.log(`📋 No previous newsletters found, using last 7 days: ${date}`);
278
+ }
279
+ }
280
+
281
+ console.log(`📋 Fetching issues resolved since ${date}...`);
282
+
283
+ // Get all closed issues from the specified date (not just user-reported)
284
+ const command = `gh search issues --repo=${fraimConfig.repoOwner}/${fraimConfig.repoName} --state=closed --closed=">${date}" --json number,title,body,labels,closedAt,author --limit 100`;
285
+ const output = execSync(command, { encoding: 'utf-8' });
286
+ const issues: ResolvedIssue[] = JSON.parse(output);
287
+
288
+ console.log(`✅ Found ${issues.length} resolved issues`);
289
+
290
+ return issues;
291
+ }
292
+
293
+ /**
294
+ * Get all active executives (defaults to production database)
295
+ * DETERMINISTIC: Just fetches executive list
296
+ */
297
+ export async function getAllActiveExecutives(): Promise<Executive[]> {
298
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
299
+ if (!mongoUrl) {
300
+ throw new Error('PROD_MONGO_DATABASE_URL or MONGO_DATABASE_URL environment variable is required');
301
+ }
302
+
303
+ const client = new MongoClient(mongoUrl);
304
+ try {
305
+ await client.connect();
306
+ const dbName = getDatabaseName();
307
+ const collectionName = getCollectionName('Executive');
308
+
309
+ console.log(`[getAllActiveExecutives] Querying database: ${dbName}, collection: ${collectionName}`);
310
+
311
+ const db = client.db(dbName);
312
+ const executives = await db.collection(collectionName)
313
+ .find({})
314
+ .toArray();
315
+
316
+ console.log(`[getAllActiveExecutives] Found ${executives.length} executives in ${dbName}.${collectionName}`);
317
+
318
+ // If no executives found in prod, check PPE as fallback
319
+ if (executives.length === 0 && dbName === 'fraim_prod') {
320
+ console.log(`[getAllActiveExecutives] No executives in prod, checking PPE as fallback...`);
321
+ const ppeDb = client.db('fraim_ppe');
322
+ const ppeExecutives = await ppeDb.collection('ppe_Executive')
323
+ .find({})
324
+ .toArray();
325
+ console.log(`[getAllActiveExecutives] Found ${ppeExecutives.length} executives in fraim_ppe.ppe_Executive`);
326
+ return ppeExecutives as unknown as Executive[];
327
+ }
328
+
329
+ return executives as unknown as Executive[];
330
+ } finally {
331
+ await client.close();
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Get all potential customers (leads who have expressed interest)
337
+ * DETERMINISTIC: Just fetches potential customer list from JSON file
338
+ */
339
+ export function getPotentialCustomers(): PotentialCustomer[] {
340
+ const potentialCustomersPath = join(process.cwd(), 'docs', 'customer-development', 'potential-customers.json');
341
+
342
+ if (!existsSync(potentialCustomersPath)) {
343
+ return [];
344
+ }
345
+
346
+ try {
347
+ const content = readFileSync(potentialCustomersPath, 'utf-8');
348
+ return JSON.parse(content) as PotentialCustomer[];
349
+ } catch (error) {
350
+ console.error('❌ Error reading potential customers file:', error);
351
+ return [];
352
+ }
353
+ }
354
+ /**
355
+ * Send newsletter to all executives and potential customers
356
+ * DETERMINISTIC: Just sends emails, no decisions
357
+ *
358
+ * AI agents call this AFTER user approves the newsletter JSON
359
+ * @param newsletterPath Path to newsletter JSON file
360
+ * @param filterEmails Optional array of email addresses to send to (for testing)
361
+ * @param filterExecIds Optional array of executive IDs to send to (for testing)
362
+ * @param includePotentialCustomers Whether to include potential customers in the send (default: true)
363
+ * @param showOnly If true, only show recipient list without sending
364
+ */
365
+ export async function sendNewsletterToExecutives(
366
+ newsletterPath: string,
367
+ filterEmails?: string[],
368
+ filterExecIds?: string[],
369
+ includePotentialCustomers: boolean = true,
370
+ showOnly: boolean = false,
371
+ includeExecutives: boolean = true
372
+ ): Promise<void> {
373
+ console.log(`📧 Sending newsletter from: ${newsletterPath}`);
374
+
375
+ if (!existsSync(newsletterPath)) {
376
+ throw new Error(`Newsletter file not found: ${newsletterPath}`);
377
+ }
378
+
379
+ const newsletter: Newsletter = JSON.parse(readFileSync(newsletterPath, 'utf-8'));
380
+ const htmlPath = newsletterPath.replace('.json', '.html');
381
+
382
+ if (!existsSync(htmlPath)) {
383
+ throw new Error(`Newsletter HTML not found: ${htmlPath}. Generate it first.`);
384
+ }
385
+
386
+ const html = readFileSync(htmlPath, 'utf-8');
387
+ let executives: any[] = [];
388
+
389
+ // Get executives only if includeExecutives is true
390
+ if (includeExecutives) {
391
+ executives = await getAllActiveExecutives();
392
+
393
+ // Filter executives if filters are provided
394
+ if (filterEmails && filterEmails.length > 0) {
395
+ console.log(`🔍 Filtering to ${filterEmails.length} email(s): ${filterEmails.join(', ')}`);
396
+ executives = executives.filter(exec => filterEmails.includes(exec.email));
397
+ }
398
+
399
+ if (filterExecIds && filterExecIds.length > 0) {
400
+ console.log(`🔍 Filtering to ${filterExecIds.length} executive ID(s): ${filterExecIds.join(', ')}`);
401
+ executives = executives.filter(exec => exec.id && filterExecIds.includes(exec.id));
402
+ }
403
+ }
404
+
405
+ // Get potential customers if requested
406
+ let potentialCustomers: PotentialCustomer[] = [];
407
+ if (includePotentialCustomers) {
408
+ potentialCustomers = getPotentialCustomers();
409
+
410
+ // Filter potential customers if filterEmails is provided
411
+ if (filterEmails && filterEmails.length > 0) {
412
+ potentialCustomers = potentialCustomers.filter(customer => filterEmails.includes(customer.email));
413
+ }
414
+ }
415
+
416
+ if (includeExecutives) {
417
+ console.log(`📊 Found ${executives.length} executive(s) to send to`);
418
+ }
419
+ if (includePotentialCustomers) {
420
+ console.log(`📊 Found ${potentialCustomers.length} potential customer(s) to send to`);
421
+ }
422
+
423
+ if (showOnly) {
424
+ console.log(`\n📋 Recipient List (--showonly mode - no emails will be sent):\n`);
425
+
426
+ if (includeExecutives && executives.length > 0) {
427
+ console.log(`\n👥 ACTIVE EXECUTIVES (${executives.length}):\n`);
428
+ for (const exec of executives) {
429
+ const fromEmail = exec.id
430
+ ? await getPersonaEmailForExecutive(exec.id)
431
+ : fraimConfig.defaultEmail;
432
+ const fromDisplayName = `${fraimConfig.personaName} - ${exec.name}'s AI Executive Assistant`;
433
+
434
+ console.log(` ${exec.name || 'Unknown Name'}`);
435
+ console.log(` To: ${exec.email || 'No email'}`);
436
+ console.log(` From Email: ${fromEmail}`);
437
+ console.log(` From Display Name: ${fromDisplayName}`);
438
+ console.log(` Executive ID: ${exec.id || 'No ID'}`);
439
+ console.log('');
440
+ }
441
+ }
442
+
443
+ if (potentialCustomers.length > 0) {
444
+ const defaultEmail = fraimConfig.prodDefaultEmail || 'agent@example.com';
445
+ const defaultDisplayName = `${fraimConfig.personaName} - Your AI Executive Assistant`;
446
+
447
+ console.log(`\n🎯 POTENTIAL CUSTOMERS (${potentialCustomers.length}):\n`);
448
+ potentialCustomers.forEach((customer, index) => {
449
+ console.log(` ${customer.name || 'Unknown Name'}`);
450
+ console.log(` To: ${customer.email || 'No email'}`);
451
+ console.log(` From Email: ${defaultEmail}`);
452
+ console.log(` From Display Name: ${defaultDisplayName}`);
453
+ console.log(` Source: ${customer.source || 'Unknown'}`);
454
+ console.log('');
455
+ });
456
+ }
457
+
458
+ const totalRecipients = (includeExecutives ? executives.length : 0) + (includePotentialCustomers ? potentialCustomers.length : 0);
459
+ console.log(`✅ Total: ${totalRecipients} recipient(s) would receive the newsletter`);
460
+ return;
461
+ }
462
+
463
+ console.log(`\n📧 Sending newsletter...\n`);
464
+
465
+ // Send to active executives (from their personal Persona email) - only if includeExecutives is true
466
+ if (includeExecutives) {
467
+ for (const executive of executives) {
468
+ try {
469
+ console.log(`📧 Sending to executive ${executive.name} (${executive.email})...`);
470
+
471
+ const fromEmail = executive.id
472
+ ? await getPersonaEmailForExecutive(executive.id)
473
+ : fraimConfig.defaultEmail;
474
+
475
+ const fromDisplayName = `${fraimConfig.personaName} - ${executive.name}'s AI Executive Assistant`;
476
+ // Remove emojis from subject line for ASCII compatibility
477
+ const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
478
+ const plainText = generatePlainText(newsletter);
479
+
480
+ // Get tokens and send (reuses existing infrastructure)
481
+ const personaTokens = await getPersonaTokens(executive.id);
482
+ const executiveData = await findExecutiveByEmail(executive.email);
483
+
484
+ await sendEmailViaGmail({
485
+ to: executive.email,
486
+ subject,
487
+ plainTextBody: plainText,
488
+ htmlBody: html,
489
+ fromEmail,
490
+ fromDisplayName
491
+ }, executiveData, personaTokens);
492
+
493
+ console.log(`✅ Sent to ${executive.email}`);
494
+ } catch (error) {
495
+ console.error(`❌ Failed to send to ${executive.email}:`, error);
496
+ }
497
+ }
498
+ }
499
+
500
+ // Send to potential customers (from PROD_FRAIM_DEFAULT_EMAIL)
501
+ const defaultEmail = fraimConfig.prodDefaultEmail || 'agent@example.com';
502
+ const defaultDisplayName = `${fraimConfig.personaName} - Your AI Executive Assistant`;
503
+ const defaultAccessToken = fraimConfig.prodAccessToken || '';
504
+ const defaultRefreshToken = fraimConfig.prodRefreshToken || '';
505
+
506
+ if (!defaultAccessToken || !defaultRefreshToken) {
507
+ console.warn('⚠️ PROD_FRAIM_DEFAULT_ACCESS_TOKEN or PROD_FRAIM_DEFAULT_REFRESH_TOKEN not set. Skipping potential customers.');
508
+ } else {
509
+ for (const customer of potentialCustomers) {
510
+ try {
511
+ console.log(`📧 Sending to potential customer ${customer.name} (${customer.email})...`);
512
+
513
+ // Remove emojis from subject line for ASCII compatibility
514
+ const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
515
+ const plainText = generatePlainText(newsletter);
516
+
517
+ // Send from default Persona email with default tokens
518
+ await sendEmailViaGmail({
519
+ to: customer.email,
520
+ subject,
521
+ plainTextBody: plainText,
522
+ htmlBody: html,
523
+ fromEmail: defaultEmail,
524
+ fromDisplayName: defaultDisplayName
525
+ }, undefined, { access_token: defaultAccessToken, refresh_token: defaultRefreshToken });
526
+
527
+ console.log(`✅ Sent to ${customer.email}`);
528
+ } catch (error) {
529
+ console.error(`❌ Failed to send to ${customer.email}:`, error);
530
+ }
531
+ }
532
+ }
533
+
534
+ console.log(`\n✅ Newsletter sending complete!`);
535
+ if (includeExecutives) {
536
+ console.log(` Sent to ${executives.length} executive(s)`);
537
+ }
538
+ if (includePotentialCustomers) {
539
+ console.log(` Sent to ${potentialCustomers.length} potential customer(s)`);
540
+ }
541
+ process.exit(0);
542
+ }
543
+
544
+ /**
545
+ * Get Persona tokens for executive
546
+ */
547
+ async function getPersonaTokens(executiveId?: string): Promise<{ access_token?: string; refresh_token?: string } | null> {
548
+ if (!executiveId) return null;
549
+
550
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
551
+ if (!mongoUrl) return null;
552
+
553
+ const client = new MongoClient(mongoUrl);
554
+ try {
555
+ await client.connect();
556
+ const dbName = getDatabaseName();
557
+ const db = client.db(dbName);
558
+ const collectionName = getCollectionName(fraimConfig.identityCollection);
559
+
560
+ // Check for identity by executive ID
561
+ const identity = await db.collection(collectionName).findOne({
562
+ executive_id: executiveId,
563
+ status: 'active'
564
+ }) || await db.collection(collectionName).findOne({
565
+ executive_id: executiveId
566
+ });
567
+
568
+ if (identity && identity.access_token && identity.refresh_token) {
569
+ return {
570
+ access_token: identity.access_token,
571
+ refresh_token: identity.refresh_token
572
+ };
573
+ }
574
+
575
+ return null;
576
+ } finally {
577
+ await client.close();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Generate plain text version
583
+ */
584
+ function generatePlainText(newsletter: Newsletter): string {
585
+ const { content } = newsletter;
586
+ let text = `${content.weekTitle}\n`;
587
+ text += `${content.weekSubtitle}\n`;
588
+ text += `Week of ${content.weekDate}\n\n`;
589
+ text += `${content.openingMessage}\n\n`;
590
+
591
+ // Hero feature(s) - support both single heroFeature and array of heroFeatures
592
+ const heroFeatures = content.heroFeatures || (content.heroFeature ? [content.heroFeature] : []);
593
+ if (heroFeatures.length > 0) {
594
+ const heroLabel = heroFeatures.length > 1 ? '✨ HERO FEATURES OF THE WEEK' : '✨ FEATURE OF THE WEEK';
595
+ text += `${heroLabel}\n\n`;
596
+ heroFeatures.forEach((hero: any, index: number) => {
597
+ if (heroFeatures.length > 1) {
598
+ text += `HERO FEATURE ${index + 1}:\n`;
599
+ }
600
+ text += `${hero.title}\n`;
601
+ text += `${hero.description}\n`;
602
+ if (hero.impact) {
603
+ text += `Impact: ${hero.impact}\n`;
604
+ }
605
+ text += `\n`;
606
+ });
607
+ }
608
+
609
+ if (content.newFeatures && content.newFeatures.length > 0) {
610
+ text += `NEW FEATURES:\n`;
611
+ content.newFeatures.forEach((f: any) => {
612
+ text += `• ${f.title}: ${f.description}\n`;
613
+ });
614
+ text += `\n`;
615
+ }
616
+
617
+ if (content.improvements && content.improvements.length > 0) {
618
+ text += `IMPROVEMENTS:\n`;
619
+ content.improvements.forEach((i: any) => {
620
+ text += `• ${i.title}: ${i.description}\n`;
621
+ });
622
+ text += `\n`;
623
+ }
624
+
625
+ if (content.bugFixes && content.bugFixes.length > 0) {
626
+ text += `BUG FIXES:\n`;
627
+ content.bugFixes.forEach((b: any) => {
628
+ text += `• ${b.title}: ${b.description}\n`;
629
+ });
630
+ text += `\n`;
631
+ }
632
+
633
+ if (content.testimonial) {
634
+ text += `\n"${content.testimonial.text}"\n`;
635
+ text += `— ${content.testimonial.author}, ${content.testimonial.role}\n\n`;
636
+ }
637
+
638
+ if (content.comingNext) {
639
+ text += `COMING NEXT:\n${content.comingNext}\n\n`;
640
+ }
641
+
642
+ text += `\nWith gratitude,\n${fraimConfig.personaName}\nYour AI Executive Assistant\n\n`;
643
+ text += `Visit: ${fraimConfig.webAppUrl ? `${fraimConfig.webAppUrl}/wellness/${fraimConfig.personaName.toLowerCase()}` : '#'}\n`;
644
+
645
+ return text;
646
+ }
647
+
648
+ /**
649
+ * Send email via Gmail API (reuses existing pattern)
650
+ */
651
+ async function sendEmailViaGmail(
652
+ params: {
653
+ to: string;
654
+ subject: string;
655
+ plainTextBody: string;
656
+ htmlBody: string;
657
+ fromEmail: string;
658
+ fromDisplayName: string;
659
+ },
660
+ executive?: any,
661
+ personaTokens?: { access_token?: string; refresh_token?: string } | null
662
+ ): Promise<void> {
663
+ const { to, subject, plainTextBody, htmlBody, fromEmail, fromDisplayName } = params;
664
+
665
+ let accessToken: string;
666
+ let refreshToken: string;
667
+ let isProdToken = false; // Track if we're using PROD tokens
668
+
669
+ if (personaTokens?.access_token && personaTokens?.refresh_token) {
670
+ accessToken = personaTokens.access_token;
671
+ refreshToken = personaTokens.refresh_token;
672
+ // Check if these are PROD tokens by comparing with PROD_FRAIM_DEFAULT_REFRESH_TOKEN
673
+ const prodRefreshToken = fraimConfig.prodRefreshToken || '';
674
+ if (prodRefreshToken && refreshToken === prodRefreshToken) {
675
+ isProdToken = true;
676
+ }
677
+ } else if (executive?.personaAccessToken && executive?.personaRefreshToken) {
678
+ accessToken = executive.personaAccessToken;
679
+ refreshToken = executive.personaRefreshToken;
680
+ } else {
681
+ accessToken = fraimConfig.defaultAccessToken || '';
682
+ refreshToken = fraimConfig.defaultRefreshToken || '';
683
+
684
+ if (!accessToken || !refreshToken) {
685
+ throw new Error(`${fraimConfig.personaName} Gmail tokens not found`);
686
+ }
687
+ }
688
+
689
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
690
+ const emailMessage = [
691
+ `To: ${to}`,
692
+ `From: ${fromDisplayName} <${fromEmail}>`,
693
+ `Subject: ${subject}`,
694
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
695
+ `MIME-Version: 1.0`,
696
+ ``,
697
+ `--${boundary}`,
698
+ `Content-Type: text/plain; charset=utf-8`,
699
+ ``,
700
+ plainTextBody,
701
+ ``,
702
+ `--${boundary}`,
703
+ `Content-Type: text/html; charset=utf-8`,
704
+ ``,
705
+ htmlBody,
706
+ ``,
707
+ `--${boundary}--`
708
+ ].join('\r\n');
709
+
710
+ // Note: base64 encoding without newlines is important
711
+ const url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
712
+ const requestBody = {
713
+ raw: Buffer.from(emailMessage).toString('base64')
714
+ .replace(/\+/g, '-')
715
+ .replace(/\//g, '_')
716
+ .replace(/=+$/, '')
717
+ };
718
+
719
+ const response = await fetch(url, {
720
+ method: 'POST',
721
+ headers: {
722
+ 'Authorization': `Bearer ${accessToken}`,
723
+ 'Content-Type': 'application/json',
724
+ },
725
+ body: JSON.stringify(requestBody)
726
+ });
727
+
728
+ if (!response.ok) {
729
+ if (response.status === 401) {
730
+ // Token refresh logic - use PROD OAuth credentials if using PROD tokens
731
+ const clientId = isProdToken
732
+ ? fraimConfig.prodOAuthClientId
733
+ : fraimConfig.defaultOAuthClientId;
734
+ const clientSecret = isProdToken
735
+ ? fraimConfig.prodOAuthClientSecret
736
+ : fraimConfig.defaultOAuthClientSecret;
737
+
738
+ if (!clientId || !clientSecret) {
739
+ throw new Error(`OAuth credentials not found for token refresh${isProdToken ? ' (PROD)' : ''}`);
740
+ }
741
+
742
+ const refreshResponse = await fetch('https://oauth2.googleapis.com/token', {
743
+ method: 'POST',
744
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
745
+ body: new URLSearchParams({
746
+ client_id: clientId,
747
+ client_secret: clientSecret,
748
+ refresh_token: refreshToken,
749
+ grant_type: 'refresh_token'
750
+ })
751
+ });
752
+
753
+ if (!refreshResponse.ok) {
754
+ throw new Error('Failed to refresh access token');
755
+ }
756
+
757
+ const tokenData = await refreshResponse.json() as any;
758
+
759
+ // Retry with new token
760
+ const retryResponse = await fetch(url, {
761
+ method: 'POST',
762
+ headers: {
763
+ 'Authorization': `Bearer ${tokenData.access_token}`,
764
+ 'Content-Type': 'application/json',
765
+ },
766
+ body: JSON.stringify(requestBody)
767
+ });
768
+
769
+ if (!retryResponse.ok) {
770
+ throw new Error(`Gmail API error after refresh: ${retryResponse.status}`);
771
+ }
772
+
773
+ return;
774
+ }
775
+ throw new Error(`Gmail API error: ${response.status}`);
776
+ }
777
+ }