NestboltNestbolt

@nestbolt/disposable-email

Domain Updates

Keep your disposable email domain list current with scheduled updates, custom sources, and custom fetcher implementations.

The @nestbolt/disposable-email package ships with a bundled domains.json file containing thousands of known disposable email domains. While this list is comprehensive at the time of each package release, new disposable email services appear regularly. To keep your blocklist current without upgrading the package, you can fetch fresh domain lists from remote sources at runtime.

How Domain Loading Works

Understanding the domain loading lifecycle helps you make the right decisions about updates and persistence.

Startup Behavior

When the module initializes, the DisposableEmailService calls bootstrap() automatically via the onModuleInit hook:

  1. If storagePath is configured and the file exists, domains are loaded from that file.
  2. Otherwise, the bundled domains.json file (shipped with the package) is used.
  3. Whitelisted domains are removed from the loaded set.

At no point during startup does the service make HTTP requests. Startup is always local and synchronous (aside from reading the file from disk), so it does not delay application boot time.

Update Behavior

When updateDomains() is called:

  1. The configured fetcher retrieves domain lists from each URL in the sources array.
  2. All successfully fetched lists are merged and deduplicated.
  3. The whitelist is applied.
  4. The in-memory domain set is replaced atomically.
  5. If storagePath is configured, the merged list is written to disk.

Failed source fetches are logged but do not prevent other sources from being processed.

Scheduled Updates with @nestjs/schedule

The most common approach is to use a cron job that runs periodically. The @nestjs/schedule package provides cron decorators that work seamlessly with NestJS:

Install @nestjs/schedule

npm install @nestjs/schedule

Register the ScheduleModule

import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { DisposableEmailModule } from "@nestbolt/disposable-email";
import { DomainsUpdateTask } from "./tasks/domains-update.task";

@Module({
  imports: [
    ScheduleModule.forRoot(),
    DisposableEmailModule.forRoot({
      storagePath: "./storage/disposable_domains.json",
    }),
  ],
  providers: [DomainsUpdateTask],
})
export class AppModule {}

Create the Update Task

import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { DisposableEmailService } from "@nestbolt/disposable-email";

@Injectable()
export class DomainsUpdateTask {
  private readonly logger = new Logger(DomainsUpdateTask.name);

  constructor(private readonly disposableEmail: DisposableEmailService) {}

  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async handleCron() {
    this.logger.log("Starting scheduled disposable domains update...");

    try {
      await this.disposableEmail.updateDomains();
      const count = this.disposableEmail.getDomains().length;
      this.logger.log(`Update complete. ${count} domains loaded.`);
    } catch (error) {
      this.logger.error("Failed to update disposable domains", error);
    }
  }
}

Cron Expressions

@nestjs/schedule provides several predefined expressions via CronExpression:

ExpressionSchedule
CronExpression.EVERY_DAY_AT_MIDNIGHTOnce per day at 00:00
CronExpression.EVERY_12_HOURSTwice per day
CronExpression.EVERY_WEEKOnce per week
CronExpression.EVERY_HOUROnce per hour

You can also use custom cron strings:

// Every day at 3:30 AM
@Cron("30 3 * * *")
async handleCron() {
  await this.disposableEmail.updateDomains();
}

For most applications, updating once per day is sufficient. Disposable email providers do not appear and disappear rapidly, so hourly updates are rarely needed.

Manual Updates via Admin Endpoint

Expose a protected endpoint for administrators to trigger updates on demand:

import { Controller, Post, Get, UseGuards } from "@nestjs/common";
import { DisposableEmailService } from "@nestbolt/disposable-email";

@Controller("admin/disposable-domains")
@UseGuards(AdminGuard)
export class DisposableDomainController {
  constructor(private readonly disposableEmail: DisposableEmailService) {}

  @Post("update")
  async update() {
    const beforeCount = this.disposableEmail.getDomains().length;
    await this.disposableEmail.updateDomains();
    const afterCount = this.disposableEmail.getDomains().length;

    return {
      message: "Domain list updated successfully.",
      previousCount: beforeCount,
      currentCount: afterCount,
      added: afterCount - beforeCount,
    };
  }

  @Get("status")
  status() {
    const domains = this.disposableEmail.getDomains();
    return {
      totalDomains: domains.length,
      sampleDomains: domains.slice(0, 20),
    };
  }
}

Custom Sources

By default, the package fetches from the disposable/disposable-email-domains repository via jsDelivr CDN. You can specify additional or alternative sources:

Adding a Second Public Source

DisposableEmailModule.forRoot({
  sources: [
    "https://cdn.jsdelivr.net/gh/disposable/disposable-email-domains@master/domains.json",
    "https://raw.githubusercontent.com/your-org/custom-blocklist/main/domains.json",
  ],
  storagePath: "./storage/disposable_domains.json",
});

When multiple sources are configured, all are fetched during updateDomains(). The results are merged and deduplicated, so overlapping domains across sources do not cause duplicates.

Using an Internal API

If your organization maintains its own blocklist, point to your internal API:

DisposableEmailModule.forRoot({
  sources: [
    "https://internal-api.company.com/v1/disposable-domains",
  ],
  storagePath: "./storage/disposable_domains.json",
});

The source URL must return a JSON response that is an array of domain strings:

["mailinator.com", "guerrillamail.com", "tempmail.com"]

Combining Internal and External Sources

DisposableEmailModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    sources: [
      // Community-maintained list
      "https://cdn.jsdelivr.net/gh/disposable/disposable-email-domains@master/domains.json",
      // Company-specific additions
      config.get<string>("INTERNAL_BLOCKLIST_URL"),
    ].filter(Boolean),
    storagePath: config.get<string>(
      "DISPOSABLE_STORAGE_PATH",
      "./storage/disposable_domains.json",
    ),
  }),
});

Persistence with storagePath

Setting a storagePath enables local persistence of fetched domain lists. This is important for two reasons:

  1. Survivability: Updated domains persist across application restarts without re-fetching.
  2. Offline resilience: If remote sources are unavailable at startup, the last successfully fetched list is used.

How Persistence Works

Application starts
    |
    v
Does storagePath file exist?
    |           |
   Yes          No
    |           |
    v           v
Load from      Load bundled
storage file   domains.json
    |           |
    v           v
Apply whitelist, serve requests
    |
    v
updateDomains() called (cron / manual)
    |
    v
Fetch from sources
    |
    v
Merge, deduplicate, apply whitelist
    |
    v
Update in-memory set
    |
    v
Write to storagePath

Configuration

DisposableEmailModule.forRoot({
  storagePath: "./storage/disposable_domains.json",
});

Make sure:

  • The path ends with .json (the module validates this at startup and throws an error otherwise).
  • The parent directory exists and is writable by the application process.
  • The path is excluded from version control (add it to .gitignore).

Custom Fetcher Implementation

The default fetcher uses the native fetch() API with a 30-second timeout. For environments that require proxies, custom headers, retries, or a different HTTP client, implement the Fetcher interface:

export interface Fetcher {
  fetch(url: string): Promise<string[]>;
}

Fetcher with Proxy Support

import { Fetcher } from "@nestbolt/disposable-email";
import { HttpsProxyAgent } from "https-proxy-agent";

export class ProxyFetcher implements Fetcher {
  private readonly agent: HttpsProxyAgent;

  constructor(proxyUrl: string) {
    this.agent = new HttpsProxyAgent(proxyUrl);
  }

  async fetch(url: string): Promise<string[]> {
    const response = await globalThis.fetch(url, {
      agent: this.agent,
    } as RequestInit);

    if (!response.ok) {
      throw new Error(
        `Failed to fetch from ${url}: ${response.status} ${response.statusText}`,
      );
    }

    return response.json();
  }
}

Usage:

DisposableEmailModule.forRoot({
  fetcher: new ProxyFetcher("http://proxy.corporate.com:8080"),
});

Fetcher with Axios and Retries

import { Fetcher } from "@nestbolt/disposable-email";
import axios, { AxiosInstance } from "axios";
import axiosRetry from "axios-retry";

export class AxiosRetryFetcher implements Fetcher {
  private readonly client: AxiosInstance;

  constructor() {
    this.client = axios.create({ timeout: 30_000 });

    axiosRetry(this.client, {
      retries: 3,
      retryDelay: axiosRetry.exponentialDelay,
      retryCondition: (error) => {
        return (
          axiosRetry.isNetworkError(error) ||
          axiosRetry.isRetryableError(error)
        );
      },
    });
  }

  async fetch(url: string): Promise<string[]> {
    const response = await this.client.get<string[]>(url);
    return response.data;
  }
}

Fetcher with Authentication

import { Fetcher } from "@nestbolt/disposable-email";

export class AuthenticatedFetcher implements Fetcher {
  constructor(private readonly apiKey: string) {}

  async fetch(url: string): Promise<string[]> {
    const response = await globalThis.fetch(url, {
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        Accept: "application/json",
      },
      signal: AbortSignal.timeout(30_000),
    });

    if (!response.ok) {
      throw new Error(
        `Failed to fetch from ${url}: ${response.status} ${response.statusText}`,
      );
    }

    return response.json();
  }
}

Usage with async configuration to inject the API key from ConfigService:

DisposableEmailModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    sources: [config.get<string>("BLOCKLIST_API_URL")],
    fetcher: new AuthenticatedFetcher(
      config.get<string>("BLOCKLIST_API_KEY"),
    ),
    storagePath: "./storage/disposable_domains.json",
  }),
});

Fetcher with Logging

import { Fetcher } from "@nestbolt/disposable-email";
import { Logger } from "@nestjs/common";

export class LoggingFetcher implements Fetcher {
  private readonly logger = new Logger(LoggingFetcher.name);
  private readonly inner: Fetcher;

  constructor(inner: Fetcher) {
    this.inner = inner;
  }

  async fetch(url: string): Promise<string[]> {
    const start = Date.now();
    this.logger.log(`Fetching domains from ${url}...`);

    try {
      const domains = await this.inner.fetch(url);
      const elapsed = Date.now() - start;
      this.logger.log(
        `Fetched ${domains.length} domains from ${url} in ${elapsed}ms`,
      );
      return domains;
    } catch (error) {
      const elapsed = Date.now() - start;
      this.logger.error(
        `Failed to fetch from ${url} after ${elapsed}ms`,
        error,
      );
      throw error;
    }
  }
}

This wrapping pattern lets you compose fetchers. For example, add logging to the authenticated fetcher:

DisposableEmailModule.forRoot({
  fetcher: new LoggingFetcher(new AuthenticatedFetcher("your-api-key")),
});

Error Handling

Both updateDomains() and bootstrap() handle errors gracefully:

  • Failed source fetches: When a source URL is unreachable or returns an error, the failure is logged via the NestJS Logger and the remaining sources are still processed. If all sources fail, the in-memory domain set is not modified -- the previously loaded domains remain active.
  • Failed storage writes: If the fetched domains cannot be written to storagePath (e.g., permission denied, disk full), the error is logged but the in-memory update still succeeds. The domains are available for the current process lifetime.
  • Corrupt storage file: If the storage file exists but contains invalid JSON, bootstrap() logs the error and falls back to the bundled domains.json.

This design ensures that domain validation is never interrupted by transient network or filesystem errors. The worst case is that the domain list is not as fresh as it could be.

For production applications, the recommended configuration combines all the strategies above:

import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { DisposableEmailModule } from "@nestbolt/disposable-email";
import { DomainsUpdateTask } from "./tasks/domains-update.task";

@Module({
  imports: [
    ConfigModule.forRoot(),
    ScheduleModule.forRoot(),
    DisposableEmailModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        sources: [
          "https://cdn.jsdelivr.net/gh/disposable/disposable-email-domains@master/domains.json",
        ],
        storagePath: config.get<string>(
          "DISPOSABLE_STORAGE_PATH",
          "./storage/disposable_domains.json",
        ),
        whitelist: config
          .get<string>("DISPOSABLE_WHITELIST", "")
          .split(",")
          .filter(Boolean),
        includeSubdomains: true,
      }),
    }),
  ],
  providers: [DomainsUpdateTask],
})
export class AppModule {}

With the scheduled task:

import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { DisposableEmailService } from "@nestbolt/disposable-email";

@Injectable()
export class DomainsUpdateTask {
  private readonly logger = new Logger(DomainsUpdateTask.name);

  constructor(private readonly disposableEmail: DisposableEmailService) {}

  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async handleCron() {
    this.logger.log("Starting scheduled disposable domains update...");

    try {
      await this.disposableEmail.updateDomains();
      const count = this.disposableEmail.getDomains().length;
      this.logger.log(`Update complete. ${count} domains loaded.`);
    } catch (error) {
      this.logger.error("Failed to update disposable domains", error);
    }
  }
}

This gives you:

  • Domains loaded from local storage on startup (fast, no network dependency).
  • Automatic daily refresh from the community-maintained source.
  • Local persistence so updates survive restarts.
  • Subdomain matching for comprehensive coverage.
  • Configurable whitelist via environment variables.