# Outbound Mail Sender Identity Provisioning Gate for Egress IPs (`kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips`) Actor

SPF result for your egress IP, before the first mail. Give it the From domain, the egress IP addresses and the DKIM selectors of a new relay, tenant or email provider. It runs SPF check\_host() for each IP (RFC 7208, with lookup counts), checks reverse DNS

- **URL**: https://apify.com/kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 run starteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Outbound Mail Sender Identity Provisioning Gate for Egress IPs, DKIM Selectors, and Reverse DNS

Get the SPF result for your egress IP and the reverse DNS result before you send the first mail.

You give the From domain, the egress IP addresses and the DKIM selectors of a new relay, tenant or email provider. For each sender identity, the Actor does these checks:

- It runs SPF `check_host()` (RFC 7208) for each egress IP. It gives the result, the mechanism that matched, the DNS lookup count against the limit of 10 and the void lookup count against the limit of 2.
- It finds the PTR names of each egress IP. It checks if each name resolves back to the IP (forward-confirmed reverse DNS). It also marks provider default names, for example `ec2-…amazonaws.com`. This mark is a heuristic.
- It checks that the HELO name resolves to the egress IP.
- It looks up each DKIM key at `<selector>._domainkey.<from domain>`. It gives the key type, the key size and the revoked state (empty `p=`). It also compares the key with the SHA-256 that you expect.
- It finds the DMARC record and its policy, and it tells you if DMARC alignment is possible.
- It gives a **ready** or **blocked** verdict. The verdict lists each blocking code with a one-line fix.

Use it in a deploy pipeline. Call the Actor through the Apify API before the pipeline enables mail for a new environment, tenant, customer, relay or provider.

### What the Actor does not do

- It does not open SMTP connections, and it does not send mail.
- It does not query commercial DNS blocklists, because their free use does not permit commercial use.
- It uses public DNS only: DNS-over-HTTPS at `dns.google`, with `cloudflare-dns.com` as the fallback. It does not fetch URLs that you supply.
- It does not check private or reserved egress addresses (for example `10.0.0.0/8`, `192.0.2.0/24`, `fe80::/10`). Such an address gets the code `egress_ip_not_public`, and the gate is blocked.

### Input

| Field | Required | Description |
| --- | --- | --- |
| `identities` | yes (has a default) | 1 to 50 sender identities. |
| `identities[].from_domain` | yes | The RFC 5322 From domain. |
| `identities[].egress_ips` | yes | 1 to 16 IPv4 or IPv6 addresses. |
| `identities[].dkim_selectors` | yes | 1 to 5 selectors. |
| `identities[].envelope_domain` | no | The MAIL FROM (Return-Path) domain. The SPF check uses this domain. Default: `from_domain`. |
| `identities[].helo_name` | no | The HELO or EHLO name of the relay. The `%{h}` SPF macro also uses it. |
| `identities[].expected_dkim_key_sha256` | no | An object `selector -> SHA-256` of the base64-decoded `p=` value, in hex or base64. With one selector, a single string is also correct. |
| `fail_on` | no | The check codes that set the gate to blocked. Default: `spf_not_pass`, `dkim_missing`, `ptr_missing`, `ptr_not_forward_confirmed`, `dmarc_missing`. |

Example:

```json
{
  "identities": [
    {
      "from_domain": "example.com",
      "envelope_domain": "bounce.example.com",
      "egress_ips": ["198.51.100.25", "2001:db8:25::25"],
      "dkim_selectors": ["s2026"],
      "helo_name": "relay1.example.com",
      "expected_dkim_key_sha256": {"s2026": "3f1c…64 hex characters…"}
    }
  ],
  "fail_on": ["spf_not_pass", "dkim_missing", "ptr_missing", "ptr_not_forward_confirmed", "dmarc_missing", "ptr_generic"]
}
```

The addresses in this example are documentation addresses. The Actor does not check them. Use your public egress addresses.

With an empty input, the Actor checks two sample identities: `github.com` (ready) and `example.com` (blocked, because its SPF record is `-all` and it has no DKIM key).

If the input is not valid, the Actor writes one `run-error` record with the reason, and the run ends SUCCEEDED. It checks nothing and charges nothing.

### Output

The default dataset has four record types. The **Gate verdicts**, **Egress IPs** and **DKIM selectors** views show them as tables.

#### `sender-ip`: one record for each identity and egress IP

| Field | Meaning |
| --- | --- |
| `egressIp`, `ipVersion` | The address. |
| `evaluated` | `false` for a private or reserved address that the Actor did not check. |
| `spfResult` | `pass`, `fail`, `softfail`, `neutral`, `none`, `permerror` or `temperror`. |
| `spfMatchedMechanism` | The path to the mechanism that matched, for example `include:_spf.google.com > ip4:209.85.128.0/17`. |
| `spfReason`, `spfRecord` | Why the result occurred, and the SPF record of the envelope domain. |
| `spfDnsLookups` / `spfDnsLookupLimit` | The DNS lookups of the evaluation, and the limit of 10. |
| `spfVoidLookups` / `spfVoidLookupLimit` | The lookups that returned no data, and the limit of 2. |
| `spfAlignment`, `spfAligned` | `strict`, `relaxed` or `none` for the envelope domain against the From domain, and if this satisfies the DMARC `aspf` tag. |
| `ptrNames`, `fcrdnsResult`, `fcrdnsNames` | The PTR names. The result is `pass`, `fail`, `no_ptr` or `lookup_error`. The names are the names that resolve back to the IP. |
| `genericPtr`, `genericPtrReason`, `genericPtrIsHeuristic` | The generic-PTR heuristic (see below). |
| `heloResult`, `heloAddresses` | `matches_egress_ip`, `resolves_elsewhere`, `no_address`, `lookup_error` or `not_given`. |
| `checkCodes` | The check codes of this IP. |

#### `dkim-selector`: one record for each DKIM selector

`selector`, `dnsName`, `txtFound`, `dkimRecord`, `keyType` (`rsa` or `ed25519`), `keySizeBits`, `revoked` (empty `p=`), `testMode` (`t=y`), `keySha256`, `expectedKeySha256`, `expectedKeyMatch`, `note` and `checkCodes`.

#### `sender-gate`: one record for each identity

`status` (`ready` or `blocked`), `blockingCodes`, `warningCodes`, `fixes` (for each code: the code, `blocking`, a one-line fix, and where the code occurred), `dmarcFound`, `dmarcRecordName`, `dmarcRecord`, `dmarcPolicy`, `dmarcSubdomainPolicy`, `dmarcPct`, `aspf`, `adkim`, `alignmentPossible` (`yes` or `no`), `spfPassIps`, `usableDkimSelectors`, `egressIpsRejected` and `failOn`.

Alignment is possible if all checked egress IPs get SPF `pass` with an aligned envelope domain, or if one selector has a usable DKIM key under the From domain.

For a subdomain without its own DMARC record, the Actor uses the record of the organizational domain (Public Suffix List) and its `sp=` policy.

#### `run-summary`: one record for each run

The number of identities that are ready, blocked and skipped, the blocked identities with their codes, the checked IPs and selectors, the DNS lookup count and the notes.

The status message of the run gives the same summary, for example `2 sender identities: 1 ready, 1 blocked (example.com: dkim_missing, spf_not_pass). 2 egress IPs and 2 DKIM selectors checked.`

A blocked gate is a result, not an error. The run ends SUCCEEDED. Your pipeline must read `status` from the `sender-gate` records.

### Check codes

| Code | Blocks by default | Meaning |
| --- | --- | --- |
| `spf_not_pass` | yes | The SPF result for the egress IP is not `pass`. |
| `spf_lookup_limit_near` | no | The evaluation used 8 or more of the 10 DNS lookups. |
| `spf_not_aligned` | no | The envelope domain does not align with the From domain. |
| `dkim_missing` | yes | The selector has no usable key: no record, an empty `p=`, a key that does not parse, or an RSA key under 1024 bits. |
| `dkim_revoked` | no | The record has an empty `p=`. It also sets `dkim_missing`. |
| `dkim_invalid_key` | no | The `p=` value is not a valid RSA or Ed25519 key. It also sets `dkim_missing`. |
| `dkim_weak_key` | no | The RSA key has fewer than 1024 bits. It also sets `dkim_missing`. |
| `dkim_key_mismatch` | no | The key is not the key that `expected_dkim_key_sha256` names. |
| `dkim_test_mode` | no | The record has `t=y`. |
| `ptr_missing` | yes | The egress IP has no PTR record. |
| `ptr_not_forward_confirmed` | yes | No PTR name resolves back to the egress IP. |
| `ptr_generic` | no | The PTR name looks like a provider default (heuristic). |
| `helo_not_resolving` | no | The HELO name has no A or AAAA record. |
| `helo_mismatch` | no | The HELO name does not resolve to the egress IP. |
| `dmarc_missing` | yes | No single valid DMARC record applies to the From domain. |
| `dmarc_alignment_impossible` | no | Neither SPF nor DKIM can give DMARC alignment. |
| `egress_ip_not_public` | always | The address is private or reserved. |
| `dns_lookup_error` | always | A DNS lookup failed or timed out. The check is not proven, so the gate is blocked. Run the gate again. |

To make a code block the gate, add it to `fail_on`. `egress_ip_not_public` and `dns_lookup_error` always block.

### The generic-PTR heuristic

A PTR name is generic when one of these rules is true:

1. The name contains the IP address: the four octets in order or reversed, the first three octets, the 12 zero-padded digits, or the 8 hex digits. For IPv6, the name contains the last 12 hex nibbles.
2. A part of a label is a word for an access or dynamic pool, for example `dynamic`, `dhcp`, `pool`, `dsl`, `cable`, `customer` or `client`.
3. The name ends with a provider default suffix, for example `.amazonaws.com`, `.googleusercontent.com`, `.your-server.de`, `.vultrusercontent.com` or `.linodeusercontent.com`.

The rule is a heuristic, so `ptr_generic` does not block by default. If a result is incorrect, open an issue.

### Limits

- 50 identities, 16 egress IPs and 5 selectors for each identity.
- Each DNS answer is cached for the run, so the same include chain across many IPs costs its lookups one time.
- One run has a limit of 15,000 DNS lookups. Each lookup has a timeout of 8 s. Each SPF evaluation has a timeout of 40 s, and after that the result is `temperror`.
- The DNS answers have a limit of 64 KB. The Actor does not follow redirects.
- The `%{p}` SPF macro expands to `unknown`, as RFC 7208 section 7.3 permits.

### Pricing

This Actor uses pay per event.

| Event | Unit | Price (USD) | When |
| --- | --- | --- | --- |
| `run-started` | one Actor run | 0.002 | Once for each run with a valid input. |
| `sender-ip-evaluated` | one egress IP evaluated against SPF and reverse DNS for one identity | 0.004 | Once for each public egress IP of each checked identity. A private or reserved address is not charged. |
| `dkim-selector-checked` | one DKIM selector looked up and its key checked | 0.001 | Once for each selector of each checked identity. |
| `sender-gate-verdict` | one ready or blocked verdict for one sender identity | 0.01 | Once for each checked identity. |

The Actor charges the events of an identity after it writes the records of that identity. The `run-summary` record and a `run-error` record are not charged.

#### What one provisioning call costs

1 identity, 2 egress IPs, 2 DKIM selectors:

| Event | Count | Price | Cost (USD) |
| --- | --- | --- | --- |
| `run-started` | 1 | 0.002 | 0.0020 |
| `sender-ip-evaluated` | 2 | 0.004 | 0.0080 |
| `dkim-selector-checked` | 2 | 0.001 | 0.0020 |
| `sender-gate-verdict` | 1 | 0.01 | 0.0100 |
| **Total** | | | **0.0220** |

#### How the size of the run changes the cost

"Charged" uses the default maximum charge of USD 2.00 for each run.

| Identities | IPs each | Selectors each | Uncapped (USD) | Charged (USD) | Identities checked |
| --- | --- | --- | --- | --- | --- |
| 1 | 2 | 2 | 0.0220 | 0.0220 | 1 |
| 10 | 4 | 2 | 0.2820 | 0.2820 | 10 |
| 50 | 2 | 2 | 1.0020 | 1.0020 | 50 |
| 50 | 16 | 5 | 3.9520 | 1.9770 | 25 |

#### What happens at the maximum charge

Before the Actor checks an identity, it makes sure that all events of that identity fit in the rest of the maximum charge. If they do not fit, the Actor stops. It does not check that identity or the identities after it. It writes a note in the `run-summary` record and in the status message. An identity is never half checked.

With the maximum input (50 identities with 16 IPs and 5 selectors each), a run at the default limit of USD 2.00 checks 25 identities. To check all 50 identities in one run, set the maximum charge to USD 3.96 or more, or split the identities into two runs.

# Actor input Schema

## `identities` (type: `array`):

1 to 50 sender identities. Each identity is an object with these fields: from\_domain (required, the RFC 5322 From domain); egress\_ips (required, 1 to 16 public IPv4 or IPv6 addresses of the relay); dkim\_selectors (required, 1 to 5 selectors, looked up at <selector>.\_domainkey.\<from\_domain>); envelope\_domain (optional, the MAIL FROM / Return-Path domain, default from\_domain); helo\_name (optional, the HELO or EHLO name of the relay); expected\_dkim\_key\_sha256 (optional, an object selector -> SHA-256 of the base64-decoded p= value, in hex or base64). Private and reserved egress addresses are not checked and block the gate.

## `fail_on` (type: `array`):

The check codes that set the gate status to blocked. egress\_ip\_not\_public and dns\_lookup\_error always block. Known codes: spf\_not\_pass, spf\_lookup\_limit\_near, spf\_not\_aligned, dkim\_missing, dkim\_revoked, dkim\_invalid\_key, dkim\_weak\_key, dkim\_key\_mismatch, dkim\_test\_mode, ptr\_missing, ptr\_not\_forward\_confirmed, ptr\_generic, helo\_not\_resolving, helo\_mismatch, dmarc\_missing, dmarc\_alignment\_impossible, egress\_ip\_not\_public, dns\_lookup\_error.

## Actor input object example

```json
{
  "identities": [
    {
      "from_domain": "github.com",
      "egress_ips": [
        "209.85.220.41"
      ],
      "dkim_selectors": [
        "google"
      ],
      "helo_name": "mail-sor-f41.google.com"
    },
    {
      "from_domain": "example.com",
      "egress_ips": [
        "8.8.8.8"
      ],
      "dkim_selectors": [
        "default"
      ]
    }
  ],
  "fail_on": [
    "spf_not_pass",
    "dkim_missing",
    "ptr_missing",
    "ptr_not_forward_confirmed",
    "dmarc_missing"
  ]
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `gates` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "identities": [
        {
            "from_domain": "github.com",
            "egress_ips": [
                "209.85.220.41"
            ],
            "dkim_selectors": [
                "google"
            ],
            "helo_name": "mail-sor-f41.google.com"
        },
        {
            "from_domain": "example.com",
            "egress_ips": [
                "8.8.8.8"
            ],
            "dkim_selectors": [
                "default"
            ]
        }
    ],
    "fail_on": [
        "spf_not_pass",
        "dkim_missing",
        "ptr_missing",
        "ptr_not_forward_confirmed",
        "dmarc_missing"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "identities": [
        {
            "from_domain": "github.com",
            "egress_ips": ["209.85.220.41"],
            "dkim_selectors": ["google"],
            "helo_name": "mail-sor-f41.google.com",
        },
        {
            "from_domain": "example.com",
            "egress_ips": ["8.8.8.8"],
            "dkim_selectors": ["default"],
        },
    ],
    "fail_on": [
        "spf_not_pass",
        "dkim_missing",
        "ptr_missing",
        "ptr_not_forward_confirmed",
        "dmarc_missing",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "identities": [
    {
      "from_domain": "github.com",
      "egress_ips": [
        "209.85.220.41"
      ],
      "dkim_selectors": [
        "google"
      ],
      "helo_name": "mail-sor-f41.google.com"
    },
    {
      "from_domain": "example.com",
      "egress_ips": [
        "8.8.8.8"
      ],
      "dkim_selectors": [
        "default"
      ]
    }
  ],
  "fail_on": [
    "spf_not_pass",
    "dkim_missing",
    "ptr_missing",
    "ptr_not_forward_confirmed",
    "dmarc_missing"
  ]
}' |
apify call kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/outbound-mail-sender-identity-provisioning-gate-for-egress-ips"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/lTPnvB7YlHG7rNmBW/builds/or1mGOmjUQ6U1dmS8/openapi.json
