A domain can receive mail while your contact's guessed inbox doesn't exist.
You're about to connect email to the CRM's verified-email field. The mapping looks harmless. The uncomfortable part is what that checkbox will tell everyone downstream.
The Actor constructs an address from a name and company domain, then checks whether that domain can receive mail. A domain check does not verify a person's inbox. Your ingestion needs to preserve that distinction.
Start with a finished dataset
Separate execution status from contact evidence before inspecting individual rows. A successful run gets you to the diagnosis stage; it doesn't qualify the contacts for outreach.
Example: this input searches for marketing leaders and waits for the run to finish.
import { ApifyClient } from 'apify-client';
import { z } from 'zod';
const run_input = {
jobTitles: ['VP of Marketing'],
maxLeads: 25,
};
const client = new ApifyClient({
token: process.env.APIFY_TOKEN,
});
const run = await client
.actor('nimbryxo-studio/b2b-lead-discovery-by-job-title-location')
.call(run_input);
if (run.status !== 'SUCCEEDED') {
throw new Error(`Inspect run ${run.id}: ${run.status}`);
}
const { items } = await client
.dataset(run.defaultDatasetId)
.listItems();
This guard is an ingestion policy: stop automatic routing when execution hasn't succeeded. It doesn't attempt to diagnose runtime failures from contact fields.
Once execution passes that gate, ask which stage each person reached.
Which email symptom are you looking at?
Read email alongside company_domain and last_name. A blank address alone can't distinguish the failure paths.
| Symptom | Cause supported by the fields | Easy misdiagnosis | Fix and its cost |
|---|---|---|---|
Empty email, empty company_domain
|
Company-domain discovery couldn't resolve a domain | The person has no work inbox | Research the company website; additional research work |
Empty email, populated company_domain
|
Domain discovery succeeded, but the address was withheld; the check result isn't exposed | The person's mailbox was rejected | Investigate mail capability independently; another check, with uncertainty remaining |
Populated email
|
A pattern guess passed the domain check | The address belongs to this person | Send it to separate address verification; extra processing and possible unresolved results |
Populated email, empty last_name
|
The single-name fallback applies | The short address is malformed | Flag the naming assumption for review; additional review work |
Validate the source shape without converting empty strings into missing properties.
const Lead = z.object({
full_name: z.string(),
first_name: z.string(),
last_name: z.string(),
title: z.string(),
company_name: z.string(),
company_domain: z.string(),
location: z.string(),
linkedin_url: z.string(),
email: z.string(),
scraped_at: z.string(),
});
const people = z.array(Lead).parse(items);
Keep email as a string here. An email-format validator would reject the intentional blanks before you could diagnose them.
This schema checks the record contract. It doesn't authenticate a person, company, or address.
A missing domain sends you back to company research
Address construction depends on finding the company's domain. When that dependency is unresolved, retrying a downstream mailbox check has nothing useful to inspect.
Select that branch while retaining the entire person record.
const missingDomain = people.filter(
person =>
person.email === '' &&
person.company_domain === '',
);
Use linkedin_url to carry the record through research. The missing address shouldn't erase the person you discovered.
Start with the company association, then resolve the website. If company_name is also blank, its absence can reflect search-result text that didn't identify the company clearly.
Don't fill the gap from the person's name alone. A confident company guess can produce a plausible address for the wrong employer. That's a particularly tidy way to make the CRM worse.
The repair belongs upstream: establish the employer and website before attempting contact enrichment.
A present domain narrows the failure, not the proof
A populated domain moves your investigation past company-domain discovery. It still doesn't give you the check's underlying evidence.
Select records whose domain is available but whose address is withheld.
const domainWithoutEmail = people.filter(
person =>
person.email === '' &&
person.company_domain !== '',
);
This combination is consistent with a failed mail-capability check. The output contains no per-record check result that establishes the precise reason.
Keep your downstream diagnosis tentative. Labeling the person's mailbox invalid would claim evidence these fields don't provide.
If you investigate further, attach the new evidence to the record. Don't infer mail capability from the company website loading successfully: serving a website and receiving mail are different capabilities.
A blind rerun is a weak fix. These fields don't establish a transient failure, so repeating the same operation gives you no guaranteed explanation.
A generated address belongs in the verification queue
The construction uses first.last@domain, with first@domain when no last name is known. That fallback describes how the guess was formed; it doesn't make the address invalid.
Route populated addresses for verification, and flag the naming assumption separately.
const guesses = people
.filter(person => person.email !== '')
.map(person => ({
...person,
emailEvidence: 'pattern_guess' as const,
namingPattern:
person.last_name === ''
? 'single_name' as const
: 'first_last' as const,
nextStage: 'address_verification' as const,
}));
These added labels are your pipeline metadata. They aren't source fields or an independent verification result.
The fallback flag helps a reviewer understand the assumption without reverse-engineering the address. Avoid rebuilding it from the name fields just to check whether it “looks right.” Matching the construction pattern would only confirm that the guess resembles a guess.
Keep the proposed address separate from the CRM's confirmed address field. Promote it only when your separate verification process supplies the evidence your team requires.
That process may leave a contact unresolved. Preserve that outcome too. A queue isn't more useful because every row eventually turns green.
Also avoid using the guessed address as the person key. Naming conventions, employers, and addresses can change. Carry the existing profile reference into your ingestion matching rather than replacing it with an enrichment result.
Count people before filtering for contact details
Discovery and contact readiness answer different questions.
Use the diagnostic branches to route work:
Discovered person
|-- email empty
| |-- domain empty: company research
| `-- domain present: investigate mail capability
`-- email populated: separate address verification
`-- last name empty: flag naming assumption
Calculate stage counts before any CRM filter removes records.
const metrics = {
discoveredPeople: people.length,
discoveredDomains: people.filter(
person => person.company_domain !== '',
).length,
generatedEmailGuesses: guesses.length,
unresolvedCompanyDomains: missingDomain.length,
domainsWithoutEmail: domainWithoutEmail.length,
singleNameGuesses: guesses.filter(
person => person.namingPattern === 'single_name',
).length,
};
const queues = {
addressVerification: guesses,
companyResearch: missingDomain,
mailCapabilityResearch: domainWithoutEmail,
};
The stage totals overlap. A person with a generated address also contributes to discovery counts, so don't sum them into a contact total.
Likewise, compare equivalent stages across runs. A change in generated-address coverage is a reason to inspect the diagnostic branches, not proof that your search found worse people.
The worked example is B2B Lead Discovery by Job Title, Location & Industry. The reusable pattern is to retain what each stage established without borrowing certainty from the next stage.
The catch is operational. Preserving incomplete contacts creates research queues someone must own. Verification also adds work before an address becomes usable.
Dropping every blank address avoids that queue, but changes the population you retain. Your CRM then favors records whose company domains were discoverable and passed the domain check.
That might suit an immediate contact workflow. It's a poor silent default for account research or market mapping, where the person remains relevant without an address.
Before the CRM mapping goes live
Keep this checklist beside the ingestion job:
- Gate automatic routing on run status.
- Validate records while preserving empty strings.
- Retain
linkedin_urlthrough every queue. - Separate company research from mail-capability investigation.
- Store generated addresses with their evidence level.
- Require independent evidence before setting the verified-email field.
- Measure discovery before applying contact-readiness filters.
Your CRM's verified checkbox should describe evidence you've collected, not confidence the field name gave you.
Top comments (0)