outreach-send-coreThis file exists to make the package implementation path explicit for coding agents.
Integrate outreach-send-core by:
GoogleFirstSeedProvider for production prospect discoveryGooglePlacesSeedProvider as legacy/demo-only multi-source discovery unless you are intentionally debugging or extending that older pathDo not rewrite the engine logic unless you are intentionally modifying package behavior. The intended extension points are:
OutreachRepositoryOutboundMailerEmailVerifierlinkBuildertemplateBuilderresolveReplyToresolveGlobalDailyCapbuildFallbackSubjectProspectSeedProviderOutreachRepository.createOutreachEngine(...).GoogleFirstSeedProvider(...) for production discovery.provider.search(criteria) from your discovery job or admin action.result.seeds into the send pool.result.reviewCandidates in review UX, but do not auto-send or auto-approve them.sendApprovedBatch(campaignId) from a job, queue worker, or admin action.You must implement all of these methods exactly:
getCampaign(campaignId: string): Promise<OutreachCampaign>countSentSince(campaignId: string, since: Date): Promise<number>listApprovedSendCandidates(campaignId: string, limit: number): Promise<OutreachSendCandidate[]>getSuppressedEmailSet(normalizedEmails: string[]): Promise<Set<string>>suppressEmail(input: OutreachSuppressionInput): Promise<void>recordSuppressedSend(input: RecordSuppressedSendInput): Promise<void>recordFailedSend(input: RecordFailedSendInput): Promise<void>recordSentSend(input: RecordSentSendInput): Promise<void>OutreachCampaign
{
id: string
name?: string | null
dailySendCap: number
}
OutreachSendCandidate
{
prospect: {
id: string
campaignId: string
companyName: string
businessType?: string | null
businessSummary?: string | null
}
contact?: {
id: string
email: string
normalizedEmail: string
name?: string | null
} | null
preview?: {
id: string
publicUrl?: string | null
} | null
}
contact.normalizedEmail must already be lowercase and trimmed.countSentSince(...) must count successful sends, not failed or suppressed attempts.countSentSince(...) must be scoped to the requested campaign.listApprovedSendCandidates(...) must not return more than limit items.getSuppressedEmailSet(...) must return only the subset of input emails that are suppressed.preview.publicUrl must be a real public URL if the candidate is intended to send.recordSuppressedSend(...), recordFailedSend(...), or recordSentSend(...).When an agent calls sendApprovedBatch(campaignId), the package does this:
getCampaign(campaignId).countSentSince(campaignId, startOfToday)
resolveGlobalDailyCap(campaign)campaignDailyCap = floor(max(campaign.dailySendCap, 0))
globalDailyCap = floor(max(configuredOrOverrideCap, 0))
dailyCap = min(campaignDailyCap, globalDailyCap)
remainingDailyCap = max(dailyCap - sentTodayCount, 0)remainingDailyCap === 0, return immediately with all send counts at 0.listApprovedSendCandidates(campaignId, remainingDailyCap).approvedCount = rawCandidates.length.contact is missing, increment skippedMissingContactCount
if preview.publicUrl is missing, increment skippedMissingPreviewCount
otherwise keep the candidate for send processinggetSuppressedEmailSet(...) using the kept candidates’ normalizedEmail values.EmailVerifier exists and isEnabled() is true, verify all non-suppressed emails in one batch.attemptedCount.recordSuppressedSend(...)recordFailedSend(...)sendable: false:
optionally call suppressEmail(...)
call recordSuppressedSend(...)mailer.send(...)
call recordSentSend(...)mailer.send(...) throws:
call recordFailedSend(...)SendBatchResult.approvedCount: raw candidates returned by the repository before filtering missing contact/preview.attemptedCount: candidates with both a contact and a preview URL.sentCount: successful mailer sends recorded by recordSentSend(...).failedCount: failed attempts recorded by recordFailedSend(...).suppressedCount: existing suppressions plus verification-blocked suppressions recorded by recordSuppressedSend(...).skippedMissingContactCount: raw candidates dropped because contact was missing.skippedMissingPreviewCount: raw candidates dropped because preview.publicUrl was missing.firstError: first failure string encountered during batch execution.getCampaignMust return the campaign record used for caps and email context.
Minimum requirement:
iddailySendCapcountSentSinceUse your send log table or equivalent source of truth.
Correct behavior:
campaignIdsentAt >= sincelistApprovedSendCandidatesReturn prospects approved for outreach in the order you want them processed.
Recommended behavior:
limitIt is acceptable to return candidates with missing contact or preview, but the engine will skip them and track that in the result counters.
getSuppressedEmailSetThis is a membership query, not a full export. Given a list of normalized emails, return a set containing only the ones that are suppressed.
suppressEmailPersist a durable suppression record.
Recommended fields to store:
emailnormalizedEmaildomainreasonsourcecampaignIdprospectIdcontactIdmetadatarecordSuppressedSendPersist an outreach attempt that was intentionally not sent because the recipient was suppressed or verification blocked the send.
recordFailedSendPersist an outreach attempt that should have sent but failed because verification was unavailable, the verifier returned no result, or the mailer threw an error.
recordSentSendPersist a successful outbound send.
Recommended fields to store:
sentAtimport type {
OutreachCampaign,
OutreachRepository,
OutreachSendCandidate,
OutreachSuppressionInput,
RecordFailedSendInput,
RecordSentSendInput,
RecordSuppressedSendInput,
} from 'outreach-send-core'
export class AppOutreachRepository implements OutreachRepository {
async getCampaign(campaignId: string): Promise<OutreachCampaign> {
// Query campaign table and return dailySendCap.
}
async countSentSince(campaignId: string, since: Date): Promise<number> {
// Count successful sends for this campaign where sentAt >= since.
}
async listApprovedSendCandidates(
campaignId: string,
limit: number,
): Promise<OutreachSendCandidate[]> {
// Return approved prospects plus contact and preview data.
}
async getSuppressedEmailSet(normalizedEmails: string[]): Promise<Set<string>> {
// Query suppressions WHERE normalized_email IN (...)
}
async suppressEmail(input: OutreachSuppressionInput): Promise<void> {
// Insert durable suppression record.
}
async recordSuppressedSend(input: RecordSuppressedSendInput): Promise<void> {
// Insert send log row with status = 'suppressed'.
}
async recordFailedSend(input: RecordFailedSendInput): Promise<void> {
// Insert send log row with status = 'failed'.
}
async recordSentSend(input: RecordSentSendInput): Promise<void> {
// Insert send log row with status = 'sent'.
}
}
import {
BrevoOutboundMailer,
BouncerEmailVerifier,
createOutreachEngine,
evaluateBouncerEmailVerification,
} from 'outreach-send-core'
const engine = createOutreachEngine({
repository: new AppOutreachRepository(),
mailer: new BrevoOutboundMailer({
apiKey: process.env.BREVO_API_KEY,
fromEmail: process.env.EMAIL_FROM,
fromName: process.env.EMAIL_FROM_NAME,
}),
emailVerifier: new BouncerEmailVerifier({
apiKey: process.env.BOUNCER_API_KEY,
}),
evaluateEmailVerification: evaluateBouncerEmailVerification,
resolveGlobalDailyCap: async () => 50,
resolveReplyTo: async () => 'hello@example.com',
linkBuilder: async ({ campaign, contact, preview }) => ({
previewUrl: preview.publicUrl,
unsubscribeUrl: `https://example.com/unsubscribe?email=${encodeURIComponent(contact.email)}`,
registerUrl: `https://example.com/register?campaign=${campaign.id}`,
}),
templateBuilder: async ({ prospect, links }) => ({
subject: `Preview for ${prospect.companyName}`,
htmlContent: `<a href="${links.previewUrl}">Open preview</a>`,
textContent: `Open preview: ${links.previewUrl}`,
tags: ['outreach'],
}),
})
await engine.sendApprovedBatch('campaign_123')
countSentSince(...).normalizedEmail values.recordSentSend(...) before the mailer succeeds.approvedCount === attemptedCount.GooglePlacesSeedProvider as a replacement for the send repository. Prospect search is a separate feature.reviewCandidates into the automatic send pool.GoogleFirstSeedProvider is being used.Use GoogleFirstSeedProvider for production discovery.
import {
GoogleFirstSeedProvider,
type ProspectSeedProvider,
type SearchCriteria,
} from 'outreach-send-core'
const provider: ProspectSeedProvider<string> = new GoogleFirstSeedProvider({
apiKey: process.env.GOOGLE_PLACES_API_KEY,
publicServiceUserAgent: process.env.PUBLIC_SERVICE_USER_AGENT,
publicServiceReferer: process.env.PUBLIC_SERVICE_REFERER,
maxExpandedMunicipalities: 12,
searchBudgetMs: 120000,
crawlPageLimit: 5,
maxEnrichmentCandidates: 100,
})
const result = await provider.search({
country: 'IT',
language: 'it',
cities: ['Milano'],
provinces: [],
businessTypes: ['clothing_store'],
keywords: [],
limit: 100,
})
const acceptedSeeds = result.seeds
const reviewQueue = result.reviewCandidates ?? []
GoogleFirstSeedProvider.search(criteria) returns:
seeds
accepted-only prospects that are safe to import into the automatic send poolreviewCandidates
borderline prospects that must stay out of automation until a human reviews themdiagnostics
counts and notes that explain what happened during discoveryseeds are accepted-only.reviewCandidates are not accepted.reviewCandidates must never be auto-imported into the approved send pool.result.seeds
accepted businesses that passed identity, type, website, and contact gatesresult.reviewCandidates
businesses that look plausible but have weak evidence such as low-quality contact datadiagnostics.acceptedCount
number of accepted prospects in result.seedsdiagnostics.reviewCount
number of review-queue prospectsdiagnostics.rejectedCount
number of rejected prospectsdiagnostics.rejectionCountsByStage
rejection reasons grouped by gate, such as type_gate.type_mismatchdiagnostics.reviewCounts
review reasons such as low_quality_role or off_domain_emailAmplifon must be rejected for clothing_store discovery.reviewCandidates, not seeds.The bundled GooglePlacesSeedProvider remains available as a province-aware multi-source finder while keeping the same search(criteria) entrypoint.
Important behavior:
seeds are already filtered to accepted outreach-worthy prospectsprimaryEmail and qualityScore may be present on each returned seedrawCandidateCount, acceptedCandidateCount, rejectionCounts, and sourceContributionCountsUse this class only if you are intentionally working on the older multi-source finder. Do not make it the default production discovery provider for new integrations.
An implementation is correct if all of these are true:
contact increments skippedMissingContactCount.preview.publicUrl increments skippedMissingPreviewCount.recordSuppressedSend(...) and no mailer call.recordFailedSend(...) for unsuppressed candidates and no mailer call.suppressEmail(...).recordSentSend(...).recordFailedSend(...).result.seeds.reviewCandidates.If this file and code ever disagree, the code in src/engine.ts and src/types.ts is the source of truth.