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);
}
}