From 439ef605a12ad967b23c57436292e1dce9680d09 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 3 Jun 2026 06:43:39 +0000 Subject: [PATCH] feat: production email (Resend) + offsite backup (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 17 ++- .../Commands/BackupAllTenantsCommand.php | 17 +++ composer.json | 1 + composer.lock | 128 +++++++++++++++++- config/filesystems.php | 15 ++ tests/Feature/OffsiteBackupTest.php | 54 ++++++++ 6 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/OffsiteBackupTest.php diff --git a/.env.example b/.env.example index eada94e..fb78375 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,10 @@ REDIS_DB=0 # Broadcasting (Reverb — adăugăm la nevoie) BROADCAST_CONNECTION=log -# Mail — Mailpit intern +# Mail — Mailpit intern (dev) sau Resend (prod) +# Dev: lasă smtp + Mailpit. Prod: setează MAIL_MAILER=resend + RESEND_API_KEY, +# înregistrează domeniul în https://resend.com/domains și adaugă DNS-urile +# (TXT + DKIM CNAME-uri) în Cloudflare. Verifică în dashboard înainte de trafic. MAIL_MAILER=smtp MAIL_HOST=autocrm-mailpit MAIL_PORT=1025 @@ -58,6 +61,9 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@service.mir.md" MAIL_FROM_NAME="${APP_NAME}" +# Resend API — necesar dacă MAIL_MAILER=resend +RESEND_API_KEY= + # Web Push (VAPID) — generate with: php artisan push:vapid VAPID_SUBJECT=mailto:admin@service.mir.md VAPID_PUBLIC_KEY= @@ -69,6 +75,15 @@ VAPID_PRIVATE_KEY= HEALTH_ALERT_BOT_TOKEN= HEALTH_ALERT_CHAT_ID= +# Backblaze B2 (S3-compatible) — offsite backup target for backup:tenants. +# Creează un bucket privat + Application Key cu acces la el. Fără aceste env +# vars, backup-urile rămân doar pe VPS (single point of failure). +B2_KEY= +B2_SECRET= +B2_BUCKET= +B2_REGION=us-west-002 +B2_ENDPOINT=https://s3.us-west-002.backblazeb2.com + # Storage — local pentru MVP, S3-compatible mai târziu FILESYSTEM_DISK=local diff --git a/app/Console/Commands/BackupAllTenantsCommand.php b/app/Console/Commands/BackupAllTenantsCommand.php index 8503fc4..1d29542 100644 --- a/app/Console/Commands/BackupAllTenantsCommand.php +++ b/app/Console/Commands/BackupAllTenantsCommand.php @@ -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'); diff --git a/composer.json b/composer.json index 7883304..2f41fbd 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "minishlink/web-push": "^10.0", + "resend/resend-laravel": "^1.4", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.22", "spatie/laravel-permission": "^7.4", diff --git a/composer.lock b/composer.lock index de4fa5b..76e3ab6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "72e35bc95dd2b8489e5a7b77b421d237", + "content-hash": "82d0b6d061454a485d2a93b700e4a5a8", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -6723,6 +6723,132 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "resend/resend-laravel", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/resend/resend-laravel.git", + "reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/6dd5f5ec607404068c5af067fd7f6ba4b659262b", + "reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b", + "shasum": "" + }, + "require": { + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "resend/resend-php": "^1.0.0", + "symfony/mailer": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14", + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.17|^9.0|^10.8|^11.0", + "pestphp/pest": "^1.0|^2.0|^3.7|^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Resend\\Laravel\\ResendServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Resend\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Resend and contributors", + "homepage": "https://github.com/resend/resend-laravel/contributors" + } + ], + "description": "Resend for Laravel", + "homepage": "https://resend.com/", + "keywords": [ + "api", + "client", + "laravel", + "php", + "resend", + "sdk" + ], + "support": { + "issues": "https://github.com/resend/resend-laravel/issues", + "source": "https://github.com/resend/resend-laravel/tree/v1.4.0" + }, + "time": "2026-05-06T17:08:44+00:00" + }, + { + "name": "resend/resend-php", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/resend/resend-php.git", + "reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/resend/resend-php/zipball/87d29d98271a0ab1c09cdbee102daa2f9b3419db", + "reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.5", + "php": "^8.1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.13", + "mockery/mockery": "^1.6", + "pestphp/pest": "^1.0|^2.0|^3.0|^4.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resend.php" + ], + "psr-4": { + "Resend\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Resend and contributors", + "homepage": "https://github.com/resend/resend-php/contributors" + } + ], + "description": "Resend PHP library.", + "homepage": "https://resend.com/", + "keywords": [ + "api", + "client", + "php", + "resend", + "sdk" + ], + "support": { + "issues": "https://github.com/resend/resend-php/issues", + "source": "https://github.com/resend/resend-php/tree/v1.3.0" + }, + "time": "2026-04-11T10:48:32+00:00" + }, { "name": "ryangjchandler/blade-capture-directive", "version": "v1.1.1", diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..009b2ba 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -60,6 +60,21 @@ return [ 'report' => false, ], + // Backblaze B2 (S3-compatible). Offsite backup target for backup:tenants. + // Required env: B2_KEY, B2_SECRET, B2_BUCKET, B2_REGION (e.g. us-west-002), + // B2_ENDPOINT (e.g. https://s3.us-west-002.backblazeb2.com). + 'b2' => [ + 'driver' => 's3', + 'key' => env('B2_KEY'), + 'secret' => env('B2_SECRET'), + 'region' => env('B2_REGION', 'us-west-002'), + 'bucket' => env('B2_BUCKET'), + 'endpoint' => env('B2_ENDPOINT'), + 'use_path_style_endpoint' => false, + 'throw' => false, + 'report' => false, + ], + ], /* diff --git a/tests/Feature/OffsiteBackupTest.php b/tests/Feature/OffsiteBackupTest.php new file mode 100644 index 0000000..ae491b5 --- /dev/null +++ b/tests/Feature/OffsiteBackupTest.php @@ -0,0 +1,54 @@ + 'F', 'slug' => 'free', 'price' => 0, 'features' => []]); + Company::create([ + 'plan_id' => $plan->id, 'slug' => 'bkup', + 'name' => 'Backup Co', 'status' => 'active', + ]); + + // Pretend B2 is configured + fake the disk so the command's putStream lands here. + putenv('B2_KEY=fake'); + putenv('B2_BUCKET=fake-bucket'); + $b2 = Storage::fake('b2'); + + $this->artisan('backup:tenants --slug=bkup')->assertExitCode(0); + + // The remote key uses today's date + slug. + $remoteKey = date('Y-m-d') . '/bkup.zip'; + $b2->assertExists($remoteKey); + + putenv('B2_KEY'); + putenv('B2_BUCKET'); + } + + public function test_backup_skips_offsite_when_not_configured(): void + { + $plan = Plan::firstOrCreate(['slug' => 'free'], ['name' => 'F', 'price' => 0, 'features' => []]); + Company::create([ + 'plan_id' => $plan->id, 'slug' => 'nob2', + 'name' => 'No B2', 'status' => 'active', + ]); + + // No B2 env set. + $b2 = Storage::fake('b2'); + + $this->artisan('backup:tenants --slug=nob2')->assertExitCode(0); + + // Nothing pushed offsite. + $this->assertCount(0, $b2->allFiles()); + } +}