Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s

Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 16:24:20 +00:00
co-authored by Cursor
commit e251a4cf60
367 changed files with 66268 additions and 0 deletions
@@ -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.');
}
}