File: /home/xedaptot/be.naniguide.com/app/Http/Controllers/Refactor/MailListController.php
<?php
namespace App\Http\Controllers\Refactor;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Library\Cache\AppCache;
use App\Jobs\RefreshCacheableJob;
use App\Model\MailList;
use App\Model\Subscriber;
use App\Model\Setting;
use App\Services\Plans\Entitlements\EntitlementKey;
use App\Services\Plans\Gates\EntitlementGate;
use App\Services\Refactor\MailListService;
use App\Services\SubscriberService;
use App\Library\Facades\Hook;
use Gate;
class MailListController extends Controller
{
/**
* Lists index page.
*/
public function index(Request $request)
{
if (Gate::denies('list', MailList::class)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$lists = $customer->local()->mailLists;
// Aggregate stats for performance card (real cached data)
$totalSubscribers = 0;
$openRateSum = 0;
$clickRateSum = 0;
$unsubRateSum = 0;
$listsWithData = 0;
foreach ($lists as $list) {
$totalSubscribers += intval(AppCache::for($list)->read('SubscriberCount', 0));
$or = floatval(AppCache::for($list)->read('UniqOpenRate', 0));
$cr = floatval(AppCache::for($list)->read('ClickedRate', 0));
$ur = floatval(AppCache::for($list)->read('UnsubscribeRate', 0));
if ($or > 0 || $cr > 0) {
$openRateSum += $or;
$clickRateSum += $cr;
$unsubRateSum += $ur;
$listsWithData++;
}
}
$avgOpenRate = $listsWithData > 0 ? $openRateSum / $listsWithData : 0;
$avgClickRate = $listsWithData > 0 ? $clickRateSum / $listsWithData : 0;
$avgUnsubRate = $listsWithData > 0 ? $unsubRateSum / $listsWithData : 0;
// Sent count from campaigns (aggregate delivered from cache)
$totalSent = 0;
foreach ($customer->local()->campaigns()->where('status', 'done')->get() as $campaign) {
$totalSent += intval(AppCache::for($campaign)->read('DeliveredCount', 0));
}
return view('refactor.pages.lists.index', [
'customer' => $customer,
'totalLists' => $lists->count(),
'totalSubscribers' => $totalSubscribers,
'totalSent' => $totalSent,
'avgOpenRate' => $avgOpenRate,
'avgClickRate' => $avgClickRate,
'avgUnsubRate' => $avgUnsubRate,
]);
}
/**
* AJAX endpoint — period-filtered performance stats.
*/
public function perfStats(Request $request)
{
$days = intval($request->input('days', 365));
$customer = $request->user()->customer;
$from = now()->subDays($days);
$campaigns = $customer->local()->campaigns()
->where('status', 'done')
->where('run_at', '>=', $from)
->get();
$totalSent = 0;
$totalOpened = 0;
$totalClicked = 0;
$totalUnsub = 0;
foreach ($campaigns as $c) {
$del = intval(AppCache::for($c)->read('DeliveredCount', 0));
$totalSent += $del;
$totalOpened += intval(AppCache::for($c)->read('UniqOpenCount', 0));
$totalClicked += intval(AppCache::for($c)->read('ClickedRate', 0) * $del);
$totalUnsub += intval(AppCache::for($c)->read('UnsubscribeRate', 0) * $del);
}
$openRate = $totalSent > 0 ? ($totalOpened / $totalSent) * 100 : 0;
$clickRate = $totalSent > 0 ? ($totalClicked / $totalSent) * 100 : 0;
$unsubRate = $totalSent > 0 ? ($totalUnsub / $totalSent) * 100 : 0;
return response()->json([
'sent' => number_format($totalSent),
'delivery_rate' => $totalSent > 0 ? '99%' : '0%',
'open_rate' => number_format($openRate, 1) . '%',
'click_rate' => number_format($clickRate, 1) . '%',
'unsub_rate' => number_format($unsubRate, 1) . '%',
]);
}
/**
* AJAX paginated list partial.
*/
public function listing(Request $request)
{
if (Gate::denies('list', MailList::class)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$lists = $customer->local()->mailLists()
->search($request->keyword)
->orderBy(
$request->sort_order ?? 'created_at',
$request->sort_direction ?? 'desc'
)
->paginate($request->per_page ?? 25);
$settings = [
'list.clone_for_others' => Setting::isYes('list.clone_for_others'),
];
return view('refactor.pages.lists._list', [
'lists' => $lists,
'settings' => $settings,
]);
}
/**
* Refresh cache for one mail list — dispatches RefreshCacheableJob.
* No synchronous compute; UI shows toast and the user re-visits to see
* fresh numbers. See docs/cache/README.md "Apply caching to a page".
*/
public function refreshCache(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$entry = $request->input('entry'); // null = refresh entire manifest
RefreshCacheableJob::dispatch($list, $entry);
return response()->json([
'message' => trans('refactor/lists.cache.refresh_dispatched'),
], 202);
}
/**
* Copy a mail list.
*/
public function copy(Request $request)
{
$list = MailList::findByUid($request->copy_list_uid);
if (Gate::denies('update', $list)) {
if ($request->ajax()) {
return response()->json(['message' => trans('refactor/common.error.not_authorized')], 403);
}
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
$validator = \Validator::make($request->all(), [
'name' => 'required',
]);
if ($validator->fails()) {
return response()->json([
'status' => 'error',
'errors' => $validator->errors(),
], 422);
}
$customer = $request->user()->customer;
$list->copy($request->name, $customer);
return response()->json([
'status' => 'success',
'message' => trans('refactor/lists.flash.copied'),
]);
}
return view('refactor.pages.lists.copy', [
'list' => $list,
]);
}
/**
* Delete one or more mail lists.
*/
public function delete(Request $request)
{
$uids = is_array($request->uids) ? $request->uids : explode(',', $request->uids);
$lists = MailList::whereIn('uid', $uids)->get();
$deleted = 0;
foreach ($lists as $item) {
if (Gate::allows('delete', $item)) {
$item->delete();
(new \App\ActivityLog\Activities\ListDeleted($item, $request->user()))->record();
event(new \App\Events\MailListUpdated($item));
$deleted++;
}
}
return response()->json([
'status' => 'success',
'message' => trans('refactor/lists.flash.deleted_count', ['count' => $deleted]),
]);
}
/**
* Show create list form.
*/
public function create(Request $request)
{
if (Gate::denies('create', MailList::class)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$list = $customer->newMailList();
// Defaults
$list->subscribe_confirmation = Setting::get('list.subscribe_confirmation', 'yes');
$list->send_welcome_email = Setting::get('list.send_welcome_email', 'no');
$list->unsubscribe_notification = Setting::get('list.unsubscribe_notification', 'no');
$contact = $customer->contact;
if ($contact) {
if (!empty($contact->email)) {
$list->from_email = $contact->email;
}
$list->from_name = trim($contact->first_name . ' ' . $contact->last_name);
}
if (empty($list->from_email)) {
$list->from_email = $request->user()->email;
}
return view('refactor.pages.lists.create', compact('list'));
}
/**
* Store a new list.
*/
public function store(Request $request, MailListService $service)
{
$customer = $request->user()->customer;
if (Gate::denies('create', $customer->newMailList())) {
return $this->notAuthorized();
}
$list = $service->createFor($customer, $request->only([
'name', 'from_email', 'from_name',
'subscribe_confirmation', 'send_welcome_email', 'unsubscribe_notification',
]));
return redirect()->route('refactor.lists.overview', $list->uid)
->with('success', trans('refactor/lists.flash.created'));
}
/**
* List overview — stats dashboard.
*/
public function overview(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
event(new \App\Events\MailListUpdated($list));
$stats = [
'subscriber_count' => intval(AppCache::for($list)->read('SubscriberCount', 0)),
'open_rate' => floatval(AppCache::for($list)->read('UniqOpenRate', 0)),
'click_rate' => floatval(AppCache::for($list)->read('ClickedRate', 0)),
'subscribe_rate' => floatval(AppCache::for($list)->read('SubscribeRate', 0)),
'unsubscribe_rate' => floatval(AppCache::for($list)->read('UnsubscribeRate', 0)),
'subscribe_count' => intval(AppCache::for($list)->read('SubscribeCount', 0)),
'unsubscribe_count' => intval(AppCache::for($list)->read('UnsubscribeCount', 0)),
'unconfirmed_count' => intval(AppCache::for($list)->read('UnconfirmedCount', 0)),
'blacklisted_count' => intval(AppCache::for($list)->read('BlacklistedCount', 0)),
];
// Subscriber composition for donut chart
$subscribedCount = intval(AppCache::for($list)->read('SubscriberCount', 0))
- intval(AppCache::for($list)->read('UnsubscribeCount', 0))
- intval(AppCache::for($list)->read('UnconfirmedCount', 0))
- intval(AppCache::for($list)->read('BlacklistedCount', 0));
$subscribedCount = max(0, $subscribedCount);
$composition = [
['name' => 'Subscribed', 'value' => $subscribedCount, 'color' => '#8ecfb0'],
['name' => 'Unsubscribed', 'value' => intval(AppCache::for($list)->read('UnsubscribeCount', 0)), 'color' => '#f4a69e'],
['name' => 'Unconfirmed', 'value' => intval(AppCache::for($list)->read('UnconfirmedCount', 0)), 'color' => '#f9d57e'],
['name' => 'Blacklisted', 'value' => intval(AppCache::for($list)->read('BlacklistedCount', 0)), 'color' => '#b0bec5'],
];
$composition = array_filter($composition, fn($c) => $c['value'] > 0);
$composition = array_values($composition);
// Subscriber growth — last 6 months
$growth = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$count = $list->subscribers()
->whereYear('created_at', $month->year)
->whereMonth('created_at', $month->month)
->count();
$growth[] = [
'month' => $month->format('M Y'),
'count' => $count,
];
}
// Segment count
$segmentCount = $list->segmentsCount();
// Recent subscribers (last 5)
$recentSubscribers = $list->subscribers()
->orderBy('created_at', 'desc')
->take(5)
->get();
// Daily growth — last 30 days
$dailyGrowth = [];
for ($i = 29; $i >= 0; $i--) {
$day = now()->subDays($i);
$dailyGrowth[] = [
'day' => $day->format('M d'),
'count' => $list->subscribers()
->whereDate('created_at', $day->toDateString())
->count(),
];
}
// Getting started checklist (dynamic based on actual data)
$checklistItems = [
[
'id' => 'created',
'title' => 'List created',
'desc' => 'Your list is ready to receive contacts.',
'time' => null,
'done' => true,
'panel_title' => 'Your list is set up!',
'panel_desc' => 'Your contact list is created and ready. Now let\'s bring it to life by adding subscribers.',
'panel_cta' => null,
'panel_cta_url' => null,
],
[
'id' => 'contacts',
'title' => 'Add your contacts',
'desc' => 'Import subscribers or add them manually.',
'time' => '2 min',
'done' => $stats['subscriber_count'] > 0,
'panel_title' => 'Add contacts to start sending emails',
'panel_desc' => 'Upload a CSV file, paste contacts, or add them one by one to start sending campaigns right away.',
'panel_cta' => 'Import contacts',
'panel_cta_url' => route('refactor.lists.subscriber_import', ['uid' => $list->uid]),
],
[
'id' => 'segment',
'title' => 'Create a segment',
'desc' => 'Group contacts by shared traits for better targeting.',
'time' => '3 min',
'done' => $segmentCount > 0,
'panel_title' => 'Reach the right people',
'panel_desc' => 'Segments let you group subscribers by location, activity, or custom fields — so you can send more relevant campaigns.',
'panel_cta' => 'Create segment',
'panel_cta_url' => route('refactor.lists.segment_create', ['uid' => $list->uid]),
],
[
'id' => 'form',
'title' => 'Set up a signup form',
'desc' => 'Grow your list automatically from your website.',
'time' => '4 min',
'done' => false,
'panel_title' => 'Grow your list on autopilot',
'panel_desc' => 'Embed a signup form on your website. Every visitor who signs up goes straight into this list — no manual work needed.',
'panel_cta' => 'Get form code',
'panel_cta_url' => route('refactor.lists.embedded_form', ['uid' => $list->uid]),
],
];
$checklistDone = count(array_filter($checklistItems, fn($c) => $c['done']));
$checklistTotal = count($checklistItems);
$checklistPct = round($checklistDone / $checklistTotal * 100);
// First non-done item is the active panel
$activeChecklistIdx = 0;
foreach ($checklistItems as $idx => $item) {
if (!$item['done']) { $activeChecklistIdx = $idx; break; }
}
return view('refactor.pages.lists.overview', compact(
'list', 'stats', 'composition', 'growth',
'segmentCount', 'recentSubscribers', 'dailyGrowth',
'checklistItems', 'checklistDone', 'checklistTotal', 'checklistPct', 'activeChecklistIdx'
));
}
/**
* Edit list settings form.
*/
public function edit(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return $this->notAuthorized();
}
return view('refactor.pages.lists.edit', compact('list'));
}
/**
* Update list settings.
*/
public function update(Request $request, $uid, MailListService $service)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$service->update($list, $request->only([
'name', 'from_email', 'from_name',
'subscribe_confirmation', 'send_welcome_email', 'unsubscribe_notification',
]));
return redirect()->route('refactor.lists.overview', $list->uid)
->with('success', trans('refactor/lists.flash.updated'));
}
/**
* Subscribers listing page.
*/
public function subscribers(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$fields = $list->getFields->whereNotIn('tag', ['EMAIL']);
$pluginColumns = $this->collectPluginSubscriberColumns($list);
$pluginBulkActions = $this->collectPluginSubscriberBulkActions($list);
$columns = $this->resolveSubscribersColumns($request->user(), $fields, $pluginColumns);
// Data for popup forms
$customer = $request->user()->customer;
$otherLists = $customer->local()->mailLists()
->where('id', '!=', $list->id)
->orderBy('name')
->get()
->map(fn($l) => [
'uid' => $l->uid,
'name' => $l->name,
'count' => AppCache::for($l)->read('SubscriberCount', 0),
'date' => $l->created_at ? $l->created_at->format('M Y') : '',
])->values();
return view('refactor.pages.lists.subscribers', compact(
'list', 'columns', 'fields', 'otherLists', 'pluginColumns', 'pluginBulkActions'
));
}
/**
* Resolve the user's saved `subscribers_columns` setting, falling back
* to defaults when empty and one-time merging plugin-contributed
* default-visible columns for existing users who pre-date the plugin.
*
* The `subscribers_columns_messenger_seen` setting gates the one-time
* merge — once flipped, the user's saved setting is honored as-is
* (so explicit uncheck via the column picker is persisted).
*
* @param \Illuminate\Database\Eloquent\Collection<int, \App\Model\Field> $fields
* @param array<int, array{key: string, label: string, default_visible?: bool}> $pluginColumns
* @return array<int, string>
*/
private function resolveSubscribersColumns($user, $fields, array $pluginColumns): array
{
$columns = $user->getSetting('subscribers_columns');
if (! is_array($columns)) {
$columns = [];
}
$pluginDefaults = array_values(array_filter(array_map(
fn(array $col) => !empty($col['default_visible']) ? $col['key'] : null,
$pluginColumns
)));
if (empty($columns)) {
// New user — no saved columns. Default = first/last/created + plugin defaults.
$defaultFieldUids = $fields->whereIn('tag', ['FIRST_NAME', 'LAST_NAME'])->pluck('uid')->toArray();
return array_merge($defaultFieldUids, ['created_at'], $pluginDefaults);
}
// Existing user — one-time merge of plugin default-visible columns.
if (! empty($pluginDefaults) && empty($user->getSetting('subscribers_columns_plugin_seen'))) {
$hasPluginKey = false;
foreach ($columns as $key) {
if (is_string($key) && strpos($key, ':') !== false) {
$hasPluginKey = true;
break;
}
}
if (! $hasPluginKey) {
$columns = array_merge($columns, $pluginDefaults);
$user->writeSetting('subscribers_columns', $columns);
}
$user->writeSetting('subscribers_columns_plugin_seen', '1');
}
return $columns;
}
/**
* Collect plugin-contributed extra columns for the subscriber list.
*
* @return array<int, array{key: string, label: string, default_visible?: bool, width?: string}>
*/
private function collectPluginSubscriberColumns(MailList $list): array
{
$columns = [];
foreach (Hook::collect('subscribers.plugin_columns', [$list]) as $contribution) {
if (! is_array($contribution)) {
continue;
}
foreach ($contribution as $col) {
if (is_array($col) && isset($col['key'], $col['label'])) {
$columns[] = $col;
}
}
}
return $columns;
}
/**
* Collect plugin-contributed bulk-action dropdown items.
*
* @return array<int, array{id: string, label: string, icon?: string, url: string, confirm?: string}>
*/
private function collectPluginSubscriberBulkActions(MailList $list): array
{
$actions = [];
foreach (Hook::collect('subscribers.bulk_actions', [$list]) as $contribution) {
if (! is_array($contribution)) {
continue;
}
foreach ($contribution as $action) {
if (is_array($action) && isset($action['id'], $action['label'], $action['url'])) {
$actions[] = $action;
}
}
}
return $actions;
}
/**
* Resolve subscribers query — handles select_tool=all_items (re-query by filters)
* vs normal mode (query by IDs).
*/
private function resolveSubscribers(Request $request, MailList $list)
{
if ($request->select_tool === 'all_items') {
$query = $list->subscribers()
->search($request->keyword, $list->fields);
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->verification) {
if ($request->verification === 'unverified') {
$query->whereNull('verification_status');
} else {
$query->where('verification_status', $request->verification);
}
}
return $query->orderBy($request->sort_order ?? 'created_at', $request->sort_direction ?? 'desc');
}
$ids = array_filter(explode(',', $request->ids ?? ''));
return $list->subscribers()->whereIn('id', $ids);
}
/**
* Subscriber bulk action: move to blacklist.
*/
public function subscriberMoveToBlacklist(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$subscribers = $this->resolveSubscribers($request, $list)->limit(100)->get();
$job = new \App\Jobs\MoveToBlacklist($subscribers);
safe_dispatch($job);
return response()->json(['message' => trans('refactor/lists.subscribers.bulk_blacklist_queued', ['count' => $subscribers->count()])]);
}
/**
* Subscriber bulk action: resend confirmation email.
*/
public function subscriberResendConfirmation(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
$sent = 0;
foreach ($subscribers as $sub) {
if ($sub->status === 'unconfirmed') {
$list->sendSubscriptionConfirmationEmail($sub);
$sent++;
}
}
return response()->json(['message' => $sent . ' confirmation email(s) sent']);
}
/**
* Subscriber bulk action: copy to another list.
*/
public function subscriberCopy(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$targetList = MailList::findByUid($request->to_uid);
if (!$targetList || Gate::denies('update', $targetList)) {
return response()->json(['message' => 'Target list not found'], 404);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
foreach ($subscribers as $sub) {
$sub->copy($targetList);
}
event(new \App\Events\MailListUpdated($targetList));
return response()->json(['message' => $subscribers->count() . ' subscriber(s) copied']);
}
/**
* Subscriber bulk action: move to another list.
*/
public function subscriberMove(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$targetList = MailList::findByUid($request->to_uid);
if (!$targetList || Gate::denies('update', $targetList)) {
return response()->json(['message' => 'Target list not found'], 404);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
foreach ($subscribers as $sub) {
$sub->move($targetList);
}
event(new \App\Events\MailListUpdated($targetList));
event(new \App\Events\MailListUpdated($list));
return response()->json(['message' => $subscribers->count() . ' subscriber(s) moved']);
}
/**
* Subscriber bulk action: add tags.
*/
public function subscriberAddTag(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$tags = array_filter(array_map('trim', explode(',', $request->tags ?? '')));
if (empty($tags)) {
return response()->json(['errors' => ['tags' => ['Please enter at least one tag.']]], 422);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
foreach ($subscribers as $sub) {
$sub->addTags($tags);
}
return response()->json(['message' => 'Tags added to ' . $subscribers->count() . ' subscriber(s)']);
}
/**
* Subscriber bulk action: remove tags.
*/
public function subscriberRemoveTag(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$tags = array_filter(array_map('trim', explode(',', $request->tags ?? '')));
if (empty($tags)) {
return response()->json(['errors' => ['tags' => ['Please select at least one tag.']]], 422);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
foreach ($subscribers as $sub) {
$sub->removeTags($tags);
}
return response()->json(['message' => 'Tags removed from ' . $subscribers->count() . ' subscriber(s)']);
}
/**
* Subscriber bulk action: assign values.
*/
public function subscriberAssignValues(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$fieldUid = $request->field_uid;
$value = $request->value ?? '';
$field = $list->getFields->where('uid', $fieldUid)->first();
if (!$field) {
return response()->json(['errors' => ['field_uid' => ['Please select a field.']]], 422);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
foreach ($subscribers as $sub) {
$sub->setFieldValue($field, $value);
$sub->save();
}
return response()->json(['message' => 'Value assigned to ' . $subscribers->count() . ' subscriber(s)']);
}
/**
* Subscriber bulk action: bulk delete by email list.
*/
public function subscriberBulkDelete(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$emails = array_filter(array_map('trim', preg_split('/[\r\n,]+/', $request->emails ?? '')));
if (empty($emails)) {
return response()->json(['errors' => ['emails' => ['Please enter at least one email.']]], 422);
}
$deleted = $list->subscribers()
->whereIn('email', $emails)
->delete();
return response()->json(['message' => $deleted . ' subscriber(s) deleted']);
}
/**
* AJAX subscriber rows.
*/
public function subscribersListing(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return response(trans('refactor/common.error.not_authorized'), 403);
}
$query = $list->subscribers()
->search($request->keyword, $list->fields);
// Status filter
if ($request->status) {
$query->where('status', $request->status);
}
// Verification filter
if ($request->verification) {
if ($request->verification === 'unverified') {
$query->whereNull('verification_status');
} else {
$query->where('verification_status', $request->verification);
}
}
$query = $query
->orderBy($request->sort_order ?? 'created_at', $request->sort_direction ?? 'desc');
// Hook EVENT — plugins may eager-load or join extra data onto the query.
Hook::fire('subscribers.list_query', [$query, $list]);
$subscribers = $query->paginate($request->per_page ?? 25);
$fields = $list->getFields->whereNotIn('tag', ['EMAIL']);
$pluginColumns = $this->collectPluginSubscriberColumns($list);
$columns = $this->resolveSubscribersColumns($request->user(), $fields, $pluginColumns);
// Plugins may attach per-page bulk data to the subscriber collection
// (e.g., one-query bulk-load of opt-in status for the page's subscribers).
Hook::fire('subscribers.list_decorate', [$subscribers, $list]);
return view('refactor.pages.lists._subscriber', compact(
'list', 'subscribers', 'fields', 'columns', 'pluginColumns'
));
}
/**
* Segments listing page.
*/
public function segments(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
if (Gate::denies('list', \App\Model\Segment::class)) {
return $this->notAuthorized();
}
// Stats for creative dashboard
$totalSubscribers = intval(AppCache::for($list)->read('SubscriberCount', 0));
$segments = $list->segments()->orderBy('name')->get();
$segmentCount = $segments->count();
// Per-segment subscriber counts for visualization
$segmentStats = $segments->map(function ($seg) {
$count = $seg->subscribersCount();
return [
'uid' => $seg->uid,
'name' => $seg->name,
'count' => $count,
'match' => $seg->match ?? 'all',
'conditions' => $seg->segmentConditions()->count(),
];
});
// Coverage: what % of list is in at least one segment
$maxSegSubs = $segmentStats->max('count') ?? 0;
$coveragePct = $totalSubscribers > 0 ? min(100, round($maxSegSubs / $totalSubscribers * 100)) : 0;
return view('refactor.pages.lists.segments', compact(
'list', 'totalSubscribers', 'segmentCount', 'segmentStats', 'coveragePct'
));
}
/**
* Audience overview — aggregate stats across all lists.
*/
public function audienceOverview(Request $request)
{
$customer = $request->user()->customer;
$lists = $customer->local()->mailLists()->get();
$totalSubscribers = 0;
$totalUnconfirmed = 0;
$totalUnsubscribed = 0;
$totalBlacklisted = 0;
$listStats = [];
foreach ($lists as $list) {
$sub = intval(AppCache::for($list)->read('SubscriberCount', 0));
$unsub = intval(AppCache::for($list)->read('UnsubscribeCount', 0));
$unconf= intval(AppCache::for($list)->read('UnconfirmedCount', 0));
$black = intval(AppCache::for($list)->read('BlacklistedCount', 0));
$totalSubscribers += $sub;
$totalUnsubscribed += $unsub;
$totalUnconfirmed += $unconf;
$totalBlacklisted += $black;
$listStats[] = [
'uid' => $list->uid,
'name' => $list->name,
'subscribers' => $sub,
'open_rate' => round(floatval(AppCache::for($list)->read('UniqOpenRate', 0)) * 100, 1),
'click_rate' => round(floatval(AppCache::for($list)->read('ClickedRate', 0)) * 100, 1),
];
}
// Sort top lists by subscriber count
usort($listStats, fn($a, $b) => $b['subscribers'] <=> $a['subscribers']);
$topLists = array_slice($listStats, 0, 10);
// Growth — total new subscribers across all lists per month (last 6 months)
$growth = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$total = 0;
foreach ($lists as $list) {
$total += $list->subscribers()
->whereYear('created_at', $month->year)
->whereMonth('created_at', $month->month)
->count();
}
$growth[] = ['month' => $month->format('M Y'), 'count' => $total];
}
// Composition (aggregate)
$activeCount = max(0, $totalSubscribers - $totalUnsubscribed - $totalUnconfirmed - $totalBlacklisted);
$composition = array_filter([
['name' => 'Subscribed', 'value' => $activeCount, 'color' => '#8ecfb0'],
['name' => 'Unsubscribed', 'value' => $totalUnsubscribed, 'color' => '#f4a69e'],
['name' => 'Unconfirmed', 'value' => $totalUnconfirmed, 'color' => '#f9d57e'],
['name' => 'Blacklisted', 'value' => $totalBlacklisted, 'color' => '#b0bec5'],
], fn($c) => $c['value'] > 0);
$composition = array_values($composition);
// Daily growth — last 30 days (aggregate)
$dailyGrowth = [];
for ($i = 29; $i >= 0; $i--) {
$day = now()->subDays($i);
$total = 0;
foreach ($lists as $list) {
$total += $list->subscribers()
->whereDate('created_at', $day->toDateString())
->count();
}
$dailyGrowth[] = ['day' => $day->format('M d'), 'count' => $total];
}
// Top list for quick import link
$topList = $lists->sortByDesc(fn($l) => intval(AppCache::for($l)->read('SubscriberCount', 0)))->first();
return view('refactor.pages.audience.overview', compact(
'lists', 'totalSubscribers', 'topLists', 'growth', 'composition',
'totalUnconfirmed', 'totalUnsubscribed', 'totalBlacklisted',
'dailyGrowth', 'topList'
));
}
/**
* AJAX segment rows.
*/
public function segmentsListing(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return response(trans('refactor/common.error.not_authorized'), 403);
}
$request->merge(['list_uid' => $uid]);
$request->merge(['sort_order' => $request->sort_order ?? 'name']);
$request->merge(['sort_direction' => $request->sort_direction ?? 'asc']);
$segments = \App\Model\Segment::search($request)->paginate($request->per_page ?? 25);
return view('refactor.pages.lists._segment', compact('list', 'segments'));
}
// ─── List Fields Management ─────────────────────────────────
public function fields(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || \Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$fields = $list->getFields;
if (isset($request->old()['fields'])) {
$fields = $list->getFieldsFromParams($request->old());
}
return view('refactor.pages.lists.fields', [
'list' => $list,
'fields' => $fields,
]);
}
public function fieldsStore(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || \Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$validator = $list->updateOrCreateFieldsFromRequest($request);
$fields = $list->getFieldsFromParams($request->all());
if ($validator->fails()) {
if ($request->ajax()) {
return response()->json([
'status' => 'error',
'errors' => $validator->errors(),
], 422);
}
return redirect()->route('refactor.lists.fields', $uid)
->withErrors($validator)
->withInput();
}
return redirect()->route('refactor.lists.fields', $uid)
->with('alert-success', trans('refactor/fields.flash.saved'));
}
public function fieldSample(Request $request, $uid, $type)
{
$list = MailList::findByUid($uid);
if (!$list || \Gate::denies('update', $list)) {
return $this->notAuthorized();
}
return view('fields._form_samples', [
'list' => $list,
'type' => $type,
]);
}
public function fieldDelete(Request $request, $uid, $fieldUid)
{
$list = MailList::findByUid($uid);
if (!$list || \Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$field = \App\Model\Field::findByUid($fieldUid);
if (!$field) {
return response()->json(['message' => 'Field not found'], 404);
}
if ($field->tag == 'EMAIL') {
return redirect()->route('refactor.lists.fields', $uid)
->with('alert-error', trans('refactor/fields.error.miss_email'));
}
$field->delete();
return redirect()->route('refactor.lists.fields', $uid)
->with('alert-success', trans('refactor/fields.flash.saved'));
}
/**
* Reorder fields via drag-and-drop (AJAX).
*/
public function fieldsSort(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$uids = $request->input('uids', []);
foreach ($uids as $index => $fieldUid) {
// Scoped to this list — prevents cross-list reordering
$list->fields()->where('uid', $fieldUid)->update(['sort_order' => $index]);
}
return response()->json(['status' => 'ok']);
}
// ─── Email Verification ─────────────────────────────────────
public function verification(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || \Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$sub = $customer->getCurrentActiveSubscription();
$plan = $sub?->plan;
// Check for active job
$activeJob = $list->verificationJobs()->first();
// Verification server options
$verificationServers = collect();
if (EntitlementGate::allows($customer, EntitlementKey::HAS_OWN_EMAIL_VERIFICATION_SERVER)) {
$verificationServers = $customer->emailVerificationServers()
->where('status', 'active')->get();
}
// Get verification stats
$totalSubscribers = intval(AppCache::for($list)->read('SubscriberCount', 0));
// Verification credits — both limit + remaining come from the snapshot
// (Plan Versioning §10: never mix live plan grant with snapshot
// remaining). The "↻" icon re-syncs from the live tracker.
$verificationCreditsHas = false;
$verificationCreditsRemaining = null;
$verificationCreditsLimit = null;
$verificationCreditsLastSynced = null;
if ($sub) {
$credits = app(\App\Services\Plans\Credits\CreditsService::class);
$vck = \App\Services\Plans\Credits\CreditKey::VERIFY_CONTACT;
$verificationCreditsHas = $credits->hasSnapshotGrant($sub, $vck);
// grantedFromSnapshot/remainingSnapshot now return int|null|false
// (false = no state row → sub not granted). The hasSnapshotGrant
// boolean is the canonical disambiguator for the view: when false,
// the view must render "Not included" and ignore the limit/remaining
// values entirely.
$verificationCreditsLimit = $credits->grantedFromSnapshot($sub, $vck);
$verificationCreditsRemaining = $credits->remainingSnapshot($sub, $vck);
$verificationCreditsLastSynced = \App\Model\SubscriptionCreditState::where('subscription_id', $sub->id)
->where('credit_key', $vck->value)
->value('last_synced_at');
}
return view('refactor.pages.lists.verification', [
'list' => $list,
'customer' => $customer,
'plan' => $plan,
'subscription' => $sub,
'activeJob' => $activeJob,
'verificationServers' => $verificationServers,
'totalSubscribers' => $totalSubscribers,
'verificationCreditsHas' => $verificationCreditsHas,
'verificationCreditsRemaining' => $verificationCreditsRemaining,
'verificationCreditsLimit' => $verificationCreditsLimit,
'verificationCreditsLastSynced' => $verificationCreditsLastSynced,
]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Create / Store
* ─────────────────────────────────────────────────────── */
public function subscriberCreate(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$subscriber = new Subscriber();
$subscriber->mail_list_id = $list->id;
// Old post values for repopulation
$values = [];
if (null !== $request->old()) {
foreach ($request->old() as $key => $value) {
if (is_array($value)) {
$values[str_replace('[]', '', $key)] = implode(',', $value);
} else {
$values[$key] = $value;
}
}
}
return view('refactor.pages.lists.subscriber_create', [
'list' => $list,
'subscriber' => $subscriber,
'values' => $values,
'fields' => $list->getFields,
]);
}
public function subscriberStore(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
$subscriberService = app(SubscriberService::class);
// Ownership + RBAC
if (!$request->user()->can('addMoreSubscribers', [$list, 1])) {
return back()->withErrors(['quota' => trans('refactor/subscribers.quota.customer_reached_desc')]);
}
// Plan state: customer-wide MAX_SUBSCRIBERS + per-list MAX_SUBSCRIBERS_PER_LIST.
// Message distinguishes which cap was hit so the admin knows why.
if (!\App\Services\Plans\Gates\QuotaGate::allows($customer, \App\Services\Plans\Quotas\QuotaKey::MAX_SUBSCRIBERS, amount: 1)) {
return back()->withInput()->withErrors(['quota' => trans('refactor/subscribers.quota.customer_reached_desc')]);
}
if (!\App\Services\Plans\Gates\QuotaGate::allows($customer, \App\Services\Plans\Quotas\QuotaKey::MAX_SUBSCRIBERS_PER_LIST, list: $list, amount: 1)) {
return back()->withInput()->withErrors(['quota' => trans('refactor/subscribers.quota.per_list_reached_desc')]);
}
// Validate & create subscriber
[$validator, $subscriber] = $subscriberService->addByCustomer($list, $request, $customer);
if (is_null($subscriber)) {
return back()->withInput()->withErrors($validator);
}
$request->session()->flash('alert-success', trans('refactor/subscribers.flash.created'));
return redirect()->route('refactor.lists.subscribers', $list->uid);
}
/* ───────────────────────────────────────────────────────
| Subscriber Edit / Update
* ─────────────────────────────────────────────────────── */
public function subscriberEdit(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
if (!$list) {
return $this->notAuthorized();
}
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return $this->notAuthorized();
}
// Populate values from subscriber fields
$values = [];
foreach ($list->getFields as $field) {
$values[$field->tag] = $subscriber->getValueByField($field);
}
if (null !== $request->old()) {
foreach ($request->old() as $key => $value) {
if (is_array($value)) {
$values[str_replace('[]', '', $key)] = implode(',', $value);
} else {
$values[$key] = $value;
}
}
}
// Verification servers for email verification section
$customer = $request->user()->customer;
$verificationServers = $customer->emailVerificationServers()
->where('status', 'active')->get()
->map(fn($s) => ['value' => $s->id, 'text' => $s->name])
->prepend(['value' => '', 'text' => trans('refactor/subscribers.verification.select_server')])
->toArray();
return view('refactor.pages.lists.subscriber_edit', [
'list' => $list,
'subscriber' => $subscriber,
'values' => $values,
'fields' => $list->getFields,
'verificationServers' => $verificationServers,
]);
}
/**
* Compact "business card" partial for hover-popovers in report tables
* (open_map / click_map "Recent activity"). Returns HTML rendered by
* the lists.subscriber_card blade — small card with status, list, key
* counts, and an Edit CTA. Read-only Gate so anyone with view access
* can see it.
*/
public function subscriberCard(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
if (!$list) {
return response('', 404);
}
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('read', $subscriber)) {
return response('', 403);
}
return view('refactor.pages.lists._subscriber_card', [
'list' => $list,
'subscriber' => $subscriber,
]);
}
public function subscriberUpdate(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return $this->notAuthorized();
}
$this->validate($request, $subscriber->getRules());
// Upload avatar
if ($request->hasFile('image') && $request->file('image')->isValid()) {
$subscriber->uploadImage($request->file('image'));
}
if ($request->_remove_image === 'true') {
$subscriber->removeImage();
}
// Update fields
$subscriber->updateFields($request->all());
event(new \App\Events\SubscriptionUpdated($subscriber));
event(new \App\Events\MailListUpdated($subscriber->mailList));
$request->session()->flash('alert-success', trans('refactor/subscribers.flash.updated'));
return redirect()->route('refactor.lists.subscriber_edit', [$list->uid, $subscriber->id]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Delete (single, from edit page)
* ─────────────────────────────────────────────────────── */
public function subscriberDelete(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('delete', $subscriber)) {
if ($request->ajax()) {
return response()->json(['message' => 'Unauthorized'], 403);
}
return $this->notAuthorized();
}
$subscriber->delete();
if ($request->ajax()) {
return response()->json(['message' => trans('refactor/subscribers.flash.deleted')]);
}
$request->session()->flash('alert-success', trans('refactor/subscribers.flash.deleted'));
return redirect()->route('refactor.lists.subscribers', $list->uid);
}
/**
* R11.2 — GDPR Article 17 right-to-delete entry point.
*
* Customer-side button on the subscriber edit page. Dispatches
* `RemoveSubscriberFromAudiences` (the same job the email-unsubscribe
* cascade uses) so this email is removed from every connected ad
* audience for the customer. Returns 202 Accepted because the
* platform-side delete happens asynchronously — the audit trail in
* `ad_activity_logs` confirms completion per audience.
*/
public function subscriberPurgeFromAds(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
if (!$list) {
return $this->notAuthorized();
}
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
if ($request->ajax()) {
return response()->json(['message' => 'Unauthorized'], 403);
}
return $this->notAuthorized();
}
$customerId = (int) $list->customer_id;
$email = (string) $subscriber->email;
\App\Jobs\Ads\RemoveSubscriberFromAudiences::dispatch($customerId, $email);
\App\Services\Ads\AdActivityLogger::log(
entity: $subscriber,
action: 'gdpr.subscriber_purged_from_ads',
status: 'info',
title: "Subscriber {$email} purged from all ad audiences",
description: 'Customer-triggered GDPR right-to-delete from subscriber detail page.',
);
if ($request->ajax() || $request->expectsJson()) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/subscribers.gdpr.purged_from_ads'),
], 202);
}
$request->session()->flash('alert-success', trans('refactor/subscribers.gdpr.purged_from_ads'));
return redirect()->route('refactor.lists.subscriber_edit', [$list->uid, $subscriber->id]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Tag Management (AJAX)
* ─────────────────────────────────────────────────────── */
public function subscriberUpdateTags(Request $request, $uid, $id)
{
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$tags = array_filter(array_map('trim', explode(',', $request->tags ?? '')));
$subscriber->updateTags($tags, merge: true);
return response()->json([
'message' => trans('refactor/subscribers.flash.tags_updated'),
'tags' => $subscriber->getTags(),
]);
}
public function subscriberRemoveSingleTag(Request $request, $uid, $id)
{
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$tag = $request->tag;
$tags = array_filter($subscriber->getTags(), fn($t) => $t !== $tag);
$subscriber->updateTags($tags);
return response()->json([
'message' => trans('refactor/subscribers.flash.tag_removed'),
'tags' => $subscriber->getTags(),
]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Email Verification (from edit page)
* ─────────────────────────────────────────────────────── */
public function subscriberStartVerification(Request $request, $uid, $id)
{
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$serverId = $request->email_verification_server_id;
$server = \App\Model\EmailVerificationServer::find($serverId);
if (!$server) {
return back()->withErrors(['email_verification_server_id' => trans('refactor/subscribers.verification.server_required')]);
}
$subscriber->verify($server);
$request->session()->flash('alert-success', trans('refactor/subscribers.verification.started'));
return redirect()->route('refactor.lists.subscriber_edit', [$uid, $id]);
}
public function subscriberResetVerification(Request $request, $uid, $id)
{
$subscriber = Subscriber::find($id);
if (!$subscriber || Gate::denies('update', $subscriber)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$subscriber->resetVerification();
$request->session()->flash('alert-success', trans('refactor/subscribers.verification.reset'));
return redirect()->route('refactor.lists.subscriber_edit', [$uid, $id]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Export
* ─────────────────────────────────────────────────────── */
public function subscriberExport(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('export', $list)) {
return $this->notAuthorized();
}
$segments = $list->segments()->get()->map(fn($s) => [
'uid' => $s->uid,
'name' => $s->name,
]);
// Check for existing export job
$currentJobUid = null;
$progressCheckUrl = null;
$cancelUrl = null;
$downloadUrl = null;
$activeJob = $list->exportJobs()->whereIn('status', ['running', 'queued'])->first();
if ($activeJob) {
$currentJobUid = $activeJob->uid;
$progressCheckUrl = route('refactor.lists.export_progress', ['uid' => $list->uid, 'job_uid' => $activeJob->uid]);
$cancelUrl = route('refactor.lists.cancel_export', ['uid' => $list->uid, 'job_uid' => $activeJob->uid]);
$downloadUrl = route('refactor.lists.download_export', ['uid' => $list->uid, 'job_uid' => $activeJob->uid]);
}
return view('refactor.pages.lists.subscriber_export', [
'list' => $list,
'segments' => $segments,
'subscriberCount' => intval(AppCache::for($list)->read('SubscriberCount', 0)),
'fieldCount' => $list->fields()->count(),
'currentJobUid' => $currentJobUid,
'progressCheckUrl' => $progressCheckUrl,
'cancelUrl' => $cancelUrl,
'downloadUrl' => $downloadUrl,
]);
}
/* ───────────────────────────────────────────────────────
| Subscriber Import
* ─────────────────────────────────────────────────────── */
public function subscriberImport(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('import', $list)) {
return $this->notAuthorized();
}
$customer = \Auth::user()->customer;
$maxSubscribers = $customer->maxSubscribers();
// Compare against the CUSTOMER-wide subscriber count (MAX_SUBSCRIBERS is a
// customer-level quota). Reading the per-list cache here produced a wrong
// progress bar when the customer had multiple lists.
$subscriberCount = intval(AppCache::for($customer->local())->read('SubscriberCount', 0));
// Recent import jobs (last 5)
$recentJobs = $list->importJobs()
->orderBy('updated_at', 'desc')
->limit(5)
->get();
return view('refactor.pages.lists.subscriber_import', [
'list' => $list,
'subscriberCount' => $subscriberCount,
'maxSubscribers' => $maxSubscribers,
'recentJobs' => $recentJobs,
]);
}
/**
* Subscriber timeline — activity feed
*/
public function subscriberTimeline(Request $request, $uid, $id)
{
$list = MailList::findByUid($uid);
$subscriber = \App\Model\Subscriber::find($id);
if (!$list || !$subscriber || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
return view('refactor.pages.lists.subscriber_timeline', [
'list' => $list,
'subscriber' => $subscriber,
]);
}
/**
* Subscriber timeline — AJAX listing
*/
public function subscriberTimelineListing(Request $request, $uid, $id)
{
$subscriber = \App\Model\Subscriber::find($id);
if (!$subscriber) {
return response()->json(['error' => 'Not found'], 404);
}
$timelines = $subscriber->timelines();
if ($request->type) {
if ($request->type == 'automation') {
$timelines = $timelines->whereNotNull('activity');
} else {
$timelines = $timelines->whereType($request->type);
}
}
$timelines = $timelines->orderBy('created_at', 'desc')
->paginate($request->per_page ?? 15);
return view('refactor.pages.lists._subscriber_timeline_list', [
'timelines' => $timelines,
'subscriber' => $subscriber,
]);
}
/**
* Import wizard — Step 1: Upload CSV (popup view)
*/
public function importWizardUpload(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('import', $list)) {
return $this->notAuthorized();
}
return view('refactor.pages.lists.import.upload', [
'list' => $list,
]);
}
/**
* Import wizard — Step 2: Field mapping (popup view)
*/
public function importWizardMapping(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('import', $list)) {
return $this->notAuthorized();
}
$filepath = $request->filepath;
try {
list($headers, $total, $results) = \App\Library\Import\DriverRegistry::for($filepath)->read($filepath);
} catch (\Throwable $e) {
return response()->json([
'message' => $e->getMessage(),
], 404);
}
return view('refactor.pages.lists.import.mapping', [
'list' => $list,
'headers' => $headers,
'filepath' => $filepath,
]);
}
/**
* Import wizard — Step 3: Duplicate-handling settings (popup view)
*/
public function importWizardSettings(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SubscriberController::class)->import2Settings($request);
}
/**
* Import wizard — Step 4: Progress (popup view)
*/
public function importWizardProgress(Request $request, $uid)
{
$list = MailList::findByUid($uid);
$customer = \Auth::user()->customer;
if (!$list || Gate::denies('import', $list)) {
return $this->notAuthorized();
}
// Check cronjob health
$lastExecutedTimeUtc = \Carbon\Carbon::createFromTimestamp(\App\Model\Setting::get('cronjob_last_execution') ?? 0);
$now = \Carbon\Carbon::now();
$lastExecutedTimeDiffInMinutes = $now->diffInMinutes($lastExecutedTimeUtc, true);
$threshold = 20;
$cronjobWarning = null;
if ($lastExecutedTimeDiffInMinutes > $threshold) {
$cronjobWarning = $lastExecutedTimeUtc->timezone($customer->getTimezone());
}
return view('refactor.pages.lists.import.progress', [
'list' => $list,
'job_uid' => $request->job_uid,
'cronjobWarning' => $cronjobWarning,
]);
}
/* ═══════════════════════════════════════════════════════
| SEGMENTS — Create / Edit / Delete / Subscribers
* ═══════════════════════════════════════════════════════ */
public function segmentCreate(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$segment = new \App\Model\Segment();
$segment->mail_list_id = $list->id;
$segment->customer_id = $list->customer_id;
if (Gate::denies('create', $segment)) {
return $this->notAuthorized();
}
// Repopulate old input
if (isset($request->old()['conditions'])) {
$segment->fill($request->old());
$segment->segmentConditions = collect();
foreach ($request->old()['conditions'] as $key => $item) {
$condition = new \App\Model\SegmentCondition();
$condition->customer_id = $segment->customer_id;
$condition->uid = $key;
$condition->fill($item);
$segment->segmentConditions->push($condition);
}
}
return view('refactor.pages.lists.segment_form', [
'list' => $list,
'segment' => $segment,
'mode' => 'create',
]);
}
public function segmentStore(Request $request, $uid)
{
$list = MailList::findByUid($uid);
$segment = new \App\Model\Segment();
$segment->mail_list_id = $list->id;
$segment->customer_id = $list->customer_id;
if (Gate::denies('create', $segment)) {
return $this->notAuthorized();
}
$rules = \App\Model\Segment::$rules;
$empty = false;
if (isset($request->conditions)) {
foreach ($request->conditions as $key => $param) {
$rules['conditions.' . $key . '.field_id'] = 'required';
$rules['conditions.' . $key . '.operator'] = 'required';
if (!in_array($param['operator'] ?? '', ['blank', 'not_blank'])) {
$rules['conditions.' . $key . '.value'] = 'required';
}
}
} else {
$empty = true;
}
if ($empty) {
$rules['segment_conditions_empty'] = 'required';
}
$this->validate($request, $rules);
$segment->fill($request->all());
$segment->save();
$segment->updateConditions($request->conditions);
$segment->updateCacheDelayed();
(new \App\ActivityLog\Activities\SegmentCreated($segment, $request->user()))->record();
$request->session()->flash('alert-success', trans('refactor/segments.flash.created'));
return redirect()->route('refactor.lists.segments', $list->uid);
}
public function segmentEdit(Request $request, $uid, $seg_uid)
{
$list = MailList::findByUid($uid);
$segment = \App\Model\Segment::findByUid($seg_uid);
if (!$list || !$segment || Gate::denies('update', $segment)) {
return $this->notAuthorized();
}
// Repopulate old input
if (isset($request->old()['conditions'])) {
$segment->fill($request->old());
$segment->segmentConditions = collect();
foreach ($request->old()['conditions'] as $key => $item) {
$condition = new \App\Model\SegmentCondition();
$condition->customer_id = $segment->customer_id;
$condition->uid = $key;
$condition->fill($item);
$segment->segmentConditions->push($condition);
}
}
return view('refactor.pages.lists.segment_form', [
'list' => $list,
'segment' => $segment,
'mode' => 'edit',
]);
}
public function segmentUpdate(Request $request, $uid, $seg_uid)
{
$list = MailList::findByUid($uid);
$segment = \App\Model\Segment::findByUid($seg_uid);
if (!$segment || Gate::denies('update', $segment)) {
return $this->notAuthorized();
}
$rules = \App\Model\Segment::$rules;
$empty = false;
if (isset($request->conditions)) {
foreach ($request->conditions as $key => $param) {
$rules['conditions.' . $key . '.field_id'] = 'required';
$rules['conditions.' . $key . '.operator'] = 'required';
if (!in_array($param['operator'] ?? '', ['blank', 'not_blank'])) {
$rules['conditions.' . $key . '.value'] = 'required';
}
}
} else {
$empty = true;
}
if ($empty) {
$rules['segment_conditions_empty'] = 'required';
}
$this->validate($request, $rules);
$segment->fill($request->all());
$segment->save();
$segment->updateConditions($request->conditions);
$segment->updateCacheDelayed();
(new \App\ActivityLog\Activities\SegmentUpdated($segment, $request->user()))->record();
$request->session()->flash('alert-success', trans('refactor/segments.flash.updated'));
return redirect()->route('refactor.lists.segments', $list->uid);
}
public function segmentDelete(Request $request, $uid)
{
$list = MailList::findByUid($uid);
$uids = is_array($request->uids) ? $request->uids : explode(',', $request->uids);
$deleted = 0;
foreach ($uids as $segUid) {
$segment = \App\Model\Segment::findByUid($segUid);
if ($segment && !Gate::denies('delete', $segment)) {
$segment->delete();
$deleted++;
}
}
if ($request->ajax()) {
return response()->json(['message' => $deleted . ' ' . trans('refactor/segments.flash.deleted')]);
}
$request->session()->flash('alert-success', $deleted . ' ' . trans('refactor/segments.flash.deleted'));
return redirect()->route('refactor.lists.segments', $list->uid);
}
public function segmentSubscribers(Request $request, $uid, $seg_uid)
{
$list = MailList::findByUid($uid);
$segment = \App\Model\Segment::findByUid($seg_uid);
if (!$list || !$segment || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$fields = $list->getFields->whereNotIn('tag', ['EMAIL']);
$subCount = $segment->subscribers()->count();
return view('refactor.pages.lists.segment_subscribers', [
'list' => $list,
'segment' => $segment,
'fields' => $fields,
'subCount' => $subCount,
]);
}
public function segmentSubscribersListing(Request $request, $uid, $seg_uid)
{
$list = MailList::findByUid($uid);
$segment = \App\Model\Segment::findByUid($seg_uid);
if (!$list || !$segment || Gate::denies('read', $list)) {
return response('', 403);
}
$query = $segment->subscribers();
if ($request->keyword) {
$query->search($request->keyword, $list->fields);
}
if ($request->status) {
$query->where('status', $request->status);
}
$sortBy = $request->sort_order ?: 'created_at';
$sortDir = $request->sort_direction ?: 'desc';
$query->orderBy($sortBy, $sortDir);
$subscribers = $query->paginate($request->per_page ?: 50);
return view('refactor.pages.lists._segment_subscriber', [
'list' => $list,
'segment' => $segment,
'subscribers' => $subscribers,
]);
}
/* ═══════════════════════════════════════════════════════
| FORMS / PAGES — List Templates
* ═══════════════════════════════════════════════════════ */
public function listTemplates(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$templates = $list->mailListTemplates()->orderBy('id', 'asc')->get();
// Group by category
$subscribe = $templates->filter(fn($t) => str_starts_with($t->alias, 'sign_up_') || $t->alias === 'subscribe_notification_for_list_owner');
$unsubscribe = $templates->filter(fn($t) => str_starts_with($t->alias, 'unsubscribe_'));
$profile = $templates->filter(fn($t) => str_starts_with($t->alias, 'profile_'));
// Hide confirmation templates when double opt-in is off
if (!$list->subscribe_confirmation) {
$confirmationAliases = ['sign_up_confirmation_page', 'sign_up_confirmation_email'];
$subscribe = $subscribe->reject(fn($t) => in_array($t->alias, $confirmationAliases));
}
return view('refactor.pages.lists.templates_index', [
'list' => $list,
'templates' => $templates,
'subscribe' => $subscribe,
'unsubscribe' => $unsubscribe,
'profile' => $profile,
]);
}
public function listTemplateUpdate(Request $request, $uid, $alias)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$mailListTemplate = $list->getTemplate($alias);
if (!$mailListTemplate) {
abort(404);
}
// Check if this template type supports outside URL
$supportsOutsideUrl = in_array($alias, [
'sign_up_confirmation_page',
'sign_up_thankyou_page',
'unsubscribe_success_page',
'profile_update_email_sent',
'profile_update_success_page',
]);
$useOutsideUrl = $list->getAttributes()[$alias . '_use_outside_url'] ?? false;
$outsideUrl = $list->getAttributes()[$alias . '_outside_url'] ?? '';
// Sign-up form URL (for sign_up_form template)
$signUpUrl = null;
if ($alias === 'sign_up_form') {
$signUpUrl = action('Pub\MailListController@signUpForm', [
'list_uid' => $list->uid,
'customer_uid' => $list->customer->uid,
]);
}
return view('refactor.pages.lists.template_update', [
'list' => $list,
'mailListTemplate' => $mailListTemplate,
'alias' => $alias,
'supportsOutsideUrl' => $supportsOutsideUrl,
'useOutsideUrl' => $useOutsideUrl,
'outsideUrl' => $outsideUrl,
'signUpUrl' => $signUpUrl,
]);
}
/* ───────────────────────────────────────────────────────
| Embedded Form
* ─────────────────────────────────────────────────────── */
public function embeddedForm(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('read', $list)) {
return $this->notAuthorized();
}
$customer = $request->user()->customer;
// Handle POST — save embedded form options
if ($request->isMethod('post')) {
$list->setEmbeddedFormOptions($request->options);
return redirect()->route('refactor.lists.embedded_form', $list->uid)
->with('alert-success', trans('refactor/templates_pages.embedded.options_saved'));
}
// Build embedded form URL (public subscribe endpoint)
$formUrl = route('refactor.lists.embedded_form_subscribe', ['uid' => $list->uid]);
// Determine which fields to show
$onlyRequired = $list->getEmbeddedFormOption('only_required_fields') === 'yes';
$showInvisible = $list->getEmbeddedFormOption('show_invisible') === 'yes';
$fields = $list->getFields;
if (!$showInvisible) {
$fields = $fields->where('visible', true);
}
if ($onlyRequired) {
$fields = $fields->where('required', true);
}
// Build embed code HTML
$includeStylesheet = $list->getEmbeddedFormOption('stylesheet') === 'yes';
$includeJs = $list->getEmbeddedFormOption('javascript') === 'yes';
$formTitle = $list->getEmbeddedFormOption('form_title') ?: 'Subscribe';
$embedCode = '';
if ($includeStylesheet) {
$embedCode .= '<style>' . "\n";
$embedCode .= '.form-subscribe { max-width: 400px; font-family: -apple-system, sans-serif; }' . "\n";
$embedCode .= '.form-subscribe .form-group { margin-bottom: 12px; }' . "\n";
$embedCode .= '.form-subscribe label { display: block; margin-bottom: 4px; font-weight: 500; font-size: 14px; }' . "\n";
$embedCode .= '.form-subscribe input[type="text"], .form-subscribe input[type="email"] { width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; box-sizing: border-box; }' . "\n";
$embedCode .= '.form-subscribe button { padding: 10px 20px; background: #2C9C8B; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }' . "\n";
$embedCode .= '.form-subscribe button:hover { background: #248075; }' . "\n";
$embedCode .= '</style>' . "\n";
}
$embedCode .= '<form method="POST" action="' . $formUrl . '" class="form-subscribe">' . "\n";
if ($formTitle) {
$embedCode .= ' <h3>' . e($formTitle) . '</h3>' . "\n";
}
$embedCode .= ' <input type="hidden" name="customer_uid" value="' . e($customer->uid) . '">' . "\n";
foreach ($fields as $field) {
$required = $field->required ? ' required' : '';
$type = $field->tag === 'EMAIL' ? 'email' : 'text';
$embedCode .= ' <div class="form-group">' . "\n";
$embedCode .= ' <label>' . e($field->label) . ($field->required ? ' *' : '') . '</label>' . "\n";
$embedCode .= ' <input type="' . $type . '" name="' . e($field->tag) . '" placeholder="' . e($field->label) . '"' . $required . '>' . "\n";
$embedCode .= ' </div>' . "\n";
}
$embedCode .= ' <button type="submit">' . e(trans('refactor/templates_pages.embedded.subscribe_btn')) . '</button>' . "\n";
$embedCode .= '</form>';
if ($includeJs) {
$embedCode .= "\n" . '<script>' . "\n";
$embedCode .= 'document.querySelector(".form-subscribe").addEventListener("submit", function(e) {' . "\n";
$embedCode .= ' e.preventDefault();' . "\n";
$embedCode .= ' var form = this;' . "\n";
$embedCode .= ' var btn = form.querySelector("button");' . "\n";
$embedCode .= ' btn.disabled = true; btn.textContent = "Subscribing...";' . "\n";
$embedCode .= ' fetch(form.action, { method: "POST", body: new FormData(form) })' . "\n";
$embedCode .= ' .then(function(r) { return r.text(); })' . "\n";
$embedCode .= ' .then(function() { btn.textContent = "Subscribed!"; form.reset(); setTimeout(function(){ btn.disabled = false; btn.textContent = "Subscribe"; }, 3000); })' . "\n";
$embedCode .= ' .catch(function() { btn.disabled = false; btn.textContent = "Subscribe"; });' . "\n";
$embedCode .= '});' . "\n";
$embedCode .= '</script>';
}
// Sign-up form URL (public page)
$signUpUrl = action('Pub\MailListController@signUpForm', [
'list_uid' => $list->uid,
'customer_uid' => $customer->uid,
]);
return view('refactor.pages.lists.embedded_form', [
'list' => $list,
'customer' => $customer,
'formUrl' => $formUrl,
'signUpUrl' => $signUpUrl,
'fields' => $fields,
'embedCode' => $embedCode,
]);
}
// ─── Subscriber proxy methods ───────────────────────────────
public function subscriberSubscribe(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$customer = $request->user()->customer;
$subscribers = $this->resolveSubscribers($request, $list)->get();
$count = 0;
foreach ($subscribers as $subscriber) {
if (Gate::allows('subscribe', $subscriber)) {
$subscriber->subscribe([
'message_id' => null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '#unknown',
]);
(new \App\ActivityLog\Activities\SubscriberSubscribed($subscriber, $request->user()))->record();
\App\Model\Timeline::recordSubscribedByCustomer($subscriber, $customer);
$count++;
}
}
return response()->json(['message' => $count . ' subscriber(s) subscribed']);
}
public function subscriberUnsubscribe(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$customer = $request->user()->customer;
$subscribers = $this->resolveSubscribers($request, $list)->get();
$count = 0;
foreach ($subscribers as $subscriber) {
if (Gate::allows('unsubscribe', $subscriber)) {
$subscriber->unsubscribe([
'message_id' => null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '#unknown',
]);
(new \App\ActivityLog\Activities\SubscriberUnsubscribed($subscriber, $request->user()))->record();
\App\Model\Timeline::recordUnsubscribedByCustomer($subscriber, $customer);
$count++;
}
}
return response()->json(['message' => $count . ' subscriber(s) unsubscribed']);
}
/**
* Subscriber bulk action: delete selected by IDs (or all matching filters).
*/
public function subscriberDeleteSelected(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (!$list || Gate::denies('update', $list)) {
return response()->json(['message' => 'Not authorized'], 403);
}
$subscribers = $this->resolveSubscribers($request, $list)->get();
$count = 0;
foreach ($subscribers as $subscriber) {
if (Gate::allows('delete', $subscriber)) {
$subscriber->delete();
$count++;
}
}
return response()->json(['message' => $count . ' subscriber(s) deleted']);
}
public function subscriberSaveColumns(Request $request)
{
return app(\App\Http\Controllers\SubscriberController::class)->saveColumnsSettings($request);
}
public function import2Upload(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SubscriberController::class)->import2Upload($request);
}
public function import2Validate(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SubscriberController::class)->import2Validate($request);
}
public function import2Run(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SubscriberController::class)->import2Run($request);
}
public function import2ProgressContent(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SubscriberController::class)->import2ProgressContent($request);
}
public function cancelImport(Request $request, $job_uid)
{
$request->merge(['job_uid' => $job_uid]);
return app(\App\Http\Controllers\SubscriberController::class)->cancelImport($request);
}
public function downloadImportLog(Request $request, $job_uid)
{
$request->merge(['job_uid' => $job_uid]);
return app(\App\Http\Controllers\SubscriberController::class)->downloadImportLog($request);
}
public function dispatchExportJob(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
$oldController = app(\App\Http\Controllers\SubscriberController::class);
$response = $oldController->dispatchExportJob($request);
// Rewrite URLs from old routes to refactor routes
$data = $response->getData(true);
$list = \App\Model\MailList::findByUid($uid);
$jobUid = $data['currentJobUid'] ?? $data['currentExportJobUid'] ?? null;
if ($jobUid) {
$data['currentExportJobUid'] = $jobUid;
$data['progressCheckUrl'] = route('refactor.lists.export_progress', ['uid' => $list->uid, 'job_uid' => $jobUid]);
$data['cancelUrl'] = route('refactor.lists.cancel_export', ['uid' => $list->uid, 'job_uid' => $jobUid]);
$data['downloadUrl'] = route('refactor.lists.download_export', ['uid' => $list->uid, 'job_uid' => $jobUid]);
}
return response()->json($data);
}
public function exportProgress(Request $request, $uid, $job_uid)
{
$request->merge(['job_uid' => $job_uid]);
return app(\App\Http\Controllers\SubscriberController::class)->exportProgress($request);
}
public function cancelExport(Request $request, $uid, $job_uid)
{
$request->merge(['job_uid' => $job_uid]);
return app(\App\Http\Controllers\SubscriberController::class)->cancelExport($request);
}
public function downloadExportedFile(Request $request, $uid, $job_uid)
{
$request->merge(['job_uid' => $job_uid]);
return app(\App\Http\Controllers\SubscriberController::class)->downloadExportedFile($request);
}
public function embeddedFormSubscribe(Request $request, $uid)
{
$request->merge(['uid' => $uid]);
$subscriberService = app(SubscriberService::class);
$customerUid = $request->customer_uid;
$customer = \App\Model\Customer::findByUid($customerUid);
if (is_null($customer)) {
throw new \Exception('Cannot find customer');
} else {
$customer->setUserDbConnection();
}
$list = \App\Model\MailList::findByUid($request->uid);
if (\App\Model\Setting::isYes('embedded_form_recaptcha')) {
if ($request->hasCaptcha) {
// Fail-open: any vendor rejection still lets the request through
// but dispatches an admin notification. See
// App\Library\Tool::verifyCaptchaOrAlert.
$success = \App\Library\Tool::verifyCaptchaOrAlert($request, 'embedded-form');
} else {
return view('lists.embedded_form_captcha', [
'list' => $list,
]);
}
} else {
$success = true;
}
if ($list->customer && $list->customer->language) {
\App::setLocale($list->customer->language->code);
\Carbon\Carbon::setLocale($list->customer->language->code);
}
if (!$list) {
return view('somethingWentWrong', ['message' => trans('messages.embedded_form.list_not_exsit')]);
}
if (!$success) {
$url = $request->session()->pull('form_url');
$errs = [trans('messages.invalid_captcha')];
return view('lists.embedded_form_captcha_invalid', [
'errors' => collect($errs),
'list' => $list,
'back_link' => $url,
]);
}
try {
[$validator, $subscriber] = $subscriberService->optInFromEmbeddedForm($list, $request);
} catch (\Exception $ex) {
return view('somethingWentWrong', ['message' => $ex->getMessage()]);
}
if ($validator->fails()) {
$url = $request->session()->pull('form_url');
$errs = [];
foreach ($validator->errors()->toArray() as $key => $error) {
$field = $list->getFieldByTag($key);
$errs[] = $error[0];
}
if (strpos($url, '?') !== false) {
$url = $url . '&' . implode('&', $errs);
} else {
$url = $url . '?' . implode('&', $errs);
}
return view('lists.embedded_form_errors', [
'errors' => collect($errs),
'list' => $list,
'back_link' => $url,
]);
}
if ($request->redirect_url) {
return redirect()->away($request->redirect_url);
} elseif ($list->subscribe_confirmation && !$subscriber->isSubscribed()) {
return redirect()->action('Pub\MailListController@signUpThankyouPage', ['list_uid' => $list->uid, 'subscriber_id' => $subscriber->id, 'customer_uid' => $list->customer->uid]);
} else {
return redirect()->away($subscriberService->generateSignedConfirmationUrl($subscriber));
}
}
// ─── List management proxy methods ──────────────────────────
public function saveOutsideUrls(Request $request, $uid)
{
$list = MailList::findByUid($uid);
if (Gate::denies('update', $list)) {
return $this->notAuthorized();
}
if ($request->has('sign_up_confirmation_page_use_outside_url')) {
$list->sign_up_confirmation_page_use_outside_url = $request->sign_up_confirmation_page_use_outside_url;
$list->sign_up_confirmation_page_outside_url = $request->sign_up_confirmation_page_outside_url;
}
if ($request->has('sign_up_thankyou_page_use_outside_url')) {
$list->sign_up_thankyou_page_use_outside_url = $request->sign_up_thankyou_page_use_outside_url;
$list->sign_up_thankyou_page_outside_url = $request->sign_up_thankyou_page_outside_url;
}
if ($request->has('unsubscribe_success_page_use_outside_url')) {
$list->unsubscribe_success_page_use_outside_url = $request->unsubscribe_success_page_use_outside_url;
$list->unsubscribe_success_page_outside_url = $request->unsubscribe_success_page_outside_url;
}
if ($request->has('profile_update_email_sent_use_outside_url')) {
$list->profile_update_email_sent_use_outside_url = $request->profile_update_email_sent_use_outside_url;
$list->profile_update_email_sent_outside_url = $request->profile_update_email_sent_outside_url;
}
if ($request->has('profile_update_success_page_use_outside_url')) {
$list->profile_update_success_page_use_outside_url = $request->profile_update_success_page_use_outside_url;
$list->profile_update_success_page_outside_url = $request->profile_update_success_page_outside_url;
}
$list->save();
return redirect()->back();
}
public function resetVerification(Request $request, $uid)
{
$list = \App\Model\MailList::findByUid($uid);
if (!$list || \Gate::denies('update', $list)) {
return $this->notAuthorized();
}
$list->resetVerification();
if ($request->ajax()) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/verification.control.reset_success'),
]);
}
return redirect()->route('refactor.lists.verification', $uid);
}
public function startVerification(Request $request, $uid)
{
$request->merge(['uid' => $uid]);
$list = MailList::findByUid($request->uid);
$customer = $request->user()->customer;
if (!EntitlementGate::allows($customer, EntitlementKey::HAS_OWN_EMAIL_VERIFICATION_SERVER)) {
$servers = $customer->getCurrentActiveSubscription()->plan->getEmailVerificationServers();
if ($servers->isEmpty()) {
return response()->json([
'status' => 'error',
'message' => trans('messages.email_verification_server.not_found'),
], 400);
}
$server = $servers->random();
} else {
$server = \App\Model\EmailVerificationServer::findByUid($request->email_verification_server_id);
}
$subscription = $customer->getCurrentActiveSubscription();
$driver = $server->driver();
if ($driver instanceof \App\Verification\Capabilities\SupportsBulkVerify) {
$job = $list->dispatchBulkVerificationJob($server, $subscription);
} else {
$job = $list->dispatchVerificationJob($server, $subscription);
}
return response()->json([
'currentJobUid' => $job->uid,
'cancelUrl' => route('refactor.lists.stop_verification', ['uid' => $list->uid, 'job_uid' => $job->uid]),
'progressCheckUrl' => route('refactor.lists.verification_progress', ['uid' => $list->uid, 'job_uid' => $job->uid]),
]);
}
public function stopVerification(Request $request, $uid, $job_uid)
{
$list = MailList::findByUid($uid);
$job = $list->verificationJobs()->where('uid', $job_uid)->first();
if (!is_null($job)) {
$job->cancel();
}
return response()->json();
}
public function verificationProgress(Request $request, $uid, $job_uid)
{
$list = MailList::findByUid($uid);
$job = $list->verificationJobs()->where('uid', $job_uid)->first();
if (is_null($job)) {
throw new \Exception(sprintf('Verification job #%s does not exist', $job_uid));
}
$progress = [
'percentage' => round($list->getVerifiedSubscribersPercentage() * 100, 2),
'verified' => $list->getVerifiedSubscribersCount(),
'total' => $list->getSubscribersCount(),
'status' => $job->status,
'error' => $job->error,
];
return response()->json($progress);
}
// ─── Segment proxy methods ──────────────────────────────────
public function segmentConditionValue(Request $request)
{
$field = \App\Model\Field::findByUid($request->field_uid);
return view('refactor.pages.lists._condition_value_control', [
'operator' => $request->operator,
'field_uid' => $request->field_uid,
'field' => $field,
'index' => $request->index,
'value' => $request->value,
]);
}
public function segmentSampleCondition(Request $request, $uid)
{
$request->merge(['list_uid' => $uid]);
return app(\App\Http\Controllers\SegmentController::class)->sample_condition($request);
}
// ─── MailListTemplate proxy methods ─────────────────────────
public function templatePreview(Request $request, $uid)
{
return app(\App\Http\Controllers\MailListTemplateController::class)->preview($request, $uid);
}
public function templateBuilder(Request $request, $uid)
{
// POST (save content) — delegate to old controller update
if ($request->isMethod('post')) {
$mailListTemplate = \App\Model\MailListTemplate::findByUid($uid);
$list = $mailListTemplate->mailList;
return app(\App\Http\Controllers\MailListTemplateController::class)->update($request, $list->uid, $mailListTemplate->alias);
}
// GET — render builder with refactor routes
$mailListTemplate = \App\Model\MailListTemplate::findByUid($uid);
$list = $mailListTemplate->mailList;
if (Gate::denies('update', $list)) {
return $this->notAuthorized();
}
// Visual builder cannot edit uploaded raw HTML (json is a placeholder).
// Force user to reset to default first.
if ($mailListTemplate->template?->isUploaded()) {
$request->session()->flash('alert-error', trans('refactor/templates_pages.custom_html.builder_blocked'));
return redirect()->route('refactor.lists.template_update', [$list->uid, $mailListTemplate->alias]);
}
return view('mail_list_templates.builder', [
'list' => $list,
'mailListTemplate' => $mailListTemplate,
'templates' => collect([]),
'_refactorSaveUrl' => route('refactor.lists.template_builder', $mailListTemplate->uid),
'_refactorCancelUrl' => route('refactor.lists.template_update', [$list->uid, $mailListTemplate->alias]),
]);
}
public function templateRestoreDefault(Request $request, $uid)
{
$mailListTemplate = \App\Model\MailListTemplate::findByUid($uid);
if (Gate::denies('update', $mailListTemplate->mailList)) {
return $this->notAuthorized();
}
$mailListTemplate->restoreSystemTemplate();
$redirectUrl = route('refactor.lists.template_update', [$mailListTemplate->mailList->uid, $mailListTemplate->alias]);
if ($request->ajax()) {
return response()->json([
'status' => 'success',
'message' => trans('messages.page.reset.success'),
'redirect' => $redirectUrl,
]);
}
$request->session()->flash('alert-success', trans('messages.page.reset.success'));
return redirect($redirectUrl);
}
/**
* Paste raw HTML as the list template's content. GET renders the dual-mode
* (TinyMCE / CodeMirror) editor; POST persists the HTML by either updating
* the existing uploaded Template or creating a new one via TemplateService.
*/
public function templateCustomHtml(Request $request, $uid)
{
$mailListTemplate = \App\Model\MailListTemplate::findByUid($uid);
$list = $mailListTemplate->mailList;
if (Gate::denies('update', $list)) {
return $this->notAuthorized();
}
if ($request->isMethod('post')) {
$this->validate($request, ['html' => 'required|string']);
if ($mailListTemplate->template?->isUploaded()) {
$mailListTemplate->template->updateContent(
\App\Model\Template::DEFAULT_BUILDER_JSON,
$request->html,
);
} else {
\App\Services\TemplateService::for($mailListTemplate)
->setCustomHtml($request->html, $mailListTemplate->alias);
}
if ($request->ajax()) {
return response()->json([
'status' => 'success',
'message' => trans('refactor/templates_pages.custom_html.saved'),
'redirect' => route('refactor.lists.template_update', [$list->uid, $mailListTemplate->alias]),
]);
}
return redirect()->route('refactor.lists.template_update', [$list->uid, $mailListTemplate->alias]);
}
$currentHtml = $mailListTemplate->template?->isUploaded()
? ($mailListTemplate->template->content ?? '')
: '';
return view('refactor.pages.lists.template_custom_html', [
'list' => $list,
'mailListTemplate' => $mailListTemplate,
'alias' => $mailListTemplate->alias,
'currentHtml' => $currentHtml,
]);
}
}