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
+16 -1
View File
@@ -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
@@ -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');
+1
View File
@@ -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",
Generated
+127 -1
View File
@@ -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",
+15
View File
@@ -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,
],
],
/*
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class OffsiteBackupTest extends TestCase
{
use RefreshDatabase;
public function test_backup_uploads_to_b2_when_configured(): void
{
$plan = Plan::create(['name' => '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());
}
}