Function bodies 406 total
approveAllPending function · javascript · L210-L222 (13 LOC)shared/outreach-core/approval-system/approval-manager.js
function approveAllPending(approvedBy = "system") {
const queue = loadApprovalQueue();
let approvedCount = 0;
for (const [category, item] of Object.entries(queue)) {
if (item.status === "pending") {
const success = approveTemplate(category, approvedBy);
if (success) approvedCount++;
}
}
return approvedCount;
}getQueueItem function · javascript · L229-L232 (4 LOC)shared/outreach-core/approval-system/approval-manager.js
function getQueueItem(category) {
const queue = loadApprovalQueue();
return queue[category] || null;
}prompt function · javascript · L25-L29 (5 LOC)shared/outreach-core/approval-system/approve-cli.js
function prompt(question) {
return new Promise((resolve) => {
rl.question(question, resolve);
});
}wrapText function · javascript · L37-L53 (17 LOC)shared/outreach-core/approval-system/approve-cli.js
function wrapText(text, width) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + word).length > width) {
lines.push(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
}
if (currentLine) lines.push(currentLine.trim());
return lines.join('\n');
}displayApprovalDetail function · javascript · L61-L72 (12 LOC)shared/outreach-core/approval-system/approve-cli.js
function displayApprovalDetail(item, index, total) {
console.log(`\n${'─'.repeat(68)}`);
console.log(`APPROVAL ${index}/${total} - Category: ${item.category || 'unknown'}`);
console.log(`${'─'.repeat(68)}\n`);
console.log(`Business: ${item.business.name}`);
console.log(`Owner: ${item.business.ownerFirstName || 'Unknown'}`);
console.log(`Category: ${item.business.category || 'unknown'}\n`);
console.log(`Subject: ${item.email.subject}`);
console.log(`${'─'.repeat(68)}`);
console.log(wrapText(item.email.body, 68));
console.log(`${'─'.repeat(68)}\n`);
}promptAction function · javascript · L78-L88 (11 LOC)shared/outreach-core/approval-system/approve-cli.js
async function promptAction() {
console.log(`Actions:`);
console.log(` [a] Approve - Save template and allow export`);
console.log(` [r] Reject - Block this category from export`);
console.log(` [e] Edit - Modify subject/body before approving`);
console.log(` [s] Skip - Review later`);
console.log(` [b] Batch - Approve all remaining templates`);
console.log(` [q] Quit\n`);
return await prompt('Your choice: ');
}handleEdit function · javascript · L96-L125 (30 LOC)shared/outreach-core/approval-system/approve-cli.js
async function handleEdit(category, currentEmail) {
console.log(`\n=== EDIT EMAIL ===\n`);
console.log(`Current subject: ${currentEmail.subject}\n`);
const newSubject = await prompt('New subject (press Enter to keep current): ');
const subject = newSubject.trim() || currentEmail.subject;
console.log(`\nCurrent body:\n${currentEmail.body}\n`);
console.log(`New body (type 'END' on new line when finished):`);
const bodyLines = [];
while (true) {
const line = await prompt('');
if (line.trim() === 'END') break;
bodyLines.push(line);
}
const body = bodyLines.length > 0 ? bodyLines.join('\n') : currentEmail.body;
console.log(`\n✓ Email updated\n`);
console.log(`Subject: ${subject}`);
console.log(`Body: ${body.substring(0, 100)}...\n`);
const confirm = await prompt('Confirm approval? (y/n): ');
if (confirm.toLowerCase() !== 'y') {
return { subject: currentEmail.subject, body: currentEmail.body };
}
return { subject, body };
}About: code-quality intelligence by Repobility · https://repobility.com
processApprovalItem function · javascript · L135-L178 (44 LOC)shared/outreach-core/approval-system/approve-cli.js
async function processApprovalItem(category, item, index, total) {
displayApprovalDetail({ ...item, category }, index, total);
const action = await promptAction();
switch (action.toLowerCase()) {
case 'a': // Approve
approveTemplate(category, 'cli-user');
console.log(`\n✓ Template approved for category: ${category}\n`);
return 'approved';
case 'r': // Reject
const reason = await prompt('Rejection reason (optional): ');
rejectTemplate(category, reason);
console.log(`\n✗ Template rejected for category: ${category}\n`);
return 'rejected';
case 'e': // Edit
const { subject, body } = await handleEdit(category, item.email);
editAndApproveTemplate(category, subject, body, 'cli-user');
console.log(`\n✓ Template edited and approved for category: ${category}\n`);
return 'approved';
case 's': // Skip
console.log(`\n⊙ Skipped ${category}\n`);
return 'skipped';
case 'b': // Batch approve aldisplaySummary function · javascript · L184-L191 (8 LOC)shared/outreach-core/approval-system/approve-cli.js
function displaySummary(stats) {
console.log(`\n=== APPROVAL SUMMARY ===\n`);
console.log(`✓ Approved: ${stats.approved}`);
console.log(`✗ Rejected: ${stats.rejected}`);
console.log(`⊙ Skipped: ${stats.skipped}\n`);
console.log(`Next steps:`);
console.log(` Run: node ksd/local-outreach/orchestrator/utils/resume-approval.js <location> <postcode>\n`);
}main function · javascript · L196-L228 (33 LOC)shared/outreach-core/approval-system/approve-cli.js
async function main() {
const queue = loadApprovalQueue();
const pending = Object.entries(queue).filter(([cat, item]) => item.status === "pending");
if (pending.length === 0) {
console.log("\nNo pending approvals found.\n");
process.exit(0);
}
console.log(`\n=== EMAIL APPROVAL SYSTEM ===\n`);
console.log(`Found ${pending.length} pending approval${pending.length === 1 ? '' : 's'}:\n`);
// List all pending approvals
pending.forEach(([category, item], index) => {
console.log(` ${index + 1}. ${category} - ${item.business.name}`);
});
console.log();
const stats = { approved: 0, rejected: 0, skipped: 0 };
for (let i = 0; i < pending.length; i++) {
const [category, item] = pending[i];
const action = await processApprovalItem(category, item, i + 1, pending.length);
if (action === 'approved') stats.approved++;
if (action === 'rejected') stats.rejected++;
if (action === 'skipped') stats.skipped++;
if (action === 'quit') break;
getBusinessType function · javascript · L96-L132 (37 LOC)shared/outreach-core/content-generation/business-type-helper.js
function getBusinessType(category) {
if (!category) {
logger.debug("business-type-helper", "No category provided, using default", {
category,
});
return "local businesses";
}
const normalizedCategory = category.toLowerCase().trim();
// Direct match
if (BUSINESS_TYPE_MAP[normalizedCategory]) {
return BUSINESS_TYPE_MAP[normalizedCategory];
}
// Partial match (e.g., "dental clinic" → "dental practices")
for (const [key, value] of Object.entries(BUSINESS_TYPE_MAP)) {
if (normalizedCategory.includes(key) || key.includes(normalizedCategory)) {
logger.debug("business-type-helper", "Partial match found", {
category: normalizedCategory,
matchedKey: key,
businessType: value,
});
return value;
}
}
// Smart pluralization fallback
const pluralized = smartPluralize(normalizedCategory);
logger.debug("business-type-helper", "No match found, using smart pluralization", {
category: normalizedCategorysmartPluralize function · javascript · L139-L166 (28 LOC)shared/outreach-core/content-generation/business-type-helper.js
function smartPluralize(word) {
if (!word) return "local businesses";
// Already plural
if (word.endsWith("s") || word.endsWith("es")) {
return word;
}
// Special cases
if (word.endsWith("y") && !["ay", "ey", "iy", "oy", "uy"].some((v) => word.endsWith(v))) {
return word.slice(0, -1) + "ies"; // company → companies
}
if (word.endsWith("ch") || word.endsWith("sh") || word.endsWith("x") || word.endsWith("z")) {
return word + "es"; // church → churches
}
if (word.endsWith("f")) {
return word.slice(0, -1) + "ves"; // shelf → shelves
}
if (word.endsWith("fe")) {
return word.slice(0, -2) + "ves"; // knife → knives
}
// Default: add 's'
return word + "s";
}getBusinessTypeWithArticle function · javascript · L173-L191 (19 LOC)shared/outreach-core/content-generation/business-type-helper.js
function getBusinessTypeWithArticle(category) {
const type = getBusinessType(category);
// Get singular form (remove 's' or 'es')
let singular = type;
if (type.endsWith("ies")) {
singular = type.slice(0, -3) + "y"; // companies → company
} else if (type.endsWith("es")) {
singular = type.slice(0, -2); // churches → church
} else if (type.endsWith("s")) {
singular = type.slice(0, -1); // dentists → dentist
}
// Determine article
const vowels = ["a", "e", "i", "o", "u"];
const article = vowels.includes(singular[0].toLowerCase()) ? "an" : "a";
return `${article} ${singular}`;
}getCategoryGroup function · javascript · L215-L229 (15 LOC)shared/outreach-core/content-generation/category-mapper.js
function getCategoryGroup(category) {
if (!category) return CATEGORY_GROUPS.GENERAL;
const lowerCategory = category.toLowerCase();
// Check each group's keywords
for (const [group, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
if (keywords.some(keyword => lowerCategory.includes(keyword))) {
return CATEGORY_GROUPS[group];
}
}
// Default to GENERAL if no match
return CATEGORY_GROUPS.GENERAL;
}getCategoryEmailAngles function · javascript · L236-L238 (3 LOC)shared/outreach-core/content-generation/category-mapper.js
function getCategoryEmailAngles(categoryGroup) {
return EMAIL_ANGLES[categoryGroup] || EMAIL_ANGLES.GENERAL;
}Repobility — the code-quality scanner for AI-generated software · https://repobility.com
getCategoryLinkedInAngles function · javascript · L245-L247 (3 LOC)shared/outreach-core/content-generation/category-mapper.js
function getCategoryLinkedInAngles(categoryGroup) {
return LINKEDIN_ANGLES[categoryGroup] || LINKEDIN_ANGLES.GENERAL;
}generateEmailContent function · javascript · L62-L219 (158 LOC)shared/outreach-core/content-generation/claude-email-generator.js
async function generateEmailContent(params) {
const {
barterOpportunity,
businessName,
ownerFirstName,
category,
location,
website,
reviewCount,
rating,
competitorName,
country,
instagramUrl,
facebookUrl,
socialMedia,
model = "claude-sonnet-4-5-20250929", // Default to Sonnet 4.5 (best balance)
customPrompt // DEPRECATED: kept for backward compatibility, bypasses micro-offer system
} = params;
const apiKey = getCredential("anthropic", "apiKey");
// Humanize company name for natural tone
const { humanized: humanizedBusinessName, original: originalBusinessName } = humanizeCompanyName(businessName);
// Build prompt with humanized company name
const paramsWithHumanizedName = {
...params,
businessName: humanizedBusinessName,
originalBusinessName: originalBusinessName
};
// Build prompt (use custom prompt if provided for backward compatibility, otherwise use micro-offer system)
const prompt = custbuildEmailPrompt function · javascript · L225-L333 (109 LOC)shared/outreach-core/content-generation/claude-email-generator.js
function buildEmailPrompt(params) {
const {
barterOpportunity,
businessName,
ownerFirstName,
category,
location,
reviewCount,
rating,
competitorName,
website,
country // optional country override
} = params;
// 1. Determine category group
const categoryGroup = getCategoryGroup(category);
// 2. Compute observation signals
const signals = computeObservationSignals({
reviewCount,
rating,
website,
instagramUrl: params.instagramUrl,
facebookUrl: params.facebookUrl,
socialMedia: params.socialMedia
});
// 3. Select primary signal (most important)
const primarySignal = selectPrimarySignal(signals);
const signalHook = primarySignal ? getSignalHook(primarySignal) : "growing your business";
// 4. Get category angles
const angles = getCategoryEmailAngles(categoryGroup);
// 5. Select primary angle (use first angle as default)
const primaryAngle = angles[0] || "optimizing your customer acquisition";
/parseEmailContent function · javascript · L338-L381 (44 LOC)shared/outreach-core/content-generation/claude-email-generator.js
function parseEmailContent(content) {
const lines = content.split("\n");
let subject = "";
let bodyLines = [];
let inBody = false;
let foundSubject = false;
for (const line of lines) {
if (line.toLowerCase().startsWith("subject:")) {
subject = line.replace(/^subject:\s*/i, "").trim();
foundSubject = true;
continue; // Skip adding this line to body
} else if (line.toLowerCase().startsWith("body:")) {
inBody = true;
const bodyStart = line.replace(/^body:\s*/i, "").trim();
if (bodyStart) {
bodyLines.push(bodyStart);
}
continue;
} else if (inBody) {
bodyLines.push(line);
} else if (!foundSubject && !line.trim()) {
// Skip empty lines before we've found the subject
continue;
}
}
const body = bodyLines.join("\n").trim();
// Fallback: if no subject/body markers, use first line as subject, rest as body
if (!subject && !body) {
const parts = content.split("\n\n");
subjectgenerateEmailSequence function · javascript · L386-L482 (97 LOC)shared/outreach-core/content-generation/claude-email-generator.js
async function generateEmailSequence(businessData, sequenceConfig = {}) {
const emails = [];
// Email 1: Initial outreach (uses micro-offer system with observation hook)
const email1 = await generateEmailContent({
...businessData,
customPrompt: sequenceConfig.email1Prompt || undefined
});
email1.delayDays = 0;
emails.push(email1);
// Email 2: Follow-up with different angle (3-4 days later)
const email2Prompt = `Write a follow-up email for ${businessData.businessName} (a ${businessData.category || 'business'}).
CONTEXT: This is Email 2 of 4. They didn't respond to the first email about your original observation.
APPROACH: Use a DIFFERENT angle from the first email. Choose one:
- If first email was about social media → talk about reviews or rebooking
- If first email was about reviews → talk about online booking or automation
- If first email was about rebooking → talk about social proof or visibility
Keep it brief, conversational, and reference that you "sengenerateConnectionNote function · javascript · L53-L158 (106 LOC)shared/outreach-core/content-generation/claude-linkedin-generator.js
async function generateConnectionNote(params) {
const {
businessName,
ownerFirstName,
category,
location,
model = "claude-sonnet-4-5-20250929"
} = params;
const apiKey = getCredential("anthropic", "apiKey");
// Get category group for context
const categoryGroup = getCategoryGroup(category);
const prompt = `Write a LinkedIn connection request note for a UK local business owner.
Business: ${businessName}
Owner: ${ownerFirstName}
Category: ${category} (${categoryGroup})
Location: ${location}
Requirements:
- Under 300 characters (LinkedIn limit)
- Reference their business and location as context
- NO sales pitch - just friendly professional context
- Use casual tone: "I love" not "I'm intrigued by"
- Business name ONLY (no location suffix)
- Example: "Hi ${ownerFirstName}, I work with ${category} businesses in ${location} and love what you're doing with ${businessName}. Would be great to connect."
Output just the connection note text (no labels or formgenerateLinkedInMessage function · javascript · L167-L288 (122 LOC)shared/outreach-core/content-generation/claude-linkedin-generator.js
async function generateLinkedInMessage(params) {
const {
businessName,
ownerFirstName,
category,
location,
emailSubject,
emailBody,
model = "claude-sonnet-4-5-20250929"
} = params;
const apiKey = getCredential("anthropic", "apiKey");
// Get category group and LinkedIn angles (different from email)
const categoryGroup = getCategoryGroup(category);
const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);
const primaryAngle = linkedInAngles[0] || "growing your business";
const prompt = `Write a LinkedIn message (post-connection) for a UK local business owner.
Business: ${businessName}
Owner: ${ownerFirstName}
Category: ${category} (${categoryGroup})
Location: ${location}
LinkedIn Angle: ${primaryAngle} (use this as context, different from email)
Email Context (DO NOT REPEAT):
Subject: ${emailSubject}
Body: ${emailBody}
Requirements:
- Start with: "Hi ${ownerFirstName}, I don't know if you saw my email so I thought I'd try here."
- stripLegalSuffixes function · javascript · L134-L153 (20 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function stripLegalSuffixes(name) {
let cleaned = name.trim();
// Build regex pattern for legal suffixes
// Match at end of string or followed by comma/period
const pattern = new RegExp(
`\\s+(${LEGAL_SUFFIXES.join('|')})(?:[\\s,.]*)$`,
'gi'
);
// Keep stripping until no more matches (handles "Co., Ltd." etc.)
let previousName;
do {
previousName = cleaned;
cleaned = cleaned.replace(pattern, '');
cleaned = cleaned.trim().replace(/[,.]$/, '').trim();
} while (cleaned !== previousName && cleaned.length > 0);
return cleaned;
}Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
stripGenericDescriptors function · javascript · L162-L190 (29 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function stripGenericDescriptors(name, original) {
let cleaned = name.trim();
const words = cleaned.split(/\s+/);
// Don't strip if name is already short
if (words.length <= 1) {
return cleaned;
}
// Check last word
const lastWord = words[words.length - 1];
// If last word is brand-core descriptor, keep it
if (BRAND_CORE_DESCRIPTORS.some(desc => desc.toLowerCase() === lastWord.toLowerCase())) {
return cleaned;
}
// If last word is generic descriptor, strip it
if (GENERIC_DESCRIPTORS.some(desc => desc.toLowerCase() === lastWord.toLowerCase())) {
const withoutLast = words.slice(0, -1).join(' ');
// Strip if result is not empty and is a valid brand name
if (withoutLast.length > 0) {
return withoutLast;
}
}
return cleaned;
}stripLocation function · javascript · L197-L209 (13 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function stripLocation(name) {
let cleaned = name;
// Apply all location patterns
for (const pattern of UK_LOCATION_PATTERNS) {
cleaned = cleaned.replace(pattern, '');
}
// Clean up extra spaces and trailing punctuation
cleaned = cleaned.trim().replace(/\s+/g, ' ').replace(/[,.-]+$/, '').trim();
return cleaned;
}humanizeCompanyName function · javascript · L216-L259 (44 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function humanizeCompanyName(companyName) {
if (!companyName || typeof companyName !== 'string') {
return {
humanized: companyName || '',
original: companyName || ''
};
}
const original = companyName.trim();
let humanized = original;
// Step 1: Strip location names
humanized = stripLocation(humanized);
// Step 2: Strip legal entity suffixes
humanized = stripLegalSuffixes(humanized);
// Step 3: Strip generic descriptors (but keep brand-core words)
humanized = stripGenericDescriptors(humanized, original);
// Step 4: Final cleanup
humanized = humanized.trim();
// Validation: Don't return empty string
if (humanized.length === 0) {
logger.warn('company-name-humanizer', 'Humanization resulted in empty string', {
original
});
return {
humanized: original,
original
};
}
logger.debug('company-name-humanizer', 'Humanized company name', {
original,
humanized
});
return {
humanized,
orgetShortNameForTeam function · javascript · L274-L362 (89 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function getShortNameForTeam(companyName) {
if (!companyName || typeof companyName !== 'string') {
return 'Team';
}
// First humanize to remove location, legal suffixes, etc.
const { humanized } = humanizeCompanyName(companyName);
// Split into words
const words = humanized.split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) {
return 'Team';
}
// Skip leading articles (The, A, An)
const articles = ['the', 'a', 'an'];
let startIndex = 0;
if (words.length > 1 && articles.includes(words[0].toLowerCase())) {
startIndex = 1;
}
// Business type keywords that should be kept as second word
const businessTypes = [
'cafe', 'coffee', 'restaurant', 'bar', 'pub', 'bistro', 'grill', 'kitchen',
'gym', 'fitness', 'yoga', 'pilates', 'wellness', 'health', 'spa',
'dental', 'dentist', 'clinic', 'surgery', 'medical', 'pharmacy',
'salon', 'barber', 'hair', 'beauty', 'nails', 'spa',
'bakery', 'patisserie', 'deli', 'butcher', 'grocrunTests function · javascript · L368-L409 (42 LOC)shared/outreach-core/content-generation/company-name-humanizer.js
function runTests() {
const testCases = [
{ input: 'KissDental Bramhall', expected: 'KissDental' },
{ input: 'Kobestarr Digital Limited', expected: 'Kobestarr Digital' },
{ input: 'Apple Inc.', expected: 'Apple' },
{ input: 'Shell plc', expected: 'Shell' },
{ input: 'Samsung Electronics Co., Ltd.', expected: 'Samsung' },
{ input: 'Starbucks Coffee Company', expected: 'Starbucks Coffee' },
{ input: 'Microsoft Corporation', expected: 'Microsoft' },
{ input: 'Amazon.com, Inc.', expected: 'Amazon.com' },
{ input: 'The Hair Salon London', expected: 'The Hair Salon' },
{ input: 'Joe\'s Pizza Manchester', expected: 'Joe\'s Pizza' },
{ input: 'Acme Ltd', expected: 'Acme' },
{ input: 'XYZ Holdings plc', expected: 'XYZ' },
{ input: 'ABC Solutions Limited', expected: 'ABC Solutions' }
];
const results = testCases.map(test => {
const result = humanizeCompanyName(test.input);
const passed = result.humanized === test.expected;
rdetectCountryFromLocation function · javascript · L59-L147 (89 LOC)shared/outreach-core/content-generation/currency-localization.js
function detectCountryFromLocation(location) {
if (!location || typeof location !== 'string') return "UK"; // Default to UK
const locationLower = location.toLowerCase();
// UK: Check for postcode patterns (e.g. SK7, M1, SW1A, etc.)
// UK postcodes: 1-2 letters, 1-2 digits, optional space, digit, 2 letters
if (/\b[A-Z]{1,2}\d{1,2}\s?\d?[A-Z]{0,2}\b/i.test(location)) {
return "UK";
}
// UK: Common UK place indicators
if (locationLower.includes("uk") ||
locationLower.includes("united kingdom") ||
locationLower.includes("england") ||
locationLower.includes("scotland") ||
locationLower.includes("wales") ||
locationLower.includes("northern ireland")) {
return "UK";
}
// US: Check for state abbreviations (2 capital letters)
// Look for patterns like "NY", "CA", "TX" etc in context
const usStatePattern = /\b(AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SDgetCurrencyForLocation function · javascript · L157-L163 (7 LOC)shared/outreach-core/content-generation/currency-localization.js
function getCurrencyForLocation(location, country = null) {
// Use provided country or detect from location
const detectedCountry = country || detectCountryFromLocation(location);
// Return currency object, defaulting to UK if country not found
return CURRENCY_MAP[detectedCountry] || CURRENCY_MAP.UK;
}formatPrice function · javascript · L173-L175 (3 LOC)shared/outreach-core/content-generation/currency-localization.js
function formatPrice(amount, currency) {
return `${currency.symbol}${amount}`;
}Citation: Repobility (2026). State of AI-Generated Code. https://repobility.com/research/
getAvailableCountries function · javascript · L183-L185 (3 LOC)shared/outreach-core/content-generation/currency-localization.js
function getAvailableCountries() {
return Object.keys(CURRENCY_MAP);
}isNearby function · javascript · L74-L79 (6 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function isNearby(postcode) {
if (!postcode) return false;
const postcodePrefix = postcode.substring(0, 3).toUpperCase().trim();
return NEARBY_POSTCODES.includes(postcodePrefix);
}getLocalIntro function · javascript · L86-L92 (7 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getLocalIntro(postcode) {
if (isNearby(postcode)) {
return "I'm Kobi, a digital marketing consultant based in Poynton, so pretty close to you!";
} else {
return "I'm Kobi, a digital marketing consultant working with local businesses across the UK.";
}
}getObservationSignal function · javascript · L101-L126 (26 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getObservationSignal(business) {
const signals = computeObservationSignals(business);
const primarySignal = selectPrimarySignal(signals);
// Map signals to natural observations
const observations = {
tradesLeadGen: "know how much sites like Checkatrade and MyBuilder can eat into your margins — most tradespeople I speak to are spending £1,000-2,000 a year just to compete for leads in their own postcode",
lowReviews: "saw you're building up your online reputation",
noWebsite: "noticed you don't have a website yet",
poorWebsite: "thought your website could use a refresh",
noSocialMedia: "saw you could use help with social media",
lowRating: "noticed you could improve your online presence",
highReviews: "saw you've built up a solid reputation online"
};
const observationText = observations[primarySignal] || "thought I'd reach out";
logger.debug('email-merge-variables', 'Generated observation signal', {
business: business.name,
pgetMeetingOption function · javascript · L133-L139 (7 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getMeetingOption(postcode) {
if (isNearby(postcode)) {
return "meet in person if that's easier";
} else {
return "have a chat";
}
}getMicroOfferPrice function · javascript · L146-L168 (23 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getMicroOfferPrice(business) {
const tier = business.assignedOfferTier || 'tier5';
const multiplier = TIER_MULTIPLIERS[tier];
// Get currency and base price for location
const currency = getCurrencyForLocation(business.location || 'UK');
const basePrice = currency.microOffer; // 97 for UK, 127 for US, etc.
// Calculate tiered price
const finalPrice = Math.round(basePrice * multiplier);
const formattedPrice = `${currency.symbol}${finalPrice}`;
logger.debug('email-merge-variables', 'Calculated tiered pricing', {
business: business.name,
tier,
multiplier,
basePrice,
finalPrice: formattedPrice,
country: currency.country
});
return formattedPrice;
}getMultiOwnerNote function · javascript · L176-L209 (34 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getMultiOwnerNote(business) {
// Check if business has multiple owners
if (!business.owners || business.owners.length <= 1) {
return ""; // Single owner or no owner data
}
// Get names of other owners (excluding first one we're addressing)
const otherOwnerNames = business.owners
.slice(1) // Skip first owner (they're in {{firstName}})
.map(o => o.firstName)
.filter(name => name && name.trim()) // Remove empty/null names
.slice(0, 5); // Cap at 5 people max
if (otherOwnerNames.length === 0) {
return ""; // No valid other names found
}
// Format with commas and Oxford comma
let otherOwners;
if (otherOwnerNames.length === 1) {
otherOwners = otherOwnerNames[0];
} else if (otherOwnerNames.length === 2) {
otherOwners = otherOwnerNames.join(' and ');
} else {
// 3+ names: "Sarah, John, and Mike" (Oxford comma)
const lastIndex = otherOwnerNames.length - 1;
otherOwners = otherOwnerNames.slice(0, lastIndex).join(', 'getNoNameNote function · javascript · L217-L225 (9 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getNoNameNote(business) {
// Only show note if we used fallback name (no owner names found)
if (!business.usedFallbackName) {
return ""; // Real owner name found
}
// Note: trailing space ensures natural flow into {{localIntro}}
return "I couldn't find a direct contact name for your business! ";
}About: code-quality intelligence by Repobility · https://repobility.com
getValidFirstName function · javascript · L239-L271 (33 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getValidFirstName(business) {
// Option 1: Use existing firstName if valid
if (business.ownerFirstName && isValidPersonName(business.ownerFirstName)) {
return business.ownerFirstName;
}
// Option 2: Extract from email (CRITICAL FIX for "[email protected]" → "Derek")
const email = business.ownerEmail || business.email; // Handle both field names
if (email && email.includes('@')) {
const extractedName = extractNameFromEmail(email);
if (extractedName) {
const firstName = extractedName.split(' ')[0];
logger.info('email-merge-variables', 'Extracted firstName from email', {
business: business.name || business.businessName,
email: email,
extractedFirstName: firstName,
originalFirstName: business.ownerFirstName
});
return firstName;
}
}
// Option 3: Final fallback
logger.warn('email-merge-variables', 'Using fallback firstName "there"', {
business: business.name || business.bugetAllMergeVariables function · javascript · L278-L311 (34 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getAllMergeVariables(business) {
const postcode = business.postcode || '';
const mergeVariables = {
// Core business data
firstName: getValidFirstName(business), // CHANGED: Now uses intelligent fallback
lastName: business.ownerLastName || '',
companyName: business.businessName || business.name,
location: business.location || '',
businessType: getBusinessType(business.category),
// Dynamic variables
localIntro: getLocalIntro(postcode),
observationSignal: getObservationSignal(business),
meetingOption: getMeetingOption(postcode),
microOfferPrice: getMicroOfferPrice(business),
multiOwnerNote: getMultiOwnerNote(business),
noNameNote: getNoNameNote(business),
// Additional context
isNearby: isNearby(postcode),
tier: business.assignedOfferTier || 'tier5',
postcode: postcode
};
logger.info('email-merge-variables', 'Generated merge variables', {
business: business.name,
isNearby: mergeVariables.isaddNearbyPostcode function · javascript · L318-L324 (7 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function addNearbyPostcode(postcode) {
const prefix = postcode.substring(0, 3).toUpperCase().trim();
if (!NEARBY_POSTCODES.includes(prefix)) {
NEARBY_POSTCODES.push(prefix);
logger.info('email-merge-variables', 'Added nearby postcode', { postcode: prefix });
}
}getNearbyPostcodes function · javascript · L330-L332 (3 LOC)shared/outreach-core/content-generation/email-merge-variables.js
function getNearbyPostcodes() {
return [...NEARBY_POSTCODES];
}generateEmailContent function · javascript · L56-L202 (147 LOC)shared/outreach-core/content-generation/gpt-email-generator.js
async function generateEmailContent(params) {
const {
barterOpportunity,
businessName,
ownerFirstName,
category,
location,
website,
reviewCount,
rating,
competitorName,
country,
instagramUrl,
facebookUrl,
socialMedia,
customPrompt // DEPRECATED: kept for backward compatibility, bypasses micro-offer system
} = params;
const apiKey = getCredential("openai", "apiKey");
// Humanize company name for natural tone
const { humanized: humanizedBusinessName, original: originalBusinessName } = humanizeCompanyName(businessName);
// Build prompt with humanized company name
const paramsWithHumanizedName = {
...params,
businessName: humanizedBusinessName,
originalBusinessName: originalBusinessName
};
// Build prompt (use custom prompt if provided for backward compatibility, otherwise use micro-offer system)
const prompt = customPrompt || buildEmailPrompt(paramsWithHumanizedName);
return new Promise((resolbuildEmailPrompt function · javascript · L208-L309 (102 LOC)shared/outreach-core/content-generation/gpt-email-generator.js
function buildEmailPrompt(params) {
const {
barterOpportunity,
businessName,
ownerFirstName,
category,
location,
reviewCount,
rating,
competitorName,
website,
country // optional country override
} = params;
// 1. Determine category group
const categoryGroup = getCategoryGroup(category);
// 2. Compute observation signals
const signals = computeObservationSignals({
reviewCount,
rating,
website,
instagramUrl: params.instagramUrl,
facebookUrl: params.facebookUrl,
socialMedia: params.socialMedia
});
// 3. Select primary signal (most important)
const primarySignal = selectPrimarySignal(signals);
const signalHook = primarySignal ? getSignalHook(primarySignal) : "growing your business";
// 4. Get category angles
const angles = getCategoryEmailAngles(categoryGroup);
// 5. Select primary angle (use first angle as default)
// NOTE: Future enhancement - match angle to signal intelligently
// This reparseEmailContent function · javascript · L314-L342 (29 LOC)shared/outreach-core/content-generation/gpt-email-generator.js
function parseEmailContent(content) {
const lines = content.split("\n");
let subject = "";
let body = "";
let inBody = false;
for (const line of lines) {
if (line.toLowerCase().startsWith("subject:")) {
subject = line.replace(/^subject:\s*/i, "").trim();
} else if (line.toLowerCase().startsWith("body:")) {
inBody = true;
body = line.replace(/^body:\s*/i, "").trim();
} else if (inBody) {
body += "\n" + line.trim();
}
}
// Fallback: if no subject/body markers, use first line as subject, rest as body
if (!subject && !body) {
const parts = content.split("\n\n");
subject = parts[0] || "";
body = parts.slice(1).join("\n\n") || content;
}
return {
subject: subject || "Quick question about your business",
body: body || content
};
}generateEmailSequence function · javascript · L347-L375 (29 LOC)shared/outreach-core/content-generation/gpt-email-generator.js
async function generateEmailSequence(businessData, sequenceConfig = {}) {
const emails = [];
// Email 1: Initial outreach
emails.push(await generateEmailContent({
...businessData,
customPrompt: sequenceConfig.email1Prompt || undefined
}));
// Email 2: Follow-up (if no response)
emails.push(await generateEmailContent({
...businessData,
customPrompt: sequenceConfig.email2Prompt || `Write a follow-up email (Email 2 of 4) for ${businessData.businessName}. This is a gentle follow-up with a helpful tip or resource. Keep it brief and low-pressure.`
}));
// Email 3: Case study/value prop
emails.push(await generateEmailContent({
...businessData,
customPrompt: sequenceConfig.email3Prompt || `Write a follow-up email (Email 3 of 4) for ${businessData.businessName}. Share a brief case study or specific value proposition. Still low-pressure.`
}));
// Email 4: Final touch
emails.push(await generateEmailContent({
...businessData,
custRepobility — the code-quality scanner for AI-generated software · https://repobility.com
generateConnectionNote function · javascript · L50-L98 (49 LOC)shared/outreach-core/content-generation/gpt-linkedin-generator.js
async function generateConnectionNote(params) {
const {
ownerFirstName,
businessName,
category,
location,
linkedInTitle,
emailAngleUsed, // Optional: angle used in email to avoid repetition
customPrompt // DEPRECATED
} = params;
const apiKey = getCredential("openai", "apiKey");
if (customPrompt) {
// Backward compatibility: use custom prompt if provided
return callGPT4(customPrompt, apiKey);
}
// 1. Determine category group
const categoryGroup = getCategoryGroup(category);
// 2. Get LinkedIn angles (different from email angles)
const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);
// 3. Select angle (use first LinkedIn angle, which differs from email angles)
const selectedAngle = linkedInAngles[0] || "building a stronger local presence";
const prompt = `Write a LinkedIn connection request note for a UK local business owner.
Name: ${ownerFirstName}
Business: ${businessName}
Category: ${category} (${categoryGrogenerateLinkedInMessage function · javascript · L109-L160 (52 LOC)shared/outreach-core/content-generation/gpt-linkedin-generator.js
async function generateLinkedInMessage(params) {
const {
ownerFirstName,
businessName,
category,
location,
emailSubject,
emailPrimaryHook, // What observation/hook was used in email
customPrompt // DEPRECATED
} = params;
const apiKey = getCredential("openai", "apiKey");
if (customPrompt) {
// Backward compatibility: use custom prompt if provided
return callGPT4(customPrompt, apiKey);
}
// 1. Determine category group
const categoryGroup = getCategoryGroup(category);
// 2. Get LinkedIn angles (different from email angles)
const linkedInAngles = getCategoryLinkedInAngles(categoryGroup);
// 3. Select angle that's different from email hook
// Use second LinkedIn angle if available, otherwise first
const selectedAngle = linkedInAngles[1] || linkedInAngles[0] || "growing your customer base";
const prompt = `Write a LinkedIn first message for ${ownerFirstName} at ${businessName} in ${location}.
Context:
- They just accepted ycallGPT4 function · javascript · L165-L229 (65 LOC)shared/outreach-core/content-generation/gpt-linkedin-generator.js
function callGPT4(prompt, apiKey) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({
model: "gpt-4",
messages: [
{
role: "system",
content: LINKEDIN_SYSTEM_PROMPT
},
{
role: "user",
content: prompt
}
],
temperature: 0.75, // Slightly higher for spontaneity
max_tokens: 800 // Increased for longer DM sequences
});
const options = {
hostname: OPENAI_BASE_URL,
path: "/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"Content-Length": Buffer.byteLength(postData)
}
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
const result = JSON.parse(data);