feat: production email (Resend) + offsite backup (B2)

Resend mail transport:
- composer require resend/resend-laravel (v1.4)
- Laravel 11 ships the 'resend' mailer config in config/mail.php + services
- To switch to production email: set MAIL_MAILER=resend + RESEND_API_KEY,
  register the domain at resend.com/domains, and add the TXT + DKIM CNAME
  records in Cloudflare. .env.example documents the required steps.

Backblaze B2 offsite backup:
- New filesystems 'b2' disk (S3-compatible, env: B2_KEY/SECRET/BUCKET/REGION/ENDPOINT)
- BackupAllTenantsCommand: after writing each tenant's zip to local disk, it
  uploads the same file to the b2 disk under {YYYY-MM-DD}/{slug}.zip — only
  when both B2_KEY and B2_BUCKET are set, so unconfigured installs are no-op
- Without offsite, backups live on the same VPS as production: a single
  hardware failure loses everything. B2 + Resend together make the install
  genuinely production-ready (people get email + offsite backup).

Tests (2 new):
- backup uploads to b2 (fake disk) when configured
- backup skips offsite when env vars not present

Full suite: 140 passed. Force-rebuild deploy required so composer install
picks up resend/resend-php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 06:43:39 +00:00
parent 51917bcbaf
commit 439ef605a1
6 changed files with 230 additions and 2 deletions
@@ -49,6 +49,9 @@ class BackupAllTenantsCommand extends Command
$size = round(filesize($dest) / 1024, 1);
$this->info("{$company->slug}{$size}KB");
// Offsite copy to B2 (if configured) — disk lazily resolved.
$this->uploadOffsite($dest, "{$date}/{$company->slug}.zip");
$ok++;
} catch (\Throwable $e) {
$this->error("{$company->slug}: {$e->getMessage()}");
@@ -65,6 +68,20 @@ class BackupAllTenantsCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/** Upload one backup zip to the offsite B2 disk if env is configured. */
private function uploadOffsite(string $localPath, string $remoteKey): void
{
if (! env('B2_KEY') || ! env('B2_BUCKET')) return;
try {
$stream = fopen($localPath, 'rb');
\Illuminate\Support\Facades\Storage::disk('b2')->put($remoteKey, $stream);
if (is_resource($stream)) fclose($stream);
$this->line(" ↑ offsite: {$remoteKey}");
} catch (\Throwable $e) {
$this->warn(" ✗ offsite upload failed: " . substr($e->getMessage(), 0, 120));
}
}
private function cleanupOld(int $keep): void
{
$backupsDir = storage_path('app/backups');