HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Model/SendingServer.php
<?php

/**
 * SendingServer class.
 *
 * An abstract class for different types of sending servers
 *
 * LICENSE: This product includes software developed at
 * the Acelle Co., Ltd. (http://acellemail.com/).
 *
 * @category   MVC Model
 *
 * @author     N. Pham <[email protected]>
 * @author     L. Pham <[email protected]>
 * @copyright  Acelle Co., Ltd
 * @license    Acelle Co., Ltd
 *
 * @version    1.0
 *
 * @link       http://acellemail.com
 */

namespace App\Model;

use Illuminate\Database\Eloquent\Model;
use App\SendingServers\Identities\IdentityKind;
use App\SendingServers\Identities\IdentityStatus;
use Carbon\Carbon;
use App\Library\StringHelper;
use App\Library\Facades\Hook;
use App\Library\RateLimit;
use App\Library\RateTracker;
use App\Library\InMemoryRateTracker;
use App\Library\DynamicRateTracker;
use Exception;
use App\Library\Traits\HasUid;
use App\Library\Traits\HasModelCacheIdentity;
use App\Library\Cache\Cacheable;
use App\SendingServers\DriverRegistry;
use App\SendingServers\Drivers\SendingDriver;
use App\SendingServers\Drivers\SendResult;
use App\SendingServers\Drivers\DeliveryStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class SendingServer extends Model implements Cacheable
{
    use HasFactory;
    use HasUid;
    use HasModelCacheIdentity;

    protected static function newFactory()
    {
        return \Database\Factories\SendingServerFactory::new();
    }


    public const DELIVERY_STATUS_SENT = 'sent';
    public const DELIVERY_STATUS_FAILED = 'failed';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_INACTIVE = 'inactive';

    // TYPE
    public const TYPE_AMAZON_API = 'amazon-api';
    public const TYPE_AMAZON_SMTP = 'amazon-smtp';
    public const TYPE_SENDGRID_API = 'sendgrid-api';
    public const TYPE_SENDGRID_SMTP = 'sendgrid-smtp';
    public const TYPE_MAILGUN_API = 'mailgun-api';
    public const TYPE_MAILGUN_SMTP = 'mailgun-smtp';
    public const TYPE_ELASTICEMAIL_API = 'elasticemail-api';
    public const TYPE_ELASTICEMAIL_SMTP = 'elasticemail-smtp';
    public const TYPE_SPARKPOST_API = 'sparkpost-api';
    public const TYPE_SPARKPOST_SMTP = 'sparkpost-smtp';
    public const TYPE_SENDMAIL = 'sendmail';
    public const TYPE_SMTP = 'smtp';

    public const TYPE_BLASTENGINE_API = 'blastengine-api';
    public const TYPE_BLASTENGINE_SMTP = 'blastengine-smtp';

    public const TYPE_GMAIL_OAUTH = 'gmail-oauth';

    protected $quotaTracker;

    /**
     * Memoized driver instance (Wave F). Resolved lazily through
     * DriverRegistry on first access; reset whenever the model is re-hydrated
     * from the database (newFromBuilder builds a fresh instance).
     */
    private ?SendingDriver $driverInstance = null;

    /**
     * Wave F accessor — composes the typed driver (plain PHP, no Eloquent
     * extends) for this server row. Used at every cross-boundary call site
     * that needs vendor-specific behavior:
     *
     *   $server->driver()->send($message);
     *   $server->driver() instanceof ReceivesWebhooks;
     *
     * Throws UnknownSendingServerType if `$this->type` has no driver
     * registered (loud-fail per §5 Non-Negotiables — never silent fallback).
     */
    public function driver(): SendingDriver
    {
        return $this->driverInstance ??= DriverRegistry::resolve($this);
    }

    /**
     * Wave F: legacy vendor-specific typed columns moved to JSON `config`.
     * Forms mass-assign vendor keys via `setConfigMany($keys)`.
     */
    protected $fillable = [
        'name', 'type', 'config', 'quota_value', 'quota_base', 'quota_unit',
        'bounce_handler_id', 'feedback_loop_handler_id', 'status', 'default_from_email',
    ];

    protected $casts = [
        'config' => 'array',
    ];

    /**
     * Read a vendor config value from the JSON `config` column.
     *
     * PARITY: preserves empty-string vs null distinction — once a key has
     * been written, return verbatim even if empty.
     */
    public function getConfig(string $key, $default = null)
    {
        $config = $this->config ?? [];
        if (array_key_exists($key, $config)) {
            return $config[$key];
        }
        return $default;
    }

    public function setConfig(string $key, $value): void
    {
        $config = $this->config ?? [];
        $config[$key] = $value;
        $this->config = $config;
    }

    public function setConfigMany(array $kv): void
    {
        foreach ($kv as $k => $v) {
            $this->setConfig($k, $v);
        }
    }

    /**
     * Built-in vendor-config keys. Plugins extend this set via the
     * `register_vendor_config_keys` hook — see getVendorConfigKeys().
     *
     * Keep as `const` so static reflection (tests, IDE autocomplete) still
     * sees the canonical built-in list without needing the framework booted.
     */
    public const VENDOR_CONFIG_KEYS = [
        'api_key', 'api_secret_key', 'host', 'domain', 'username',
        'smtp_username', 'smtp_password', 'smtp_port', 'smtp_protocol',
        'local_domain',
        'aws_access_key_id', 'aws_secret_access_key', 'aws_region',
        'sendmail_path',
        'oauth_access_token', 'oauth_refresh_token', 'oauth_token_expires_at',
    ];

    /**
     * Effective vendor-config key set = built-in const + plugin contributions.
     *
     * Two paths plugins can use:
     *
     *   (preferred) merged driver hook — single source of truth:
     *     Hook::add('register_sending_server_driver', fn() => [
     *         'type'        => 'myvendor-api',
     *         'driver'      => MyVendorDriver::class,
     *         'config_keys' => ['my_api_key', 'my_region'],
     *         // 'name', 'description', 'icon_url' for picker page (optional)
     *     ]);
     *
     *   direct hook (still supported for explicit registrations):
     *     Hook::add('register_vendor_config_keys', fn() => ['my_api_key']);
     *
     * Used by fill() / applyConfigFromInput / getAttribute so a plugin-
     * registered key flows transparently through the same form → JSON config
     * pipeline as built-in keys.
     *
     * @return list<string>
     */
    public static function getVendorConfigKeys(): array
    {
        $keys = self::VENDOR_CONFIG_KEYS;

        if (class_exists(\App\Library\Facades\Hook::class)) {
            // Path 1: merged driver hook payload's `config_keys` field.
            foreach (\App\Library\Facades\Hook::collect('register_sending_server_driver') as $meta) {
                if (!is_array($meta)) continue;
                $contributed = $meta['config_keys'] ?? [];
                if (is_string($contributed) && $contributed !== '') {
                    $keys[] = $contributed;
                } elseif (is_array($contributed)) {
                    foreach ($contributed as $k) {
                        if (is_string($k) && $k !== '') {
                            $keys[] = $k;
                        }
                    }
                }
            }

            // Path 2: direct hook (legacy / explicit).
            foreach (\App\Library\Facades\Hook::collect('register_vendor_config_keys') as $contribution) {
                if (is_string($contribution) && $contribution !== '') {
                    $keys[] = $contribution;
                } elseif (is_array($contribution)) {
                    foreach ($contribution as $k) {
                        if (is_string($k) && $k !== '') {
                            $keys[] = $k;
                        }
                    }
                }
            }
        }

        return array_values(array_unique($keys));
    }

    /**
     * Pull every recognised vendor-config key out of $input (e.g.
     * `$request->all()`) and write to the JSON config column.
     *
     * @param  array<string, mixed>  $input
     */
    public function applyConfigFromInput(array $input): void
    {
        foreach (self::getVendorConfigKeys() as $key) {
            if (array_key_exists($key, $input)) {
                $this->setConfig($key, $input[$key]);
            }
        }
    }

    /**
     * Override Eloquent `fill()` so that controllers can keep using
     * `$server->fill($request->all())` — vendor-config keys are routed
     * into the JSON config column transparently. Non-vendor keys still
     * flow through the standard `$fillable` filter.
     *
     * @param  array<string, mixed>  $attributes
     * @return $this
     */
    public function fill(array $attributes)
    {
        $this->applyConfigFromInput($attributes);
        // Strip vendor keys before parent::fill so any subsequent code path
        // (e.g. forceFill via firstOrCreate) won't try to write them as
        // typed columns — those columns no longer exist.
        foreach (self::getVendorConfigKeys() as $key) {
            unset($attributes[$key]);
        }
        return parent::fill($attributes);
    }

    /**
     * Wave F: legacy code reads vendor credentials via `$server->api_key`
     * etc. After dropping the typed columns, those reads become nulls
     * unless we route through the JSON config. Override Eloquent's getter
     * to fall back to config for known vendor keys.
     *
     * Form blades, legacy widgets, and anything stamped with attribute-
     * style access keep working without per-call-site rewrites.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        $value = parent::getAttribute($key);
        if ($value === null && in_array($key, self::getVendorConfigKeys(), true)) {
            return $this->getConfig($key);
        }
        return $value;
    }

    /**
     * Wave F: STI subclass tree deleted; Eloquent returns plain SendingServer
     * for every row. Vendor behavior dispatched via `$this->driver()`.
     */
    public static function newOfType(string $type, array $attrs = []): self
    {
        $instance = new self();
        $instance->fill(['type' => $type] + $attrs);
        return $instance;
    }

    public function trackingLogs()
    {
        return $this->hasMany('App\Model\TrackingLog', 'sending_server_id')->orderBy('created_at', 'asc');
    }

    public function plans()
    {
        return $this->belongsToMany('App\Model\Plan', 'plans_sending_servers');
    }

    public function plansSendingServers()
    {
        return $this->hasMany('App\Model\PlansSendingServer', 'sending_server_id');
    }

    /**
     * Get the bounce handler.
     */
    public function bounceHandler()
    {
        return $this->belongsTo('App\Model\BounceHandler');
    }

    public function identities()
    {
        return $this->hasMany('App\Model\SendingIdentity', 'sending_server_id');
    }

    public function warmupStrategy()
    {
        return $this->belongsTo(WarmupStrategy::class);
    }

    public function warmupUsages()
    {
        return $this->hasMany(SendingServerWarmupUsage::class);
    }

    public function warmupLogs()
    {
        return $this->hasMany(SendingServerWarmupLog::class);
    }

    public function isWarmupEnabled(): bool
    {
        return (bool) $this->warmup_enabled
            && !is_null($this->warmup_strategy_id)
            && !is_null($this->warmup_started_at);
    }

    public function senders()
    {
        return $this->hasMany('App\Model\Sender', 'sending_server_id');
    }

    /**
     * Get all items.
     *
     * @return collect
     */
    public function getVerp($recipient)
    {
        if ($this->bounceHandler) {
            $validator = \Validator::make(
                ['email' => $this->bounceHandler->username],
                ['email' => 'required|email']
            );

            if ($validator->passes()) {
                // @todo disable VERP as it is not supported by all mailbox
                // return str_replace('@', '+'.str_replace('@', '=', $recipient).'@', $this->bounceHandler->username);
                return $this->bounceHandler->username;
            } else {
                // @todo raise an error here, hold off the entire campaign
                return $this->bounceHandler->email;
            }
        }

        return;
    }

    /**
     * Associations.
     *
     * @var object | collect
     */
    public function customer()
    {
        return $this->belongsTo('App\Model\Customer');
    }

    public function admin()
    {
        return $this->belongsTo('App\Model\Admin');
    }

    /**
     * Filter items.
     *
     * @return collect
     */
    public static function scopeFilter($query, $request)
    {
        $query = $query->select('sending_servers.*');

        // filters
        $filters = $request->all();
        if (!empty($filters)) {
            if (!empty($filters['type'])) {
                $query = $query->where('sending_servers.type', '=', $filters['type']);
            }
        }

        // Other filter
        if (!empty($request->customer_id)) {
            $query = $query->where('sending_servers.customer_id', '=', $request->customer_id);
        }

        if (!empty($request->admin_id)) {
            $query = $query->where('sending_servers.admin_id', '=', $request->admin_id);
        }
    }

    /**
     * Search items.
     *
     * @return collect
     */
    public static function scopeSearch($query, $keyword)
    {
        // Keyword
        if (!empty(trim($keyword))) {
            foreach (explode(' ', trim($keyword)) as $keyword) {
                $query = $query->where(function ($q) use ($keyword) {
                    $q->orwhere('sending_servers.name', 'like', '%'.$keyword.'%')
                        ->orWhere('sending_servers.type', 'like', '%'.$keyword.'%')
                        ->orWhere('sending_servers.host', 'like', '%'.$keyword.'%');
                });
            }
        }
    }

    /**
     * Items per page.
     *
     * @var array
     */
    public static $itemsPerPage = 25;

    /**
     * Get select options.
     *
     * @return array
     */
    public static function getSelectOptions()
    {
        $query = self::active();
        $options = $query->orderBy('name')->get()->map(function ($item) {
            return ['value' => $item->uid, 'text' => $item->name];
        });

        return $options->values()->all();
    }

    /**
     * Get sparkpost select options.
     *
     * @return array
     */
    public static function getSparkpostHostnameSelectOptions()
    {
        $options = [
            ['text' => trans('messages.choose'), 'value' => ''],
            ['text' => 'SparkPost Global', 'value' => 'api.sparkpost.com'],
            ['text' => 'SparkPost EU', 'value' => 'api.eu.sparkpost.com'],
        ];

        return $options;
    }

    /**
     * Static rule lookup by type slug — convenience wrapper that builds an
     * in-memory SendingServer to delegate through the driver.
     *
     * @return array<string,mixed>
     */
    public static function rules(string $type): array
    {
        $rules = self::resolveDriverRules($type, 'cols');
        $rules['quota_value'] = 'required|numeric';
        $rules['quota_base']  = 'required|numeric';
        $rules['quota_unit']  = 'required';
        return $rules;
    }

    /**
     * @return array<string,mixed>
     */
    public static function frontendRules(string $type): array
    {
        // Frontend single-page form merges connection 'cols' + settings.
        $cols     = self::resolveDriverRules($type, 'cols');
        $settings = self::resolveDriverRules($type, 'settings');
        $rules    = $settings + $cols;
        $rules['quota_value'] = 'required|numeric';
        $rules['quota_base']  = 'required|numeric';
        $rules['quota_unit']  = 'required';

        return $rules;
    }

    /**
     * Connection-form rules — single source of truth is the driver's
     * validationRules() method.
     *
     * @return array<string,mixed>
     */
    public function getRules(): array
    {
        $rules = $this->driver()->validationRules();
        return $rules['cols'] ?? [];
    }

    /**
     * Frontend single-page form rules — falls back to merged
     * settings + cols when no explicit `frontend_cols` declared.
     *
     * @return array<string,mixed>
     */
    public function getFrontendRules(): array
    {
        $rules = $this->driver()->validationRules();
        if (isset($rules['frontend_cols'])) {
            return $rules['frontend_cols'];
        }
        return ($rules['settings'] ?? []) + ($rules['cols'] ?? []);
    }

    /**
     * Helper for static `rules()` / `frontendRules()` — builds an in-memory
     * SendingServer instance to dispatch through the driver. Returns the
     * requested rule bucket or [] if unknown type / missing bucket.
     *
     * @return array<string,mixed>
     */
    private static function resolveDriverRules(string $type, string $bucket): array
    {
        if (!\App\SendingServers\DriverRegistry::has($type)) {
            return [];
        }
        $stub = new self(['type' => $type]);
        return $stub->driver()->validationRules()[$bucket] ?? [];
    }

    public function getCustomValidationError()
    {
        return [];
    }

    /**
     * Test connection.
     *
     * @return object
     */
    public function validConnection($params)
    {
        $validator = \Validator::make($params, $this->getRules(), $this->getCustomValidationError());

        // Wave F: delegate live-connection probe to the driver. Returns
        // TestResult — failure surfaces on the `connection` validator field,
        // shown next to the form fields that produced the bad credentials.
        $validator->after(function ($validator) {
            $result = $this->driver()->test();
            if (!$result->ok) {
                $validator->errors()->add('connection', $result->error ?? 'Connection test failed');
            }
        });

        return $validator;
    }

    /**
     * Settings-form rules. Driver returns its `settings` bucket.
     *
     * @return array<string,mixed>
     */
    public function getConfigRules(): array
    {
        $rules = $this->driver()->validationRules();
        return $rules['settings'] ?? [];
    }

    /**
     * Quota display.
     *
     * @return string
     */
    public function displayQuota()
    {
        if ($this->quota_value == -1) {
            return trans('messages.unlimited');
        }

        return $this->quota_value.'/'.$this->quota_base.' '.trans('messages.'.\App\Library\Tool::getPluralPrase($this->quota_unit, $this->quota_base));
    }

    /**
     * Quota display.
     *
     * @return string
     */
    public function displayQuotaHtml()
    {
        if ($this->quota_value == -1) {
            return trans('messages.unlimited');
        }

        return '<b>'.$this->quota_value.'</b>/<b>'.$this->quota_base.' '.trans('messages.'.\App\Library\Tool::getPluralPrase($this->quota_unit, $this->quota_base)).'</b>';
    }

    /**
     * Select options for aws region.
     *
     * @return array
     */
    public static function awsRegionSelectOptions()
    {
        return [
            ['value' => '', 'text' => trans('messages.choose')],
            ['value' => 'us-east-1', 'text' => 'US East (N. Virginia)', 'host' => 'email-smtp.us-east-1.amazonaws.com'],
            ['value' => 'us-east-2', 'text' => 'US East (Ohio)', 'host' => 'email-smtp.us-east-2.amazonaws.com'],
            ['value' => 'us-west-1', 'text' => 'US West (N. California)', 'host' => 'email-smtp.us-west-1.amazonaws.com'],
            ['value' => 'us-west-2', 'text' => 'US West (Oregon)', 'host' => 'email-smtp.us-west-2.amazonaws.com'],
            ['value' => 'ap-south-1', 'text' => 'Asia Pacific (Mumbai)', 'host' => 'email-smtp.ap-south-1.amazonaws.com'],
            ['value' => 'ap-southeast-1', 'text' => 'Asia Pacific (Singapore)', 'host' => 'email-smtp.ap-southeast-1.amazonaws.com'],
            ['value' => 'ap-southeast-2', 'text' => 'Asia Pacific (Sydney)', 'host' => 'email-smtp.ap-southeast-2.amazonaws.com'],
            ['value' => 'ap-northeast-3', 'text' => 'Asia Pacific (Osaka)', 'host' => 'email-smtp.ap-northeast-3.amazonaws.com'],
            ['value' => 'ap-northeast-1', 'text' => 'Asia Pacific (Tokyo)', 'host' => 'email-smtp.ap-northeast-1.amazonaws.com'],
            ['value' => 'ap-northeast-2', 'text' => 'Asia Pacific (Seoul)', 'host' => 'email-smtp.ap-northeast-2.amazonaws.com'],
            ['value' => 'ca-central-1', 'text' => 'Canada (Central)', 'host' => 'email-smtp.ca-central-1.amazonaws.com'],
            ['value' => 'eu-central-1', 'text' => 'Europe (Frankfurt)', 'host' => 'email-smtp.eu-central-1.amazonaws.com'],
            ['value' => 'eu-west-1', 'text' => 'EU (Ireland)', 'host' => 'email-smtp.eu-west-1.amazonaws.com'],
            ['value' => 'eu-west-2', 'text' => 'Europe (London)', 'host' => 'email-smtp.eu-west-2.amazonaws.com'],
            ['value' => 'eu-west-3', 'text' => 'Europe (Paris)', 'host' => 'email-smtp.eu-west-3.amazonaws.com'],
            ['value' => 'eu-south-1', 'text' => 'Europe (Milan)', 'host' => 'email-smtp.eu-south-1.amazonaws.com'],
            ['value' => 'eu-north-1', 'text' => 'Europe (Stockholm)', 'host' => 'email-smtp.eu-north-1.amazonaws.com'],
            ['value' => 'me-south-1', 'text' => 'Middle East (Bahrain)', 'host' => 'email-smtp.me-south-1.amazonaws.com'],
            ['value' => 'sa-east-1', 'text' => 'South America (São Paulo)', 'host' => 'email-smtp.sa-east-1.amazonaws.com'],
        ];
    }

    /**
     * Select options for aws region.
     *
     * @return array
     */
    public static function mailgunRegionSelectOptions()
    {
        return [
            ['value' => '', 'text' => trans('messages.choose')],
            ['value' => 'https://api.mailgun.net', 'text' => 'US/Global Server'],
            ['value' => 'https://api.eu.mailgun.net', 'text' => 'EU Server'],
        ];
    }

    /**
     * Disable sending server.
     *
     * @return array
     */
    public function disable()
    {
        $this->status = 'inactive';
        $this->save();
    }

    /**
     * Enable sending server.
     *
     * @return array
     */
    public function enable()
    {
        $this->status = 'active';
        $this->save();
    }

    /**
     * Get all active items.
     *
     * @return collect
     */
    public function scopeActive($query)
    {
        return $query->where('status', '=', self::STATUS_ACTIVE);
    }

    /**
     * Get all active system items.
     *
     * @return collect
     */
    public function scopeSystem($query)
    {
        return $query->whereNull('customer_id');
    }


    /**
     * Send a test email for the sending server.
     */
    public function sendTestEmail($message)
    {
        $fromEmail = array_keys($message->getFrom())[0];
        $this->driver()->setupBeforeSend($fromEmail);
        $this->send($message);

        return true;
    }

    /**
     * Check if the sending server is ElasticEmailAPI or ElasticEmailSmtp.
     *
     * @return bool
     */
    public function isElasticEmailServer()
    {
        return $this->type == 'elasticemail-api' || $this->type == 'elasticemail-smtp';
    }

    /**
     * Get sending server select2 select options.
     *
     * @return array
     */
    public static function select2($request)
    {
        $data = ['items' => [], 'more' => true];

        $query = self::active();
        if (isset($request->q)) {
            $keyword = $request->q;
            $query = $query->where(function ($q) use ($keyword) {
                $q->orwhere('sending_servers.name', 'like', '%'.$keyword.'%');
            });
        }

        // plan
        if ($request->plan_uid) {
            $plan = \App\Model\Plan::findByUid($request->plan_uid);
            $existIds = $plan->plansSendingServers()->pluck('sending_server_id')->toArray();
        }

        foreach ($query->limit(20)->get() as $server) {
            if ($request->plan_uid && in_array($server->id, $existIds)) {
                $data['items'][] = [
                    'id' => $server->uid,
                    'text' => $server->name.' ('.trans('messages.sending_server.added').')'.'|||'.trans('messages.sending_server.type.'.$server->type),
                    'disabled' => true,
                ];
            } else {
                $data['items'][] = ['id' => $server->uid, 'text' => $server->name.'|||'.trans('messages.sending_server.type.'.$server->type)];
            }
        }

        return json_encode($data);
    }

    /**
     * Get sending server select2 select options.
     *
     * @return array
     */
    public static function adminSelect2($request)
    {
        $data = ['items' => [], 'more' => true];

        $query = self::active()->whereNull('customer_id');
        if (isset($request->q)) {
            $keyword = $request->q;
            $query = $query->where(function ($q) use ($keyword) {
                $q->orwhere('sending_servers.name', 'like', '%'.$keyword.'%');
            });
        }

        // plan
        if ($request->plan_uid) {
            $plan = \App\Model\Plan::findByUid($request->plan_uid);
            $existIds = $plan->plansSendingServers()->pluck('sending_server_id')->toArray();
        }

        foreach ($query->limit(20)->get() as $server) {
            if ($request->plan_uid && in_array($server->id, $existIds)) {
                $data['items'][] = [
                    'id' => $server->uid,
                    'text' => $server->name.' ('.trans('messages.sending_server.added').')'.'|||'.trans('messages.sending_server.type.'.$server->type),
                    'disabled' => true,
                ];
            } else {
                $type = $server->isExtended() ? $server->getTypeName() : trans('messages.sending_server.type.'.$server->type);
                $data['items'][] = ['id' => $server->uid, 'text' => $server->name.'|||'.$type ];
            }
        }

        return json_encode($data);
    }

    /**
     * Get sending limit types.
     *
     * @return array
     */
    public static function sendingLimitValues()
    {
        return [
            'unlimited' => [
                'quota_value' => -1,
                'quota_base' => -1,
                'quota_unit' => 'day',
            ],
            '100_per_minute' => [
                'quota_value' => 100,
                'quota_base' => 1,
                'quota_unit' => 'minute',
            ],
            '1000_per_hour' => [
                'quota_value' => 1000,
                'quota_base' => 1,
                'quota_unit' => 'hour',
            ],
            '10000_per_day' => [
                'quota_value' => 10000,
                'quota_base' => 1,
                'quota_unit' => 'day',
            ],
        ];
    }

    /**
     * Get sending limit select options.
     *
     * @return array
     */
    public function getSendingLimitSelectOptions()
    {
        $options = [];
        $current = trans('messages.sending_servers.sending_limit.phrase', [
            'quota_value' => number_with_delimiter($this->quota_value, $precision = 0),
            'quota_base' => number_with_delimiter($this->quota_base, $precision = 0),
            'quota_unit' => $this->quota_unit,
        ]);
        if ($this->quota_value == -1) {
            $current = trans('messages.sending_server.quota.unlimited');
        }

        $exist = false;
        foreach (self::sendingLimitValues() as $key => $data) {
            $wording = trans('messages.sending_servers.sending_limit.phrase', [
                'quota_value' => number_with_delimiter($data['quota_value'], $precision = 0),
                'quota_base' => number_with_delimiter($data['quota_base'], $precision = 0),
                'quota_unit' => $data['quota_unit'],
            ]);

            if ($data['quota_value'] == -1) {
                $wording = trans('messages.sending_server.quota.unlimited');
            }

            $options[] = ['text' => $wording, 'value' => $key];

            if ($wording == $current) {
                $exist = true;
                $this->setOption('sending_limit', $key);
            }
        }

        // exist
        if (!$exist) {
            $options[] = ['text' => $current, 'value' => 'current'];
            $this->setOption('sending_limit', 'current');
        }

        // Custom
        $options[] = ['text' => trans('messages.sending_servers.quota.custom'), 'value' => 'custom'];

        return $options;
    }

    /**
     * Default options.
     *
     * @return array
     */
    public static function defaultOptions()
    {
        return [
            'domains' => [],
            'emails' => [],
            'allow_unverified_from_email' => 'no',
            'allow_verify_domain_remotely' => 'no',
            'allow_verify_email_remotely' => 'no',
        ];
    }

    /**
     * Get options.
     *
     * @return array
     */
    public function getOptions()
    {
        $savedOptions = isset($this->options) ? json_decode($this->options, true) : [];

        return array_merge(self::defaultOptions(), $savedOptions);
    }

    /**
     * Get option.
     *
     * @return array
     */
    public function getOption($name)
    {
        $options = $this->getOptions();

        $value = isset($options[$name]) ? $options[$name] : null;

        // default value
        if (!$value) {
            // default verification email
            if ($name == 'custom_verification_email') {
                $value = trans('messages.sending_server.default_email_verification.content');
                ;
            }

            // default verification email
            if ($name == 'custom_verification_email_subject') {
                $value = trans('messages.sending_server.default_email_verification.subject');
                ;
            }
        }


        return $value;
    }

    /**
     * Get options.
     *
     * @return array
     */
    public function setOptions($options)
    {
        $savingOptions = $this->getOptions();
        foreach ($options as $key => $option) {
            $savingOptions[$key] = $option;
        }

        $this->options = json_encode($savingOptions);
    }

    /**
     * Get options.
     *
     * @return array
     */
    public function setOption($name, $value)
    {
        if (!isset($this->options)) {
            $options = [];
        } else {
            $options = json_decode($this->options, true);
        }

        $options[$name] = $value;

        $this->options = json_encode($options);
    }

    /**
     * Allow user send from unverified FROM email address.
     *
     * @return bool
     */
    public function allowUnverifiedFromEmailAddress()
    {
        $options = json_decode($this->options, true);

        if (is_null($options)) {
            return false;
        }

        return array_key_exists('allow_unverified_from_email', $options) && $options['allow_unverified_from_email'] == 'yes';
    }

    /**
     * Bulk-toggle which identities on this server are "Available for all" —
     * the `shared` flag that admin-owned identities need so customers see
     * them in their from-address droplist (per `SendingIdentity::scopeVisibleTo`
     * branch 3 + design doc § 9). Any value present in the admin Sender
     * Identity form payload flips to `shared=true`; the rest flip to
     * `shared=false`.
     *
     * @param  array{emails?: list<string>, domains?: list<string>}  $selected
     */
    public function bulkUpdateShared(array $selected): void
    {
        $values = array_merge(
            $selected['emails']  ?? [],
            $selected['domains'] ?? [],
        );

        $this->identities()->update(['shared' => false]);
        if (!empty($values)) {
            $this->identities()->whereIn('value', $values)->update(['shared' => true]);
        }
    }

    // Identity-list helpers removed — moved to IdentityCatalog service:
    //   getSharedVerifiedIdentityValues() → IdentityCatalog::valuesForServer($server)
    //   verifiedIdentitiesDroplist($k)    → IdentityCatalog::droplistForServer($server, $k)
    //   buildDroplistFromValues($v, $k)   → IdentityCatalog::formatDroplist($v, $k)

    /**
     * Delete sending server.
     */
    public function doDelete()
    {
        $plans = $this->plans;

        // delete
        $this->delete();

        // check all plans status
        foreach ($plans as $plan) {
            $plan->checkStatus();
        }
    }

    public function sendWithDefaultFromAddress(\Symfony\Component\Mime\Email $email, $params = []): SendResult
    {
        if (!empty($this->from_name)) {
            $email->from(new \Symfony\Component\Mime\Address($this->from_address, $this->from_name));
        } else {
            $email->from($this->from_address);
        }

        return $this->send($email, $params);
    }

    /**
     * Pick a default From: email address from the server's own verified
     * identities. Prefer first verified domain (prefixed `noreply@`),
     * fallback to first verified email. No-op if `default_from_email`
     * is already set.
     */
    public function setDefaultFromEmailAddress()
    {
        if (!empty($this->default_from_email)) {
            return;
        }

        $verified = $this->identities()
            ->where('enabled', true)
            ->where('status', IdentityStatus::VERIFIED->value)
            ->get(['kind', 'value']);

        $domain = $verified->firstWhere('kind', IdentityKind::DOMAIN)?->value;
        if ($domain !== null) {
            $this->default_from_email = 'noreply@' . $domain;
            $this->save();
            return;
        }

        $email = $verified->firstWhere('kind', IdentityKind::EMAIL)?->value;
        if ($email !== null) {
            $this->default_from_email = $email;
            $this->save();
        }
    }

    public function getSpfHost()
    {
        return null;
    }

    public function isExtended()
    {
        return false;
    }

    public function getIconUrl()
    {
        return null;
    }

    public function getDefaultName()
    {
        return null;
    }

    public static function createFromArray($params)
    {
        if (!isset($params['type'])) {
            throw new \InvalidArgumentException('SendingServer::createFromArray requires a type');
        }
        $server = self::newOfType($params['type'], $params);

        // validation
        $validator = $server->validConnection($params);

        if ($validator->fails()) {
            return [$validator, $server]; // IMPORTANT, $server instance (not saved) is required by parent controller
        }

        if (isset($params['admin_id'])) {
            $server->admin_id = $params['admin_id'];
        }
        if (isset($params['customer_id'])) {
            $server->customer_id = $params['customer_id'];
        }

        $server->status = self::STATUS_ACTIVE;

        // default name
        if (!$server->name) {
            $server->name = $server->getDefaultName() ?: trans('messages.sending_server.type.' . $server->type);
        }

        // default sever quota
        if (!$server->quota_value) {
            $server->quota_value = 1000;
            $server->quota_base = 1;
            $server->quota_unit = 'hour';
            $options = ['sending_limit' => '1000_per_hour'];
            $server->options = json_encode($options);
        }

        // bounce / feedback hanlder nullable
        if (!isset($params['bounce_handler_id'])) {
            $server->bounce_handler_id = null;
        }
        if (!isset($params['feedback_loop_handler_id'])) {
            $server->feedback_loop_handler_id = null;
        }

        // default options
        if ($server->type == self::TYPE_SENDGRID_API || $server->type == self::TYPE_SENDGRID_SMTP) {
            $server->setOption('allow_verify_domain_against_acelle', 'yes');
        }

        if ($server->type == self::TYPE_AMAZON_API || $server->type == self::TYPE_AMAZON_SMTP) {
            $server->setOption('allow_verify_domain_remotely', 'yes');
        }

        if ($server->type == self::TYPE_SENDMAIL || $server->type == self::TYPE_SMTP) {
            $server->setOption('allow_unverified_from_email', 'yes');
            $server->setOption('allow_verify_domain_against_acelle', 'yes');
        }

        // save
        $server->save();

        return [ $validator, $server ];
    }

    public function getTypeName()
    {
        return self::resolveTypeLabel($this->type);
    }

    /**
     * Display label for a sending-server type slug.
     *
     * Resolves in this order:
     *   1. Lang key — checks both legacy `messages.sending_server.type.<type>`
     *      and modern `refactor/sending.servers.type.<type>` namespaces. Built-in
     *      drivers ship these, preserving "(API)" / "(SMTP)" suffix that
     *      distinguishes vendor variants.
     *   2. `name` field from the plugin's `register_sending_server_driver` hook
     *      payload — plugins can't write to main app's lang file, so they push
     *      their display name through the hook.
     *   3. Driver's getServiceName() as a last-resort fallback.
     *   4. Raw type slug if even the driver isn't resolvable (unknown type).
     *
     * Use from any view rendering a server type — instance method getTypeName()
     * delegates here, and views iterating over types pass the slug directly.
     */
    public static function resolveTypeLabel(string $type): string
    {
        foreach ([
            'messages.sending_server.type.' . $type,
            'refactor/sending.servers.type.' . $type,
        ] as $key) {
            $label = trans($key);
            if ($label !== $key) {
                return $label;
            }
        }

        foreach (\App\Library\Facades\Hook::collect('register_sending_server_driver') as $meta) {
            if (is_array($meta) && ($meta['type'] ?? null) === $type && !empty($meta['name'])) {
                return (string) $meta['name'];
            }
        }

        try {
            return (new self(['type' => $type]))->driver()->getServiceName();
        } catch (\Throwable $e) {
            return $type;   // unknown type — surface the raw slug rather than the lang key
        }
    }

    public function setQuotaSettings(int $value, string $periodUnit, int $periodBase)
    {
        $this->quota_base = $periodBase;
        $this->quota_unit = $periodUnit;
        $this->quota_value = $value;
    }

    public function getRateLimits()
    {
        $limits = [];

        if ($this->quota_value != RateLimit::UNLIMITED) {
            $limits[] = new RateLimit(
                $this->quota_value,
                $this->quota_base,
                $this->quota_unit,
                "Server's sending limit of {$this->quota_value} per {$this->quota_base} {$this->quota_unit}"
            );
        }

        return $limits;
    }

    public function getRateLimitTracker()
    {
        if (config('custom.distributed_mode')) {
            $key = "server-send-email-rate-tracking-log-{$this->uid}";
            $tracker = new DynamicRateTracker($key, $this->getRateLimits());
        } else {
            $file = storage_path('app/quota/server-send-email-rate-tracking-log-'.$this->uid);
            $tracker = new RateTracker($file, $this->getRateLimits());
        }

        return $tracker;
    }

    public function dryrun(\Symfony\Component\Mime\Email $message): SendResult
    {
        $toEmail = $message->getTo()[0]?->getAddress() ?? 'unknown';
        $filename = "dryrun-{$toEmail}";
        $dir = '/tmp/dryrun';
        if (!is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        $output = "{$dir}/{$filename}";
        file_put_contents($output, $message->toString(), FILE_APPEND | LOCK_EX);
        return new SendResult(runtimeMessageId: null, status: DeliveryStatus::SENT);
    }

    public function cacheManifest(): array
    {
        return [
            'SentCount' => function () {
                return $this->trackingLogs()->count();
            }
        ];
    }

    public function enableWarmupIfNeeded(): void
    {
        if (!is_null($this->warmup_strategy_id) && !$this->isWarmupEnabled()) {
            $this->warmup_enabled = true;
            $this->warmup_started_at = $this->warmup_started_at ?? now();
            $this->save();
        }
    }

    public function saveWarmupStrategy($warmupStrategy)
    {
        $this->warmup_strategy_id = $warmupStrategy?->id;
        $this->save();
    }

    /**
     * Wave F: thin shim — delegates to driver, preserves dry-run gate.
     * Caller pattern remains `$server->send($message, $params)` so all
     * existing send-pipeline code (Campaign worker, AutomationEmail, jobs)
     * keeps working without per-call-site changes.
     *
     * Type hint pinned to Symfony\Component\Mime\Email (not the parent
     * Message class). Past regression: an older Acelle version had
     * dkim_sign() return $signer->sign() output directly — that's typed
     * Mime\Message (Email's parent), which then arrived at the driver
     * `send(Email ...)` and raised TypeError deep in the pipeline. Pinning
     * the shim to Email catches the same regression here with a clear
     * call-site stack frame, before the typed driver call.
     */
    public function send(\Symfony\Component\Mime\Email $message, array $params = []): SendResult
    {
        if (config('custom.dryrun')) {
            return $this->dryrun($message);
        }
        return $this->driver()->send($message, $params);
    }
}