File: /home/xedaptot/ai.naniguide.com/app/Http/Controllers/Refactor/Admin/SendingServerController.php
<?php
namespace App\Http\Controllers\Refactor\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WarmupStrategy\AssignWarmupStrategyRequest;
use App\Library\Facades\Hook;
use App\Library\StringHelper;
use App\Model\SendingIdentity;
use App\Model\SendingServer;
use App\Model\WarmupStrategy;
use App\Services\WarmupStrategyService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use function App\Helpers\dkim_sign_with_default_domain;
class SendingServerController extends Controller
{
public function __construct(protected WarmupStrategyService $warmupStrategyService)
{
}
public function index(Request $request)
{
if (!$request->user()->admin->can('read', new SendingServer())) {
return $this->notAuthorized();
}
$query = SendingServer::system();
$stats = [
'total' => (clone $query)->count(),
'active' => (clone $query)->where('sending_servers.status', 'active')->count(),
'inactive' => (clone $query)->where('sending_servers.status', 'inactive')->count(),
];
// Type distribution for chart
$typeDistribution = (clone $query)
->selectRaw('type, count(*) as count')
->groupBy('type')
->pluck('count', 'type')
->toArray();
return view('refactor.pages.admin.sending-servers.index', compact('stats', 'typeDistribution'));
}
public function listing(Request $request)
{
if (!$request->user()->admin->can('read', new SendingServer())) {
return response('Unauthorized', 403);
}
if (!$request->user()->admin->can('readAll', new SendingServer())) {
$request->merge(['admin_id' => $request->user()->admin->id]);
}
$request->merge(['no_customer' => true]);
$query = SendingServer::system()->search($request->keyword)->filter($request);
// Type filter
if ($request->type && $request->type !== 'all') {
$query = $query->where('sending_servers.type', $request->type);
}
// Status filter
if ($request->status && $request->status !== 'all') {
$query = $query->where('sending_servers.status', $request->status);
}
$sortBy = $request->sort_by ?? 'sending_servers.created_at';
$sortDir = $request->sort_direction ?? 'desc';
$query = $query->orderBy($sortBy, $sortDir);
$items = $query->paginate($request->per_page ?? 15);
return view('refactor.pages.admin.sending-servers._list', compact('items'));
}
public function select(Request $request)
{
if (!$request->user()->admin->can('create', SendingServer::class)) {
return $this->notAuthorized();
}
$more = $this->collectPluginPickerCards();
return view('refactor.pages.admin.sending-servers.select', compact('more'));
}
/**
* Build "Plugin Servers" picker cards from two sources:
* (1) merged `register_sending_server_driver` hook with picker metadata
* (name / description / icon_url) — preferred for new plugins.
* (2) legacy `register_sending_server` hook — kept for backward
* compatibility with old plugins that registered the picker card
* separately from the driver.
*
* @return list<array{name: string, description: string, icon_url: string, create_url: string}>
*/
private function collectPluginPickerCards(): array
{
$cards = [];
foreach (Hook::collect('register_sending_server_driver') as $meta) {
if (!is_array($meta) || !isset($meta['type'], $meta['driver'])) continue;
// Only show on picker if plugin shipped picker metadata.
if (!isset($meta['name'])) continue;
$cards[] = [
'name' => (string) $meta['name'],
'description' => (string) ($meta['description'] ?? ''),
'icon_url' => (string) ($meta['icon_url'] ?? ''),
'create_url' => (string) ($meta['create_url']
?? route('refactor.admin.sending-servers.create', ['type' => $meta['type']])),
];
}
foreach (Hook::collect('register_sending_server') as $legacy) {
if (!is_array($legacy)) continue;
$cards[] = [
'name' => (string) ($legacy['name'] ?? ''),
'description' => (string) ($legacy['description'] ?? ''),
'icon_url' => (string) ($legacy['icon_url'] ?? ''),
'create_url' => (string) ($legacy['create_url'] ?? '#'),
];
}
return $cards;
}
public function create(Request $request)
{
if (!$request->user()->admin->can('create', SendingServer::class)) {
return $this->notAuthorized();
}
$server = SendingServer::newOfType($request->type);
$server->status = 'active';
$server->uid = '0';
$server->quota_value = '1000';
$server->quota_base = '1';
$server->quota_unit = 'hour';
$server->fill($request->old());
$server->name = SendingServer::resolveTypeLabel($request->type);
return view('refactor.pages.admin.sending-servers.create', [
'server' => $server,
]);
}
public function store(Request $request)
{
if (!$request->user()->admin->can('create', SendingServer::class)) {
return $this->notAuthorized();
}
list($validator, $server) = SendingServer::createFromArray(array_merge($request->all(), [
'admin_id' => $request->user()->admin->id,
]));
if ($validator->fails()) {
if ($server->isExtended()) {
return redirect()->back()->withErrors($validator)->withInput();
}
return redirect()->route('refactor.admin.sending-servers.create', $server->type)
->withErrors($validator)->withInput();
}
$request->session()->flash('alert-success', trans('refactor/admin_sending_servers.flash.created'));
if ($server->isExtended()) {
return redirect($server->getEditUrl());
}
return redirect()->route('refactor.admin.sending-servers.edit', $server->uid);
}
public function edit(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
dispatch(new \App\Jobs\UpdateSendingServerJob($server));
if ($request->old() && empty($request->old()['bounce_handler_id'])) {
$server->bounce_handler_id = null;
}
if ($request->old() && empty($request->old()['feedback_loop_handler_id'])) {
$server->feedback_loop_handler_id = null;
}
$server->fill($request->old());
$notices = [];
try {
$driver = $server->driver();
$testResult = $driver->test();
if (!$testResult->ok) {
throw new \RuntimeException($testResult->error ?? 'Unknown test failure');
}
if ($driver instanceof \App\SendingServers\Capabilities\SupportsIdentitySync) {
app(\App\SendingServers\Identities\IdentitySyncService::class)->sync($server);
}
$server->setDefaultFromEmailAddress();
} catch (\Throwable $ex) {
$server->disable();
$notices[] = [
'title' => trans('messages.sending_server.connect_failed'),
'message' => $ex->getMessage(),
];
}
$identities = collect();
try {
$identities = $server->identities()->orderBy('value')->get();
} catch (\Throwable $ex) {
$notices[] = [
'title' => trans('messages.sending_server.identities_list_failed'),
'message' => $ex->getMessage(),
];
}
if (isset($request->old()['options'])) {
$server->options = json_encode($request->old()['options']);
}
$bigNotices = Hook::collect('generate_big_notice_for_sending_server', [$server]);
$emailit = ($server->getConfig('host') == 'smtp.emailit.com');
$webhook = $emailit ? action('DeliveryController@notify', ['stype' => 'emailit']) : null;
return view('refactor.pages.admin.sending-servers.edit', [
'server' => $server,
'bigNotices' => $bigNotices,
'notices' => $notices,
'identities' => $identities,
'emailit' => $emailit,
'webhook' => $webhook,
]);
}
/**
* Save the Connection tab — driver-specific credentials (host, smtp_*, api_key, etc).
* Validates via the driver's $cols rules + a live connection probe.
*/
public function connectionSave(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
$server->fill($request->all());
$validator = $server->validConnection($request->all());
if ($validator->fails()) {
if ($server->isExtended()) {
return redirect($server->getEditUrl())->withErrors($validator)->withInput();
}
return redirect()->route('refactor.admin.sending-servers.edit', ['uid' => $server->uid, 'section' => 'connection'])
->withErrors($validator)->withInput();
}
$server->save();
$request->session()->flash('alert-success', trans('refactor/admin_sending_servers.flash.updated'));
if ($server->isExtended()) {
return redirect($server->getEditUrl());
}
return redirect()->route('refactor.admin.sending-servers.edit', ['uid' => $server->uid, 'section' => 'connection']);
}
public function enable(Request $request)
{
$items = SendingServer::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
foreach ($items->get() as $item) {
if ($request->user()->admin->can('enable', $item)) {
$item->enable();
}
}
echo trans('refactor/admin_sending_servers.flash.enabled');
}
public function disable(Request $request)
{
$items = SendingServer::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
foreach ($items->get() as $item) {
if ($request->user()->admin->can('disable', $item)) {
$item->disable();
}
}
echo trans('refactor/admin_sending_servers.flash.disabled');
}
public function delete(Request $request)
{
$items = SendingServer::whereIn(
'uid',
is_array($request->uids) ? $request->uids : explode(',', $request->uids)
);
foreach ($items->get() as $item) {
if ($request->user()->admin->can('delete', $item)) {
$item->doDelete();
}
}
echo trans('refactor/admin_sending_servers.flash.deleted');
}
public function testConnection(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
// F4.1 (P11) — single source of truth for the probe shared with the
// `sending.test_connection` AI tool. UI button + AI tool see byte-
// identical health-check semantics.
$result = app(\App\Services\SendingServerHealthService::class)->testConnection($server);
if ($result['success']) {
return response()->json([
'status' => 'success',
'message' => trans('messages.sending_server.test_success'),
]);
}
// Preserve legacy auto-disable side-effect on UI button path; the AI
// tool path does NOT disable (probe is non-destructive there).
$server->disable();
return response()->json([
'status' => 'error',
'message' => $result['error_message'] ?? '',
], 400);
}
public function test(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
$server->fill($request->all());
if (!$request->user()->admin->can('test', $server)) {
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
$email = new \Symfony\Component\Mime\Email();
$msgId = StringHelper::generateMessageId(StringHelper::getDomainFromEmail($request->from_email));
$email->getHeaders()->addIdHeader('Message-ID', $msgId);
$email->getHeaders()->addDateHeader('Date', new \DateTimeImmutable());
$email->getHeaders()->addTextHeader('X-Acelle-Message-Id', $msgId);
$email->subject($request->subject);
$email->from($request->from_email);
$email->to($request->to_email);
$email->replyTo($request->from_email);
$email->text($request->content);
if (config('custom.sign_with_default_domain')) {
$email = dkim_sign_with_default_domain($email);
}
try {
$server->sendTestEmail($email);
} catch (\Exception $ex) {
return response()->json([
'status' => 'error',
'message' => $ex->getMessage(),
]);
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/admin_sending_servers.test.success'),
]);
}
return view('refactor.pages.admin.sending-servers.test', compact('server'));
}
public function sendingLimit(Request $request)
{
if (!$request->uid) {
$server = new SendingServer();
} else {
$server = SendingServer::findByUid($request->uid);
}
$server->fill($request->all());
if ($server->quota_value == -1) {
$server->quota_value = '1000';
$server->quota_base = '1';
$server->quota_unit = 'hour';
$server->setOption('sending_limit', '1000_per_hour');
}
if ($request->isMethod('post')) {
return view('refactor.pages.admin.sending-servers._partials._sending_limit', [
'quotaValue' => $request->quota_value,
'quotaBase' => $request->quota_base,
'quotaUnit' => $request->quota_unit,
'server' => $server,
]);
}
return view('refactor.pages.admin.sending-servers._partials.sending_limit', compact('server'));
}
/**
* Save the Configuration tab — name, default_from_email, bounce/FBL handlers,
* sending_limit + quota_*. Identity options are handled by identitySave() so
* this method's scope is strictly the Configuration form's fields.
*/
public function configurationSave(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
$this->validate($request, [
'name' => 'required',
'default_from_email' => 'nullable|email',
'bounce_handler_id' => 'nullable',
'feedback_loop_handler_id' => 'nullable',
]);
$server->name = $request->name;
$server->default_from_email = $request->default_from_email;
$server->bounce_handler_id = !empty($request->bounce_handler_id) ? $request->bounce_handler_id : null;
$server->feedback_loop_handler_id = !empty($request->feedback_loop_handler_id) ? $request->feedback_loop_handler_id : null;
// sending_limit → quota_value/unit/base columns + persist as option for "current"/"custom" passthrough.
$sendingLimit = $request->input('options.sending_limit');
if ($sendingLimit !== null && $sendingLimit !== '') {
$server->setOption('sending_limit', $sendingLimit);
if ($sendingLimit !== 'custom' && $sendingLimit !== 'current') {
$limits = SendingServer::sendingLimitValues()[$sendingLimit] ?? null;
if ($limits) {
$server->quota_value = $limits['quota_value'];
$server->quota_unit = $limits['quota_unit'];
$server->quota_base = $limits['quota_base'];
}
}
}
$server->save();
$request->session()->flash('alert-success', trans('refactor/admin_sending_servers.flash.updated'));
if ($server->isExtended()) {
return redirect($server->getEditUrl());
}
return redirect()->route('refactor.admin.sending-servers.edit', ['uid' => $server->uid, 'section' => 'config']);
}
/**
* Save the Sender Identity tab — verification toggles + per-identity enabled
* flag (which domains/emails on the server are advertised to customers).
* Validates only identity-scoped options; never touches name/handlers/quota.
*/
public function identitySave(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if (!$server) {
abort(404);
}
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
$this->validate($request, [
'options.allow_unverified_from_email' => 'nullable|in:yes,no',
'options.allow_verify_domain_against_acelle' => 'nullable|in:yes,no',
'options.allow_verify_email_against_acelle' => 'nullable|in:yes,no',
'options.emails' => 'array',
'options.emails.*' => 'string',
'options.domains' => 'array',
'options.domains.*' => 'string',
]);
$options = $request->input('options', []);
// Identity-scoped option keys only — prevent any other field from leaking through.
$identityKeys = [
'allow_unverified_from_email',
'allow_verify_domain_against_acelle',
'allow_verify_email_against_acelle',
];
$identityOpts = array_intersect_key($options, array_flip($identityKeys));
if (!empty($identityOpts)) {
$server->setOptions($identityOpts);
}
$server->bulkUpdateShared($options);
$server->save();
$request->session()->flash('alert-success', trans('refactor/admin_sending_servers.flash.updated'));
if ($server->isExtended()) {
return redirect($server->getEditUrl());
}
return redirect()->route('refactor.admin.sending-servers.edit', ['uid' => $server->uid, 'section' => 'identity']);
}
public function awsRegionHost(Request $request)
{
if ($request->uid) {
$server = SendingServer::findByUid($request->uid);
} else {
$server = new SendingServer();
}
foreach (SendingServer::awsRegionSelectOptions() as $option) {
if (isset($option['host']) && $option['value'] == $request->aws_region) {
$server->setConfig('host', $option['host']);
}
}
return view('refactor.pages.admin.sending-servers._partials._aws_region_host', compact('server'));
}
public function addDomain(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
if ($request->isMethod('post')) {
$value = strtolower(trim((string) $request->domain));
$isEmail = checkEmail($value);
$kind = $isEmail
? \App\SendingServers\Identities\IdentityKind::EMAIL
: \App\SendingServers\Identities\IdentityKind::DOMAIN;
$validator = \Validator::make(
['domain' => $value],
['domain' => $isEmail
? ['required', 'email']
: ['required', 'regex:' . SendingIdentity::VALIDATION_REGEXP],
],
);
$exists = $server->identities()
->where('kind', $kind->value)
->where('value', $value)
->exists();
if ($exists) {
$validator->errors()->add('domain', trans('messages.sending_identity.exist_error'));
}
if ($validator->fails()) {
return redirect()->route('refactor.admin.sending-servers.add-domain', $server->uid)
->withErrors($validator)->withInput();
}
app(\App\SendingServers\Identities\IdentityCreationService::class)
->createByAdmin(
server: $server,
kind: $kind,
value: $value,
initialStatus: \App\SendingServers\Identities\IdentityStatus::VERIFIED,
shared: true,
);
$request->session()->flash('alert-success', trans('refactor/admin_sending_servers.flash.updated'));
return;
}
return view('refactor.pages.admin.sending-servers.add_domain', compact('server'));
}
public function removeDomain(Request $request, $uid, $identity)
{
$server = SendingServer::findByUid($uid);
$value = base64_decode($identity);
$row = $server->identities()->where('value', $value)->first();
if ($row === null) {
return response()->json([
'message' => trans('messages.sending_server.domain.removed'),
'reload' => true,
]);
}
// Guard: VENDOR_SYNC rows can't be deleted from Acelle alone — the
// next IdentitySyncService::sync would re-INSERT from vendor list.
// Until a SupportsRemoteDomainDelete capability ships, refuse the
// operation. Frontend already hides the trash for these rows; this
// catch handles URL/API tinkering.
if ($row->management_mode === \App\SendingServers\Identities\ManagementMode::VENDOR_SYNC) {
return response()->json([
'status' => 'error',
'message' => 'This identity is vendor-managed (VENDOR_SYNC). '
. 'Delete it at the vendor dashboard; the next sync will reflect the change.',
], 422);
}
$row->delete();
return response()->json([
'message' => trans('messages.sending_server.domain.removed'),
'reload' => true,
]);
}
public function fromDropbox(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
$droplist = app(\App\SendingServers\Identities\IdentityCatalog::class)
->droplistForServer($server, strtolower(trim($request->keyword)));
return response()->json($droplist);
}
public function warmupStats(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
abort_if(is_null($server->warmupStrategy), 404);
return view('refactor.pages.admin.sending-servers.warmup_stats', compact('server'));
}
public function warmupReset(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
$server->warmupUsages()->delete();
$server->warmupLogs()->delete();
$server->warmup_started_at = null;
$server->warmup_enabled = false;
$server->save();
return response()->json([
'message' => 'Warmup records have been reset.',
'reload' => true,
]);
}
public function warmupSave(AssignWarmupStrategyRequest $request, $uid)
{
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
$warmupStrategy = $request->filled('warmup_strategy_uid')
? WarmupStrategy::findByUid($request->warmup_strategy_uid)
: null;
$this->warmupStrategyService->assignToSendingServer($server, $warmupStrategy);
return redirect()->route('refactor.admin.sending-servers.edit', $server->uid);
}
public function gmailOAuthSetup(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
return view('refactor.pages.admin.sending-servers.gmail-oauth-setup', [
'server' => $server,
'callbackUrl' => route('refactor.admin.sending-servers.gmail-oauth-callback'),
'setupError' => session('gmail_setup_error'),
]);
}
public function gmailOAuthRedirect(Request $request, $uid)
{
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
if (!$request->user()->admin->can('update', $server)) {
return $this->notAuthorized();
}
$params = http_build_query([
'client_id' => $server->getConfig('api_key'),
'redirect_uri' => route('refactor.admin.sending-servers.gmail-oauth-callback'),
'response_type' => 'code',
'scope' => 'https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email',
'access_type' => 'offline',
'prompt' => 'consent',
'state' => $uid,
]);
return redirect('https://accounts.google.com/o/oauth2/v2/auth?' . $params);
}
public function gmailOAuthCallback(Request $request)
{
$uid = $request->query('state');
$code = $request->query('code');
$error = $request->query('error');
$server = SendingServer::findByUid($uid);
abort_if(is_null($server), 404);
if ($error === 'redirect_uri_mismatch' || $error === 'access_denied') {
return redirect()->route('refactor.admin.sending-servers.gmail-oauth-setup', $server->uid)
->with('gmail_setup_error', $error);
}
if ($error || !$code) {
return redirect()->route('refactor.admin.sending-servers.edit', $server->uid)
->with('alert-error', trans('messages.sending_servers.gmail_oauth.oauth_error', ['error' => $error ?? 'no_code']));
}
try {
$client = new \GuzzleHttp\Client();
$response = $client->post(\App\SendingServers\Drivers\Vendors\Gmail\GmailOAuthDriver::GOOGLE_TOKEN_URL, [
'form_params' => [
'code' => $code,
'client_id' => $server->getConfig('api_key'),
'client_secret' => $server->getConfig('api_secret_key'),
'redirect_uri' => route('refactor.admin.sending-servers.gmail-oauth-callback'),
'grant_type' => 'authorization_code',
],
]);
$tokens = json_decode((string) $response->getBody(), true);
if (empty($tokens['access_token'])) {
throw new \Exception('No access token in response: ' . json_encode($tokens));
}
$userResponse = $client->get(\App\SendingServers\Drivers\Vendors\Gmail\GmailOAuthDriver::GOOGLE_USERINFO_URL, [
'headers' => ['Authorization' => 'Bearer ' . $tokens['access_token']],
]);
$userInfo = json_decode((string) $userResponse->getBody(), true);
$email = $userInfo['email'] ?? null;
$server->setConfigMany([
'oauth_access_token' => $tokens['access_token'],
'oauth_refresh_token' => $tokens['refresh_token'] ?? $server->getConfig('oauth_refresh_token'),
'oauth_token_expires_at' => \Carbon\Carbon::now()->addSeconds($tokens['expires_in'] ?? 3600)->toIso8601String(),
'smtp_username' => $email,
]);
$server->default_from_email = $server->default_from_email ?: $email;
$server->save();
$testResult = $server->driver()->test();
if (!$testResult->ok) {
throw new \RuntimeException($testResult->error ?? 'Test failed');
}
$server->status = SendingServer::STATUS_ACTIVE;
$server->save();
} catch (\Throwable $e) {
return redirect()->route('refactor.admin.sending-servers.edit', $server->uid)
->with('alert-error', trans('messages.sending_servers.gmail_oauth.oauth_failed') . ': ' . $e->getMessage());
}
return redirect()->route('refactor.admin.sending-servers.edit', $server->uid)
->with('alert-success', trans('messages.sending_servers.gmail_oauth.oauth_success', ['email' => $server->getConfig('smtp_username')]));
}
public function typeChart(Request $request)
{
$data = SendingServer::system()
->selectRaw('type, count(*) as count')
->groupBy('type')
->pluck('count', 'type')
->toArray();
return response()->json($data);
}
}