---
title: "Deliver store license keys by email"
description: "Deliver store license keys by email for the complete private reference application."
slug: "/docs/v1/guides/store-service/email"
section: "Webhooks and integrations"
order: 797
audience: "all"
level: "advanced"
last_updated: "2026-10-18"
version: "v1"
related: ["/docs/v1/guides/any-store", "/docs/v1/api/idempotency"]
draft: false
---
Use the exact private encrypted key saved after a confirmed initial issuance. The mail worker never calls create-license, so an email retry cannot mint a second key. This is your software buyer's [delivery](/docs/v1/start/glossary#delivery) workflow, not KDPika platform account email configuration.

## What you will learn

Read and run the exact Deliver store [license keys](/docs/v1/start/glossary#license-key) by email with the complete shared application, without exposing private credentials or claiming an unverified payment.

## Configure private SMTP

1. Complete the [store service installation](/docs/v1/guides/store-service) and ensure the initial licensing effect is DONE.
2. In private environment.php set KDP_SMTP_HOST, KDP_SMTP_PORT, KDP_SMTP_USERNAME, KDP_SMTP_PASSWORD and KDP_SMTP_FROM for your own authenticated sender.
3. Use port 465 for implicit TLS or 587 for STARTTLS. Certificate validation remains enabled. Do not bypass TLS to make a production test pass.
4. Set KDP_SOFTWARE_NAME to your application's plain display name. The worker refuses newline injection in header-related values and sends a UTF-8 plain-text transactional email.
5. Verify your sending domain's SPF/DKIM and applicable DMARC policy with your mail provider. Use a sender address you control and test the actual recipient inbox/spam folder.

## Run and inspect

```bash
/usr/local/bin/php /home/ACCOUNT/license-events/private/kdpika-store/stores/cron.php mail
```

Replace the CLI binary/path with your host's actual values. Expected after one completed issue: smtp_accepted:1, review:0, followed by smtp_accepted:0 on another run. The exact JSON also indicates any held items. The worker selects at most 25 previously unattempted saved keys and tracks the ciphertext fingerprint in a separate private mail.sqlite ledger. A large already-sent prefix cannot starve later deliveries.

SMTP DATA 250 means the mail server explicitly accepted the message, not inbox delivery or reading. A disconnect after DATA, an unknown acknowledgement or interruption leaves IN_FLIGHT/REVIEW and does not auto-resend. Check your provider's private delivery evidence before a reviewed retry. No reset/resend automation is supplied. Do not delete mail.sqlite or create another [license](/docs/v1/start/glossary#license) to make delivery green.

## Complete source

```php
<?php
declare(strict_types=1);
// Private encrypted issuance outbox -> authenticated SMTP delivery worker.
// PHP 8.2+, PDO SQLite, OpenSSL; same private fulfillment/store configuration.
// Run: php stores/mail.php. No keys/addresses/credentials are printed.
// Unknown SMTP acknowledgement is held for review, never blindly resent.
ini_set('display_errors', '0');

function mailRequire(bool $condition): void { if (!$condition) { throw new RuntimeException('Private mail configuration or delivery requires review'); } }

function sendLicenseMail(string $recipient, string $key): void
{
    $host = (string) getenv('KDP_SMTP_HOST'); $port = (int) getenv('KDP_SMTP_PORT');
    $from = (string) getenv('KDP_SMTP_FROM'); $name = (string) getenv('KDP_SOFTWARE_NAME');
    mailRequire(filter_var($recipient, FILTER_VALIDATE_EMAIL) !== false && filter_var($from, FILTER_VALIDATE_EMAIL) !== false);
    mailRequire($name !== '' && strlen($name) <= 120 && !preg_match('/[\r\n]/', $name . $from . $recipient . $host));
    $config = json_decode((string) file_get_contents((string) getenv('KDP_FULFILLMENT_CONFIG')), true, 64, JSON_THROW_ON_ERROR);
    $local = $config['environment'] === 'sandbox' && getenv('KDP_SMTP_ALLOW_LOOPBACK') === '1' && $host === '127.0.0.1';
    mailRequire($local || in_array($port, [465, 587], true));
    $context = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'peer_name' => $host, 'allow_self_signed' => false]]);
    $stream = stream_socket_client(($port === 465 && !$local ? 'ssl://' : 'tcp://') . $host . ':' . $port, $code, $error, 10, STREAM_CLIENT_CONNECT, $context);
    mailRequire(is_resource($stream)); stream_set_timeout($stream, 10);
    $read = static function (int $expected) use ($stream): void {
        $bytes = 0;
        do {
            $line = fgets($stream, 2048); mailRequire(is_string($line)); $bytes += strlen($line);
            mailRequire($bytes <= 16384 && preg_match('/\A[0-9]{3}[- ]/', $line) === 1);
            $more = $line[3] === '-'; $status = (int) substr($line, 0, 3);
        } while ($more);
        mailRequire($status === $expected);
    };
    $write = static function (string $line) use ($stream): void { $raw = $line . "\r\n"; mailRequire(fwrite($stream, $raw) === strlen($raw)); };
    try {
        $read(220); $write('EHLO localhost'); $read(250);
        if (!$local && $port === 587) {
            $write('STARTTLS'); $read(220);
            mailRequire(stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) === true);
            $write('EHLO localhost'); $read(250);
        }
        if (!$local) {
            $username = (string) getenv('KDP_SMTP_USERNAME'); $password = (string) getenv('KDP_SMTP_PASSWORD');
            mailRequire($username !== '' && $password !== '');
            $write('AUTH LOGIN'); $read(334); $write(base64_encode($username)); $read(334); $write(base64_encode($password)); $read(235);
        }
        $write('MAIL FROM:<' . $from . '>'); $read(250); $write('RCPT TO:<' . $recipient . '>'); $read(250); $write('DATA'); $read(354);
        $subject = '=?UTF-8?B?' . base64_encode('Your ' . $name . ' license') . '?=';
        $message = 'From: ' . $from . "\r\nTo: " . $recipient . "\r\nSubject: " . $subject . "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
            . 'Thank you for your purchase of ' . $name . ".\r\n\r\nYour software license key:\r\n" . $key
            . "\r\n\r\nKeep this key private. Use the purchase email when the application asks for it.\r\nFor help, reply to this email.\r\n";
        $message = preg_replace('/(?m)^\./', '..', $message);
        $write($message . '.'); $read(250); // This is the SMTP acceptance boundary, not inbox/read proof.
        // QUIT failure after explicit DATA250 must not change a confirmed send into a resend.
        @fwrite($stream, "QUIT\r\n");
    } finally { fclose($stream); }
}

function deliverLicenseOutbox(): array
{
    umask(0077);
    $directory = (string) getenv('KDP_STORE_STATE'); $fulfillment = (string) getenv('KDP_FULFILLMENT_STATE');
    foreach ([$directory, $fulfillment] as $path) { mailRequire(str_starts_with($path, '/') && is_dir($path) && !is_link($path) && (fileperms($path) & 0077) === 0); }
    $key = base64_decode((string) getenv('KDP_FULFILLMENT_OUTBOX_KEY'), true); mailRequire(is_string($key) && strlen($key) === 32);
    $lockPath = $directory . '/mail.lock'; mailRequire(!is_link($lockPath));
    $lock = fopen($lockPath, 'c'); mailRequire($lock !== false && flock($lock, LOCK_EX | LOCK_NB));
    try {
        $database = $directory . '/mail.sqlite'; mailRequire(!is_link($database));
        $mail = new PDO('sqlite:' . $database, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); chmod($database, 0600);
        $mail->exec('PRAGMA busy_timeout=3000');
        $mail->exec('CREATE TABLE IF NOT EXISTS delivery(order_key TEXT PRIMARY KEY,cipher_hash TEXT NOT NULL,state TEXT NOT NULL)');
        $source = new PDO('sqlite:' . $fulfillment . '/fulfillment.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
        $binding = $source->prepare('SELECT outbox_key_hash FROM binding WHERE id=1'); $binding->execute();
        $bound = (string) $binding->fetchColumn(); $binding->closeCursor();
        mailRequire(hash_equals($bound, hash('sha256', $key)));
        $attach = $source->prepare('ATTACH DATABASE ? AS delivery_state'); $attach->execute([$database]);
        $query = $source->prepare('SELECT b.order_key,b.cipher,o.email FROM outbox b JOIN orders o ON o.order_key=b.order_key LEFT JOIN delivery_state.delivery d ON d.order_key=b.order_key WHERE d.order_key IS NULL ORDER BY b.created_at,b.order_key LIMIT 25'); $query->execute();
        $rows = $query->fetchAll(); $query->closeCursor();
        $sent = 0; $review = 0;
        foreach ($rows as $row) {
            $find = $mail->prepare('SELECT cipher_hash,state FROM delivery WHERE order_key=?'); $find->execute([$row['order_key']]); $existing = $find->fetch(PDO::FETCH_ASSOC); $find->closeCursor();
            $hash = hash('sha256', $row['cipher']);
            if ($existing) { mailRequire($existing['cipher_hash'] === $hash); continue; }
            $insert = $mail->prepare("INSERT INTO delivery(order_key,cipher_hash,state) VALUES(?,?,'IN_FLIGHT')"); $insert->execute([$row['order_key'], $hash]);
            try {
                $raw = base64_decode($row['cipher'], true); mailRequire(is_string($raw) && strlen($raw) >= 29);
                $plain = openssl_decrypt(substr($raw, 12, -16), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, -16), $row['order_key']);
                mailRequire(is_string($plain)); sendLicenseMail($row['email'], $plain); unset($plain);
                $update = $mail->prepare("UPDATE delivery SET state='SENT' WHERE order_key=?"); $update->execute([$row['order_key']]); $sent++;
            } catch (Throwable) {
                $update = $mail->prepare("UPDATE delivery SET state='REVIEW' WHERE order_key=?"); $update->execute([$row['order_key']]); $review++;
            }
        }
        return ['smtp_accepted' => $sent, 'review' => $review];
    } finally { flock($lock, LOCK_UN); fclose($lock); }
}

if (defined('KDP_STORE_MAIL_LIBRARY_ONLY') && KDP_STORE_MAIL_LIBRARY_ONLY === true) { return; }
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
try { echo json_encode(deliverLicenseOutbox(), JSON_THROW_ON_ERROR) . PHP_EOL; }
catch (Throwable) { fwrite(STDERR, "Mail state requires private review. No automatic resend.\n"); exit(1); }
```

## Privacy and verification

Keys are decrypted only for intended delivery using the private AES-256-GCM outbox key and order identity. Changing that key without a reviewed migration is refused. No full key, recipient or SMTP secret is printed. The message intentionally contains the buyer's secret license, so restrict mailbox access and never share email screenshots publicly.

The exact source passed a local SMTP conversation for acceptance, duplicate prevention, lost acknowledgement, wrong encryption key and more than 100 existing sent entries. That explicitly allowed local [Sandbox](/docs/v1/start/glossary#sandbox) server is not a production SMTP TLS/certificate test or a genuine inbox-delivery claim. Run your real verified-TLS sender/inbox test before [Live](/docs/v1/start/glossary#live). For authenticated manual redelivery use [saved-key export](/docs/v1/guides/manual-issuance#export-a-saved-key-for-delivery), never a second create.


## Prerequisites

Complete the [private service installation](/docs/v1/guides/store-service). Keep all files from the exact source archive together, use PHP 8.2+ with its documented extensions and start with an isolated Sandbox [product](/docs/v1/start/glossary#product)/key. A source fragment alone is not the application.


## Expected result

Only a confirmed provider purchase authorizes a licensing effect. A durable HTTP receipt, completed licensing operation and SMTP acceptance are separate outcomes. Inspect aggregate worker states and verify the resulting license with the same buyer email; no full key appears in public replies.


## Common mistakes

Do not deploy one module without its archive dependencies, place configuration under the web root, skip provider API verification or clear a durable ledger to bypass review. Optional Node/Python ingress still requires the PHP worker.


## Security notes

Use least-privilege private provider and product-scoped licensing credentials, verified HTTPS, owner-only persistent state and separate secrets per account/environment. Never trust browser payment claims. Preserve held operations after an unknown result and reconcile before another write; exactly-once behavior across systems is not guaranteed.


## Troubleshooting

For a receipt refusal check raw-byte preservation, the matching endpoint secret/account/mode, server clock where applicable and the private bootstrap layout. For REVIEW/IN_FLIGHT inspect provider state, safe private error codes and the licensing ledger before retrying. Missing permissions, recipient data or an initial mapping are not successful payments. For mail verify the separate SMTP ledger and provider delivery evidence. Do not post raw events, keys or SQLite files publicly.


## Next steps

Run the selected [provider Sandbox procedure](/docs/v1/guides/store-service#choose-your-provider), then test [private key email delivery](/docs/v1/guides/store-service/email). Review [supported recipe limitations](/docs/v1/guides/recipes) before enabling Live.


## Related

- [Generic store trust boundary](/docs/v1/guides/any-store)
- [API idempotency](/docs/v1/api/idempotency)
- [Private service setup](/docs/v1/guides/store-service)
- [Manual key delivery](/docs/v1/guides/manual-issuance)
