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:
+16
-1
@@ -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');
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user