DEV Community

Cover image for Stop giving your real email to websites. Here's what I built with Laravel and cPanel API.
MisterDebug
MisterDebug

Posted on

Stop giving your real email to websites. Here's what I built with Laravel and cPanel API.

One email address per service. Kill the forwarding when spam starts. Your real inbox stays invisible. All from your phone.

TL;DR

Did you know cPanel has a full REST API? I didn't either. I used it to build a Laravel PWA that lets me create a disposable email address in 10 seconds — right while filling out a signup form on my phone.

The idea: every service gets its own address (amazon_xxx@mydomain.com, netflix_xxx@mydomain.com...). If it starts spamming, I cut the forwarding in one click. My real inbox is never exposed.

In this post I'll walk through the architecture decisions, the SOLID patterns I used, and how I made the whole thing testable without a single real HTTP call.


The Problem

You sign up for a new service, hand over your email, and a few weeks later the spam starts. Your address leaked, got sold, or the service got breached.

The usual workarounds aren't great:

  • Gmail +alias (user+amazon@gmail.com) still exposes your real address — anyone can reverse-engineer it.
  • Protonmail aliases are elegant but require a paid plan and trusting a third party.
  • Disposable inboxes (Guerrilla Mail etc.) aren't persistent.

The good news: if you have a cPanel hosting account (which most shared hosting runs on), you already have everything you need. cPanel exposes a full REST API including complete email and forwarder management.

The concept is simple: create amazon_xxx@mydomain.com that automatically forwards to your real inbox. Spam starts? Cut the forwarder. Your main address stays invisible forever.


Architecture — Design Before Coding

Before writing a single line, let's define the architecture. The feature set is simple, but this is a great opportunity to apply SOLID principles properly.

Here's the dependency hierarchy:

EmailController
  ↓ depends on EmailProviderInterface
CpanelService  (business logic)
  ↓ depends on CpanelClientInterface
CpanelHttpClient (prod) | FakeCpanelClient (tests)
Enter fullscreen mode Exit fullscreen mode

Two interfaces, two levels of abstraction:

  • CpanelClientInterface — pure HTTP transport. It knows how to call the cPanel API, that's it.
  • EmailProviderInterface — the business contract. Exposes methods like createEmailWithForwarder() without knowing anything about cPanel underneath.

Why this separation? Because we want to test business logic without real HTTP calls — but that's a topic for another post.


The cPanel API — What You Need to Know

Authentication

cPanel exposes a REST API called UAPI on port 2083. Auth is done via an API token (generate one in cPanel → Security → API Tokens). Send it in the Authorization header:

Authorization: cpanel MY_USER:MY_TOKEN
Enter fullscreen mode Exit fullscreen mode

Endpoints we use

Email/list_pops_with_disk  → list addresses with disk info
Email/add_pop              → create an address
Email/delete_pop           → delete an address
Email/list_forwarders      → list forwarders for a domain
Email/add_forwarder        → create a forwarder
Email/delete_forwarder     → delete a forwarder
Enter fullscreen mode Exit fullscreen mode

list_pops vs list_pops_with_disk

Email/list_pops only returns 4 fields: email, login, suspended_incoming, suspended_login. If you want quota and disk usage, use Email/list_pops_with_disk which adds domain, user, diskused, diskquota, humandiskused, humandiskquota, diskusedpercent.

The forwarder gotcha

This one tripped me up. A forwarder is not attached to an email account — it's an independent routing rule at the mail server level. The API response uses confusing key names:

  • dest — the source address (the one doing the forwarding)
  • forward — the destination (where mail actually arrives)

And to delete a forwarder, the API requires both parameters: the source address (address) AND the destination (forwarder). Miss one and it silently fails.


The Result Object Pattern

Before diving into the classes, let's talk about how we handle errors throughout.

The classic PHP approach is exceptions. But they have a problem: they mix infrastructure errors (network timeout) with business errors (email already exists). The caller has to catch both with cascading try/catch blocks.

The Result Object is a cleaner alternative:

// Before (exceptions)
try {
    $this->provider->createEmail(...);
    return redirect()->with('success', '...');
} catch (RuntimeException $e) {
    return redirect()->with('error', $e->getMessage());
}

// After (Result Object)
$result = $this->provider->createEmail(...);
return $result->isOk()
    ? redirect()->with('success', '...')
    : redirect()->with('error', $result->error());
Enter fullscreen mode Exit fullscreen mode

The Result class itself is tiny:

final class Result
{
    private function __construct(
        private readonly bool   $ok,
        private readonly mixed  $value = null,
        private readonly string $error = '',
    ) {}

    public static function ok(mixed $value = null): self
    {
        return new self(true, $value);
    }

    public static function fail(string $error): self
    {
        return new self(false, error: $error);
    }

    public function isOk(): bool   { return $this->ok; }
    public function isFail(): bool  { return ! $this->ok; }
    public function value(): mixed  { return $this->value; }
    public function error(): string { return $this->error; }
}
Enter fullscreen mode Exit fullscreen mode

This pattern shines especially for composed operations like createEmailWithForwarder() — where the address can be created successfully but the forwarder fails. An exception would unwind everything. Result lets us return a partial failure with a clear message.


The Interfaces — Dependency Inversion in Practice

The D in SOLID says we should depend on abstractions, not concrete implementations. So we define both interfaces before writing any implementation.

CpanelClientInterface — transport layer

One method, one job: call a cPanel endpoint and return a Result.

interface CpanelClientInterface
{
    public function call(string $endpoint, array $params = []): Result;
}
Enter fullscreen mode Exit fullscreen mode

EmailProviderInterface — business layer

This interface exposes business operations without knowing anything about cPanel.

interface EmailProviderInterface
{
    public function listEmails(): Result;
    public function createEmail(string $email, string $password,
                                string $domain, int $quota): Result;
    public function listForwarders(string $domain): Result;
    public function createForwarder(string $email, string $domain,
                                    string $forwardTo): Result;
    public function deleteForwarder(string $email, string $domain,
                                    string $forwardTo): Result;
    public function createEmailWithForwarder(...): Result;
    public function deleteEmailWithForwarder(...): Result;
}
Enter fullscreen mode Exit fullscreen mode

Want to switch to Mailgun tomorrow? Create a new class implementing EmailProviderInterface, update the DI binding. The controller doesn't change at all.


The Implementations

CpanelHttpClient — the real HTTP call

The production implementation. Receives config via constructor, uses Laravel's HTTP client to hit the API. All error handling is centralized here — the service layer never needs a try/catch.

final class CpanelHttpClient implements CpanelClientInterface
{
    public function __construct(
        private readonly string $host,
        private readonly string $user,
        private readonly string $token,
    ) {}

    public function call(string $endpoint, array $params = []): Result
    {
        try {
            $response = Http::withHeaders([
                'Authorization' => "cpanel {$this->user}:{$this->token}",
            ])->get("{$this->host}/execute/{$endpoint}", $params);

            $json = $response->json();

            if (! isset($json['result']['status'])) {
                return Result::fail('Invalid API response.');
            }

            if ($json['result']['status'] !== 1) {
                $error = implode(', ', $json['result']['errors'] ?? ['Unknown error.']);
                return Result::fail($error);
            }

            return Result::ok($json['result']['data'] ?? null);

        } catch (ConnectionException $e) {
            return Result::fail('Could not reach cPanel: ' . $e->getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

CpanelService — business logic

The service receives a CpanelClientInterface via dependency injection. It doesn't know if it's talking to the real HTTP client or the Fake — that's the whole point.

final class CpanelService implements EmailProviderInterface
{
    public function __construct(
        private readonly CpanelClientInterface $client
    ) {}

    public function listEmails(): Result
    {
        return $this->client->call('Email/list_pops_with_disk', [
            'no_validate' => 1, // skip slow DB validation
        ]);
    }

    public function deleteForwarder(
        string $email, string $domain, string $forwardTo
    ): Result {
        // API requires BOTH source and destination
        return $this->client->call('Email/delete_forwarder', [
            'address'   => "{$email}@{$domain}",
            'forwarder' => $forwardTo,
        ]);
    }

    public function createEmailWithForwarder(
        string $email, string $password,
        string $domain, string $forwardTo, int $quota
    ): Result {
        $result = $this->createEmail($email, $password, $domain, $quota);
        if ($result->isFail()) return $result;

        $forwarder = $this->createForwarder($email, $domain, $forwardTo);
        if ($forwarder->isFail()) {
            return Result::fail(
                "Address created but forwarding failed: {$forwarder->error()}"
            );
        }

        return Result::ok();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Controller — HTTP Only

The controller has one job: translate an HTTP request into an HTTP response. Zero business logic.

class EmailController extends Controller
{
    public function __construct(private EmailProviderInterface $provider) {}

    public function index()
    {
        $domain           = config('services.cpanel.domain');
        $emailsResult     = $this->provider->listEmails();
        $forwardersResult = $this->provider->listForwarders($domain);

        $forwarders = $forwardersResult->isOk() ? $forwardersResult->value() : [];

        // dest = source address, forward = destination (cPanel convention)
        $activeForwarders = collect($forwarders)
            ->mapWithKeys(fn($f) => [$f['dest'] => true]);

        $emails = collect($emailsResult->value())
            ->map(function ($email) use ($activeForwarders) {
                $email['has_forwarder'] = $activeForwarders->has($email['email']);
                return $email;
            })->all();

        return view('emails.index', compact('emails', 'domain'));
    }

    public function store(StoreEmailRequest $request)
    {
        $result = $this->provider->createEmailWithForwarder(
            $request->username,
            $request->password,
            $request->domain,
            config('services.cpanel.forward_to'),
            $request->input('quota', 250),
        );

        return $result->isOk()
            ? redirect()->route('emails.index')->with('success', 'Address created.')
            : redirect()->route('emails.index')->with('error', $result->error());
    }
}
Enter fullscreen mode Exit fullscreen mode

Validation is delegated to a dedicated StoreEmailRequest FormRequest — Single Responsibility Principle applied.


The View — Blade + Tailwind

The view is a single Blade file. A form to create an address at the top, a table with all addresses below. Each row shows the address, disk usage with a color-coded progress bar, and a forwarding toggle button.

A few details worth noting:

  • The username input and the @domain part are visually merged into a single field using rounded-l-lg / rounded-r-lg — cleaner than two separate inputs.
  • The password is pre-filled with a random secure value via str()->password() — one less thing to think about.
  • The forwarding toggle switches between green (active) and gray (cut) and sends to two different routes depending on state.
  • The progress bar turns amber above 50% and red above 80%.
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Email address manager') }}
        </h2>
    </x-slot>

    <div class="py-8">
        <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">

            @if(session('success'))
                <div class="flex items-center gap-3 bg-green-50 border border-green-200 text-green-800 text-sm px-4 py-3 rounded-lg">
                    <svg class="w-4 h-4 shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
                    </svg>
                    {{ session('success') }}
                </div>
            @endif
            @if(session('error'))
                <div class="flex items-center gap-3 bg-red-50 border border-red-200 text-red-800 text-sm px-4 py-3 rounded-lg">
                    <svg class="w-4 h-4 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                    </svg>
                    {{ session('error') }}
                </div>
            @endif

            <div class="bg-white rounded-xl shadow-sm border border-gray-200">
                <div class="px-6 pt-6 border-b border-gray-100">
                    <h3 class="text-base font-semibold text-gray-900">Create an address</h3>
                </div>

                <form method="POST" action="{{ route('emails.store') }}" class="px-6 py-3 space-y-4">
                    @csrf

                    @php $defaultUsername = 'amazon_' . rand(100, 9999); @endphp

                    <div class="flex flex-col gap-1">
                        <label class="text-xs font-medium text-gray-600">Email address</label>
                        <div class="flex">
                            <input type="text" name="username"
                                   value="{{ old('username', $defaultUsername) }}"
                                   class="border border-gray-300 rounded-l-lg px-3 py-2 text-sm min-w-0 flex-1
                                          focus:outline-none focus:ring-2 focus:ring-blue-500 focus:z-10
                                          @error('username') border-red-400 @enderror">
                            <span class="inline-flex items-center px-3 bg-gray-50 border-t border-b border-gray-300 text-gray-400 text-sm select-none">@</span>
                            <input type="text" name="domain"
                                   value="{{ old('domain', $domain) }}"
                                   class="border border-gray-300 rounded-r-lg px-3 py-2 text-sm min-w-0 flex-1
                                          focus:outline-none focus:ring-2 focus:ring-blue-500 focus:z-10
                                          @error('domain') border-red-400 @enderror">
                        </div>
                        @error('username') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
                        @error('domain')   <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
                    </div>

                    @php $defaultPassword = str()->password(length: rand(16, 20), symbols: false); @endphp
                    <div class="flex gap-4">
                        <div class="flex flex-col gap-1">
                            <label class="text-xs font-medium text-gray-600">Password</label>
                            <input type="text" name="password"
                                   value="{{ old('password', $defaultPassword) }}"
                                   class="border border-gray-300 rounded-lg px-3 py-2 text-sm
                                          focus:outline-none focus:ring-2 focus:ring-blue-500 w-52
                                          @error('password') border-red-400 @enderror">
                        </div>
                        <div class="flex flex-col gap-1">
                            <label class="text-xs font-medium text-gray-600">
                                Quota <span class="text-gray-400 font-normal">(MB, 0 = ∞)</span>
                            </label>
                            <input type="number" name="quota" value="{{ old('quota', 250) }}" min="0"
                                   class="border border-gray-300 rounded-lg px-3 py-2 text-sm
                                          focus:outline-none focus:ring-2 focus:ring-blue-500 w-28">
                        </div>
                    </div>

                    <div class="pt-1">
                        <button type="submit"
                                class="inline-flex items-center gap-2 bg-gray-900 hover:bg-gray-700
                                       text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
                            <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
                            </svg>
                            Create
                        </button>
                    </div>
                </form>
            </div>

            <div class="bg-white rounded-xl shadow-sm border border-gray-200">
                <div class="px-6 py-6 border-b border-gray-100">
                    <h3 class="text-base font-semibold text-gray-900">Addresses</h3>
                </div>

                <div class="overflow-x-auto">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead class="bg-gray-50">
                            <tr>
                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Storage</th>
                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Forwarding</th>
                            </tr>
                        </thead>
                        <tbody class="bg-white divide-y divide-gray-200">
                            @forelse($emails as $item)
                            <tr class="hover:bg-gray-50 transition-colors">

                                <td class="px-6 py-4 whitespace-nowrap">
                                    <span class="font-mono text-sm text-gray-800">{{ $item['email'] }}</span>
                                    @if($item['has_suspended'] ?? false)
                                        <span class="ml-2 text-xs bg-orange-100 text-orange-600 px-2 py-0.5 rounded-full">suspended</span>
                                    @endif
                                </td>

                                <td class="px-6 py-4 whitespace-nowrap">
                                    <div class="text-sm text-gray-700">
                                        {{ $item['humandiskused'] ?? '0 MB' }}
                                        <span class="text-gray-400"> / </span>
                                        {{ $item['humandiskquota'] ?? '∞' }}
                                    </div>
                                    @if(isset($item['diskusedpercent']) && ($item['diskquota'] ?? '') !== 'unlimited')
                                        @php $pct = min((int) $item['diskusedpercent'], 100); @endphp
                                        <div class="mt-1 w-32 bg-gray-200 rounded-full h-1.5">
                                            <div class="h-1.5 rounded-full {{ $pct > 80 ? 'bg-red-400' : ($pct > 50 ? 'bg-amber-400' : 'bg-blue-400') }}"
                                                 style="width: {{ $pct }}%"></div>
                                        </div>
                                    @endif
                                </td>

                                <td class="px-6 py-4 whitespace-nowrap">
                                    @if($item['has_forwarder'])
                                        <form method="POST" action="{{ route('emails.forwarder.disable') }}">
                                            @csrf @method('DELETE')
                                            <input type="hidden" name="email"  value="{{ $item['user'] }}">
                                            <input type="hidden" name="domain" value="{{ $item['domain'] }}">
                                            <button type="submit"
                                                    class="inline-flex items-center gap-1.5 bg-emerald-50 text-emerald-700
                                                           border border-emerald-200 text-xs font-medium px-3 py-1.5 rounded-full
                                                           hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors">
                                                <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block"></span>
                                                Enabled — Remove it
                                            </button>
                                        </form>
                                    @else
                                        <form method="POST" action="{{ route('emails.forwarder.enable') }}">
                                            @csrf
                                            <input type="hidden" name="email"  value="{{ $item['user'] }}">
                                            <input type="hidden" name="domain" value="{{ $item['domain'] }}">
                                            <button type="submit"
                                                    class="inline-flex items-center gap-1.5 bg-gray-50 text-gray-500
                                                           border border-gray-200 text-xs font-medium px-3 py-1.5 rounded-full
                                                           hover:bg-emerald-50 hover:text-emerald-700 hover:border-emerald-200 transition-colors">
                                                <span class="w-1.5 h-1.5 rounded-full bg-gray-300 inline-block"></span>
                                                Removed — Activate it
                                            </button>
                                        </form>
                                    @endif
                                </td>

                            </tr>
                            @empty
                            <tr>
                                <td colspan="3" class="px-6 py-12 text-center text-sm text-gray-400">
                                    No addresses yet
                                </td>
                            </tr>
                            @endforelse
                        </tbody>
                    </table>
                </div>
            </div>

        </div>
    </div>
</x-app-layout>
Enter fullscreen mode Exit fullscreen mode

Configuration

.env

CPANEL_HOST=https://mydomain.com:2083
CPANEL_USER=my_cpanel_user
CPANEL_TOKEN=my_api_token
CPANEL_DOMAIN=mydomain.com
CPANEL_FORWARD_TO=real@mydomain.com
Enter fullscreen mode Exit fullscreen mode

config/services.php

'cpanel' => [
    'host'       => env('CPANEL_HOST'),
    'user'       => env('CPANEL_USER'),
    'token'      => env('CPANEL_TOKEN'),
    'domain'     => env('CPANEL_DOMAIN'),
    'forward_to' => env('CPANEL_FORWARD_TO'),
],
Enter fullscreen mode Exit fullscreen mode

The CPANEL_FORWARD_TO value — your real inbox — lives only in the .env. It never appears in the UI, forms, or logs.


The PWA — Your Inbox Manager in Your Pocket

The app is built with Laravel Breeze for auth, protected behind an auth middleware. Only you can access it.

To install it as a PWA: add a manifest.json and a basic service worker. On iOS (Safari): Share → Add to Home Screen. On Android (Chrome): the browser prompts automatically.

The result: a full-screen app on your home screen. You're signing up for a new service? Switch to the app, type the service name, hit Create — the address exists in 10 seconds. Copy it into the signup form and move on.

No more opening cPanel, navigating to Email Accounts, filling in a full form, waiting for the page to reload. Ten seconds vs. two minutes.


Wrapping Up

Here's what we built:

  • One email alias per service, auto-forwarded to your real inbox
  • One-click forwarder toggle to kill spam without deleting the address
  • SOLID architecture with clean separation between transport, business logic, and HTTP
  • Result Object pattern for explicit, composable error handling
  • Installable PWA to create addresses from your phone in seconds

Found this useful? Drop a comment or a reaction — it genuinely helps. And if you build something on top of this, I'd love to hear about it.

Top comments (0)