Extract Ladill Servers as standalone app at servers.ladill.com.

VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 19:18:30 +00:00
co-authored by Cursor
commit b6c8ac343f
382 changed files with 67315 additions and 0 deletions
@@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Models\EmailSetting;
use App\Models\User;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use App\Support\MailboxPricing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Account Wallet, Billing, Settings. Money is read from the one platform
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
*/
class AccountController extends Controller
{
public function __construct(
private WalletPaymentService $wallet,
private BillingClient $billing,
private MailboxClient $mailboxes,
private IdentityClient $identity,
) {}
private function topupUrl(): string
{
return 'https://'.config('app.account_domain').'/wallet';
}
/** Wallet: balance + email spend summary + add-funds link. */
public function wallet(): View
{
$account = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
return view('email.account.wallet', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
public function billing(): View
{
$account = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
$paidMailboxes = [];
$monthlyTotalMinor = 0;
try {
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
if (! ($m['is_paid'] ?? false)) {
continue;
}
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
$monthlyTotalMinor += $m['price_minor'];
$paidMailboxes[] = $m;
}
} catch (\Throwable $e) {
report($e);
}
return view('email.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'paidMailboxes' => $paidMailboxes,
'monthlyTotalMinor' => $monthlyTotalMinor,
'topupUrl' => $this->topupUrl(),
]);
}
public function settings(): View
{
$account = ladill_account();
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
return view('email.account.settings', [
'account' => $account,
'settings' => $settings,
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
]);
}
public function linkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'mailbox_address' => ['required', 'email', 'max:255'],
]);
try {
$result = $this->identity->linkMailbox(
(string) $account->public_id,
$data['mailbox_address'],
);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
if ($connectUrl === '') {
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
}
return redirect()->away($connectUrl);
}
public function unlinkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
try {
$this->identity->unlinkMailbox((string) $account->public_id);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
}
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
private function mailboxLinkContext(?User $account): array
{
$linkStatus = ['show_reminder' => false];
$mailboxOptions = [];
if (! $account?->public_id) {
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => false,
];
}
try {
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
}
try {
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
}
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|| ($linkStatus['show_reminder'] ?? false)
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
];
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
{
if ($linkStatus['linked_mailbox'] ?? null) {
return false;
}
$email = strtolower(trim($account->email ?? ''));
if ($email === '') {
return false;
}
foreach ($mailboxOptions as $mailbox) {
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
return false;
}
}
return count($mailboxOptions) > 0;
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
/** @return array<string, mixed> */
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
{
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
return [
'show_reminder' => $needsLink,
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
'linked_mailbox' => null,
'account_email' => $account->email,
];
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
]);
EmailSetting::updateOrCreate(
['user_id' => $account->id],
[
'default_quota_mb' => $data['default_quota_mb'] ?? null,
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
],
);
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
/**
* @return array{0:int,1:array<string,mixed>} [balanceMinor, ledger]
*/
private function billingSnapshot(?string $publicId): array
{
if (! $publicId) {
return [0, []];
}
try {
return [
$this->wallet->balanceMinor(ladill_account()),
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
];
} catch (\Throwable $e) {
report($e);
return [0, []];
}
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Ladill Email Afia chat endpoint. Grounds the assistant in the user's live
* mailboxes/domains so answers are specific.
*/
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> Live email context for grounding. */
private function context(): array
{
$account = ladill_account();
if (! $account) {
return ['signed_in' => 'no'];
}
$pid = (string) $account->public_id;
$ctx = ['signed_in' => 'yes'];
try {
$mailboxes = app(MailboxClient::class)->forUser($pid);
$ctx['mailboxes_total'] = count($mailboxes);
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
} catch (\Throwable) {
}
try {
$domains = app(EmailDomainClient::class)->forUser($pid);
$ctx['domains_total'] = count($domains);
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
} catch (\Throwable) {
}
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
} catch (\Throwable) {
}
return $ctx;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill Email API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
* now: account + mailbox listing). The plaintext token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('email.account.developers', [
'tokens' => $request->user()->tokens()->latest()->get(),
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
'newToken' => session('new_token'),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
]);
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
return redirect()->route('account.developers')
->with('new_token', $token->plainTextToken)
->with('success', 'Token created — copy it now, it wont be shown again.');
}
public function destroy(Request $request, int $token): RedirectResponse
{
$request->user()->tokens()->whereKey($token)->delete();
return redirect()->route('account.developers')->with('success', 'Token revoked.');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Services\EmailDomain\EmailDomainClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/** Email domain setup (add → publish DNS → verify), via the §4-D API. */
class DomainsController extends Controller
{
public function __construct(private EmailDomainClient $domains) {}
private function pid(): string
{
return (string) ladill_account()->public_id;
}
public function index(): View
{
$domains = [];
$error = null;
try {
$domains = $this->domains->forUser($this->pid());
} catch (\Throwable $e) {
report($e);
$error = 'Could not load your domains.';
}
return view('email.domains.index', compact('domains', 'error'));
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate(['domain' => ['required', 'string', 'max:253']]);
try {
$domain = $this->domains->create($this->pid(), strtolower(trim($data['domain'])));
return redirect()->route('email.domains.show', $domain['id'])
->with('success', 'Domain added — publish the DNS records below, then verify.');
} catch (\Throwable $e) {
report($e);
return back()->with('error', 'Could not add that domain. It may already exist.');
}
}
public function show(int $id): View|RedirectResponse
{
try {
$domain = $this->domains->show($this->pid(), $id);
} catch (\Throwable $e) {
return redirect()->route('email.domains.index')->with('error', 'Domain not found.');
}
return view('email.domains.show', ['domain' => $domain]);
}
public function verify(int $id): RedirectResponse
{
try {
$domain = $this->domains->verify($this->pid(), $id);
$ok = $domain['active'] ?? false;
return redirect()->route('email.domains.show', $id)
->with($ok ? 'success' : 'error', $ok ? 'Domain verified — you can now create mailboxes.' : 'Not verified yet. DNS can take time to propagate.');
} catch (\Throwable $e) {
return back()->with('error', 'Verification failed. Try again shortly.');
}
}
public function destroy(int $id): RedirectResponse
{
try {
$this->domains->delete($this->pid(), $id);
} catch (\Throwable $e) {
return back()->with('error', 'Could not remove that domain.');
}
return redirect()->route('email.domains.index')->with('success', 'Domain removed.');
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MailboxLinkBannerController extends Controller
{
public function dismiss(Request $request): RedirectResponse
{
$request->session()->put('mailbox_link_banner_dismissed', true);
return back();
}
}
@@ -0,0 +1,254 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use App\Support\MailboxPricing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
/** My Mailboxes — list/create/manage, via the §4 Mailbox API. */
class MailboxesController extends Controller
{
public function __construct(
private MailboxClient $mailboxes,
private EmailDomainClient $domains,
private WalletPaymentService $wallet,
) {}
private function pid(): string
{
return (string) ladill_account()->public_id;
}
/** Storage tiers + the default (free) plan. Pricing is purely per-tier. */
private function quota(): array
{
return [
'tiers' => MailboxPricing::tiers(),
'default_mb' => MailboxPricing::defaultQuotaMb(),
'currency' => config('email.currency', 'GHS'),
];
}
public function index(Request $request): View
{
$mailboxes = [];
$error = null;
try {
$mailboxes = $this->mailboxes->forUser($this->pid());
} catch (\Throwable $e) {
report($e);
$error = 'Could not load your mailboxes.';
}
$q = trim((string) $request->query('q', ''));
if ($q !== '') {
$needle = mb_strtolower($q);
$mailboxes = array_values(array_filter($mailboxes, fn ($m) => str_contains(
mb_strtolower(($m['address'] ?? '').' '.($m['display_name'] ?? '')), $needle
)));
}
return view('email.mailboxes.index', ['mailboxes' => $mailboxes, 'error' => $error, 'q' => $q]);
}
public function create(): View
{
$domains = [];
try {
$domains = $this->domains->verified($this->pid());
} catch (\Throwable $e) {
report($e);
}
return view('email.mailboxes.create', [
'domains' => $domains,
'quota' => $this->quota(),
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'email_domain_id' => ['required', 'integer'],
'local_part' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9._%+-]+$/i'],
'display_name' => ['required', 'string', 'max:160'],
'password' => ['required', 'string', 'min:8', 'max:190', 'confirmed'],
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
if (! MailboxPricing::isValidQuota((int) $value)) {
$fail('Choose a valid storage plan.');
}
}],
]);
$user = ladill_account();
$quotaMb = (int) $data['quota_mb'];
$price = MailboxPricing::priceMinorFor($quotaMb); // 1 GB = 0 (free forever)
$isPaid = $price > 0;
$reference = 'mbx_'.Str::lower(Str::random(20));
// Paid tiers are wallet-funded (debit first); insufficient → top up.
if ($isPaid) {
if (! $this->wallet->canAfford($user, $price)) {
return back()->withInput()
->with('error', 'Your wallet balance is too low for this storage plan. Please top up and try again.')
->with('topup', true);
}
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_create', 'Mailbox '.strtolower($data['local_part']))) {
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
}
}
try {
$mailbox = $this->mailboxes->create(
$this->pid(),
(int) $data['email_domain_id'],
strtolower(trim($data['local_part'])),
$data['display_name'],
$data['password'],
$quotaMb,
$isPaid,
$isPaid ? $reference : null,
);
return redirect()->route('email.mailboxes.show', $mailbox['id'])
->with('success', 'Mailbox '.$mailbox['address'].' is being set up.');
} catch (\Throwable $e) {
// Provisioning failed after a charge — refund so the customer isn't out of pocket.
if ($isPaid) {
$this->wallet->refund($user, $price, $reference, 'Mailbox creation failed');
}
$msg = $e instanceof \Illuminate\Http\Client\RequestException
? (string) ($e->response?->json('message') ?? 'Could not create the mailbox.')
: 'Could not create the mailbox.';
report($e);
return back()->withInput()->with('error', $msg);
}
}
public function show(int $id): View|RedirectResponse
{
try {
$mailbox = $this->mailboxes->show($this->pid(), $id);
} catch (\Throwable $e) {
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
}
return view('email.mailboxes.show', ['mailbox' => $mailbox]);
}
/** Upgrade form — pick a larger storage plan for an existing mailbox. */
public function upgrade(int $id): View|RedirectResponse
{
try {
$mailbox = $this->mailboxes->show($this->pid(), $id);
} catch (\Throwable $e) {
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
}
$currentMb = (int) ($mailbox['quota_mb'] ?? 0);
// Only offer plans larger than the current one.
$tiers = array_values(array_filter(MailboxPricing::tiers(), fn ($t) => $t['mb'] > $currentMb));
if (empty($tiers)) {
return redirect()->route('email.mailboxes.show', $id)->with('error', 'This mailbox is already on the largest plan.');
}
return view('email.mailboxes.upgrade', [
'mailbox' => $mailbox,
'currentMb' => $currentMb,
'tiers' => $tiers,
'defaultMb' => $tiers[0]['mb'],
'currency' => config('email.currency', 'GHS'),
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
/** Apply the upgrade — charge the new tier, then change the quota. */
public function upgradeStore(Request $request, int $id): RedirectResponse
{
$data = $request->validate([
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
if (! MailboxPricing::isValidQuota((int) $value)) {
$fail('Choose a valid storage plan.');
}
}],
]);
$user = ladill_account();
$newMb = (int) $data['quota_mb'];
try {
$mailbox = $this->mailboxes->show($this->pid(), $id);
} catch (\Throwable $e) {
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
}
if ($newMb <= (int) ($mailbox['quota_mb'] ?? 0)) {
return back()->withInput()->with('error', 'Choose a larger plan than your current one.');
}
$price = MailboxPricing::priceMinorFor($newMb);
$isPaid = $price > 0;
$reference = 'mbxup_'.Str::lower(Str::random(20));
if ($isPaid) {
if (! $this->wallet->canAfford($user, $price)) {
return back()->withInput()
->with('error', 'Your wallet balance is too low for this plan. Please top up and try again.')
->with('topup', true);
}
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_upgrade', 'Upgrade '.($mailbox['address'] ?? 'mailbox').' to '.MailboxPricing::label($newMb))) {
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
}
}
try {
$this->mailboxes->updateQuota($this->pid(), $id, $newMb, $isPaid, $isPaid ? $reference : null);
} catch (\Throwable $e) {
if ($isPaid) {
$this->wallet->refund($user, $price, $reference, 'Mailbox upgrade failed');
}
report($e);
return back()->withInput()->with('error', 'Could not upgrade the mailbox.');
}
return redirect()->route('email.mailboxes.show', $id)
->with('success', 'Upgraded to '.MailboxPricing::label($newMb).'.');
}
public function resetPassword(Request $request, int $id): RedirectResponse
{
$data = $request->validate(['password' => ['required', 'string', 'min:8', 'max:190', 'confirmed']]);
try {
$this->mailboxes->resetPassword($this->pid(), $id, $data['password']);
} catch (\Throwable $e) {
return back()->with('error', 'Could not update the password.');
}
return redirect()->route('email.mailboxes.show', $id)->with('success', 'Password updated.');
}
public function destroy(int $id): RedirectResponse
{
try {
$this->mailboxes->delete($this->pid(), $id);
} catch (\Throwable $e) {
return back()->with('error', 'Could not delete the mailbox.');
}
return redirect()->route('email.mailboxes.index')->with('success', 'Mailbox deleted.');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OverviewController extends Controller
{
public function __construct(
private MailboxClient $mailboxes,
private EmailDomainClient $domains,
) {}
public function index(Request $request): View
{
$pid = (string) ladill_account()->public_id;
$mailboxes = [];
$domains = [];
$error = null;
try {
$mailboxes = $this->mailboxes->forUser($pid);
$domains = $this->domains->forUser($pid);
} catch (\Throwable $e) {
report($e);
$error = 'Some data could not be loaded right now.';
}
return view('email.dashboard', [
'mailboxCount' => count($mailboxes),
'domainCount' => count($domains),
'verifiedDomainCount' => collect($domains)->where('active', true)->count(),
'recent' => collect($mailboxes)->take(5)->all(),
'error' => $error,
]);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Email;
use App\Http\Controllers\Controller;
use App\Models\EmailTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* Ladill Email Team. Invite teammates to an account and manage their roles;
* members can then manage that account's mailboxes & domains. Members accept by
* signing into Email with the invited email (SSO). Owner and admins can manage.
*/
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = EmailTeamMember::query()
->where('account_id', $account->id)
->with('member')
->orderBy('status')->orderBy('email')
->get();
return view('email.account.team', [
'account' => $account,
'members' => $members,
'canManage' => $this->canManage($request),
'isOwner' => $request->user()->id === $account->id,
]);
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->canManage($request), 403);
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'in:admin,member'],
]);
$account = ladill_account();
$email = strtolower($validated['email']);
if ($email === strtolower($account->email)) {
return back()->withErrors(['email' => 'The owner is already on the account.']);
}
$member = EmailTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
$member->role = $validated['role'];
if (! $member->exists) {
$member->status = EmailTeamMember::STATUS_INVITED;
$member->token = Str::random(40);
}
$member->save();
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
EmailTeamMember::linkPendingInvitesFor($existing);
}
return back()->with('success', $email.' invited.');
}
public function updateRole(Request $request, EmailTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
$member->update(['role' => $validated['role']]);
return back()->with('success', 'Role updated.');
}
public function destroy(Request $request, EmailTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$member->delete();
return back()->with('success', 'Member removed.');
}
/** Switch the acting account (own, or one you're a member of). */
public function switchAccount(Request $request): RedirectResponse
{
$validated = $request->validate(['account' => ['required', 'integer']]);
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
$request->session()->put('ladill_account', (int) $validated['account']);
return redirect()->route('email.dashboard');
}
private function canManage(Request $request): bool
{
$user = $request->user();
$account = ladill_account();
if ($user->id === $account->id) {
return true; // owner
}
return $user->memberships()
->where('account_id', $account->id)
->where('role', EmailTeamMember::ROLE_ADMIN)
->exists();
}
}