diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 1a5d358c..a9f59316 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.111,"Tests\\Feature\\CalendarEventsTest::test_find_first_sunday_of_june_2026":0.034,"Tests\\Feature\\CalendarEventsTest::test_find_second_saturday_of_june_2026":0.001,"Tests\\Feature\\CalendarEventsTest::test_find_fifth_sunday_returns_null_when_not_in_month":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_for_all_months":0.025,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_respects_selected_months":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_annuale_events_produces_correct_dates":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":0.004}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.005,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.049,"Tests\\Feature\\CalendarEventsTest::test_find_first_sunday_of_june_2026":0.017,"Tests\\Feature\\CalendarEventsTest::test_find_second_saturday_of_june_2026":0.001,"Tests\\Feature\\CalendarEventsTest::test_find_fifth_sunday_returns_null_when_not_in_month":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_for_all_months":0.013,"Tests\\Feature\\CalendarEventsTest::test_generate_mensile_events_respects_selected_months":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_annuale_events_produces_correct_dates":0.001,"Tests\\Feature\\CalendarEventsTest::test_generate_settimanale_events_produces_weekly_occurrences":0.003}} \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index 5508b2bb..48dedc63 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -608,6 +608,72 @@ php artisan route:list --name=email (Last updated: 26 Maggio 2026 - Fix Salvataggio is_admin) +### 26 Maggio 2026 - Sender Accounts (Mittenti Aggiuntivi solo invio) + +- **Nuova tabella**: `sender_accounts` con migration `2026_05_26_000002` +- **Colonne**: `id`, `email_address` (UNIQUE), `email_name`, `smtp_host`, `smtp_port`, `smtp_encryption`, `smtp_username`, `smtp_password` (Crypt), `reply_to`, `verify_email`, `note`, `is_active`, timestamps +- **Model `SenderAccount`**: `$fillable`, `$casts`, metodi: + - `getDecryptedPassword()` — decripta password SMTP con fallback + - `sanitizeName()` — pulisce il nome visualizzato + - `getReplyTo()` — priorità: `reply_to` → `verify_email` → null + - `buildDsn()` — costruisce DSN Symfony Mailer + - `buildSymfonyEmail(to, subject, body)` — costruisce email con From, Reply-To + - `sendEmail(to, subject, body, attachmentPaths)` — invia via Symfony Mailer + - `sendReport(subject, body)` — invia report riepilogo a `verify_email` +- **Logica Reply-To**: se impostato `reply_to` (es. `no-reply@parrocchia.it`) → quello; fallback `verify_email`; nessun Reply-To se entrambi null +- **Report invio**: dopo ogni invio massivo via sender, una email di riepilogo viene inviata a `verify_email` con: mittente, oggetto, totale, ok, falliti, dettaglio errori +- **Migration `2026_05_26_000003`**: aggiunte colonne `log_falliti` (JSON), `mittente_nome`, `mittente_email` a `mailing_messaggi` +- **Model `MailingMessaggio`**: aggiornato `$fillable` e `$casts` per `log_falliti` (array) +- **CRUD in `EmailSettingsController`**: 4 nuovi metodi protetti da `authorizeWrite('settings')`: + - `senderStore()` — crea nuovo sender con password criptata + - `senderUpdate()` — modifica sender, password opzionale + - `senderDestroy()` — elimina sender + - `senderTestSmtp()` — test invio email via sender +- **Routes** (6 nuove): + - `POST /impostazioni/sender` → `senderStore` + - `PUT /impostazioni/sender/{id}` → `senderUpdate` + - `DELETE /impostazioni/sender/{id}` → `senderDestroy` + - `POST /impostazioni/sender/{id}/test` → `senderTestSmtp` + - `GET /mailing/invio` → `MailingController@invio` (prima mancante) + - `POST /mailing/invio/elabora` → `MailingController@invioElabora` (prima mancante) +- **`EmailController@compose`**: passa `$senderAccounts` alla view; nuova view mostra dropdown "Da (mittente)" prima dei destinatari +- **`EmailController@send`** modificato: + - Se `mittente_id` > 0 → usa `SenderAccount::sendEmail()` con allegati; salta store sent message + - Se `mittente_id` = 0 o assente → comportamento attuale con `EmailSetting` + - Processa allegati `allegati[]` e `documenti_selezionati` + - Nuovo helper: `sendWithSender()`, `processAttachments()`, `resolveAttachmentPaths()` + - `sendViaImap()` ora accetta `$attachmentPaths` e li attacha all'email +- **`MailingController`** riscritto con invio SMTP reale: + - `invia()` — invio 1:1 a ogni destinatario, log su `mailing_messaggi` + - `invioElabora()` — invio massivo 1:1 per ogni email da liste multiple, log su `mailing_messaggi` + - Tracking: stato `in_coda` → `inviato`/`parziale`/`fallito`, conteggi ok/fail, log_falliti JSON + - Summary email a `verify_email` dopo invio massivo + - `sendViaSystem()` — invio via EmailSetting (fallback quando nessun sender selezionato) + - `resolveMailingAttachmentPaths()` — risolve path file da Documenti selezionati +- **View `email-settings/index.blade.php`**: + - Nuovo tab "Mittenti Aggiuntivi" nella sidebar + - Tabella con email, nome, SMTP, Reply-To, Verify, Stato, Azioni (Modifica/Test/Elimina) + - Modal CRUD per creare/modificare sender con tutti i campi + - JS: `resetSenderForm()`, `editSender(id)`, `testSenderSmtp(id)` + - Sezione informativa "Come funziona" +- **View `email/compose.blade.php`**: dropdown "Da (mittente)" se ci sono sender attivi (default: Sistema) +- **View `mailing/nuovo.blade.php`**: dropdown "Mittente" se ci sono sender attivi +- **View `mailing/invio.blade.php`**: dropdown "Mittente" se ci sono sender attivi +- **Files creati**: + - `database/migrations/2026_05_26_000002_create_sender_accounts_table.php` + - `database/migrations/2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php` + - `app/Models/SenderAccount.php` +- **Files modificati**: + - `app/Models/MailingMessaggio.php` + - `app/Http/Controllers/Admin/EmailSettingsController.php` + - `app/Http/Controllers/EmailController.php` + - `app/Http/Controllers/MailingController.php` + - `routes/web.php` + - `resources/views/admin/email-settings/index.blade.php` + - `resources/views/email/compose.blade.php` + - `resources/views/mailing/nuovo.blade.php` + - `resources/views/mailing/invio.blade.php` + ### 26 Maggio 2026 - Export ICS/iCal Eventi - **Nuovo Service**: `app/Services/IcsExportService.php` — generazione file ICS RFC 5545 - `generateSingle(Evento)` — esporta un singolo evento diff --git a/app/Http/Controllers/Admin/EmailSettingsController.php b/app/Http/Controllers/Admin/EmailSettingsController.php index 77941307..d23aa526 100644 --- a/app/Http/Controllers/Admin/EmailSettingsController.php +++ b/app/Http/Controllers/Admin/EmailSettingsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\EmailSetting; +use App\Models\SenderAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; @@ -13,7 +14,8 @@ class EmailSettingsController extends Controller { $this->authorizeWrite('settings'); $settings = EmailSetting::first() ?? new EmailSetting(); - return view('admin.email-settings.index', compact('settings')); + $senderAccounts = SenderAccount::orderBy('email_address')->get(); + return view('admin.email-settings.index', compact('settings', 'senderAccounts')); } public function save(Request $request) @@ -183,4 +185,91 @@ class EmailSettingsController extends Controller return back()->with('error', 'Errore sync: ' . $e->getMessage()); } } + + public function senderStore(Request $request) + { + $this->authorizeWrite('settings'); + $data = $request->validate([ + 'email_address' => 'required|email|max:255|unique:sender_accounts,email_address', + 'email_name' => 'nullable|string|max:255', + 'smtp_host' => 'nullable|string|max:255', + 'smtp_port' => 'nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'nullable|in:tls,ssl,none', + 'smtp_username' => 'nullable|string|max:255', + 'smtp_password' => 'nullable|string', + 'reply_to' => 'nullable|email|max:255', + 'verify_email' => 'nullable|email|max:255', + 'note' => 'nullable|string|max:500', + 'is_active' => 'boolean', + ]); + + if (!empty($data['smtp_password'])) { + $data['smtp_password'] = Crypt::encryptString($data['smtp_password']); + } else { + unset($data['smtp_password']); + } + + $sender = SenderAccount::create($data); + + return back()->with('success', 'Mittente "' . $sender->email_address . '" creato con successo.'); + } + + public function senderUpdate(Request $request, int $id) + { + $this->authorizeWrite('settings'); + $sender = SenderAccount::findOrFail($id); + + $data = $request->validate([ + 'email_address' => 'required|email|max:255|unique:sender_accounts,email_address,' . $id, + 'email_name' => 'nullable|string|max:255', + 'smtp_host' => 'nullable|string|max:255', + 'smtp_port' => 'nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'nullable|in:tls,ssl,none', + 'smtp_username' => 'nullable|string|max:255', + 'smtp_password' => 'nullable|string', + 'reply_to' => 'nullable|email|max:255', + 'verify_email' => 'nullable|email|max:255', + 'note' => 'nullable|string|max:500', + 'is_active' => 'boolean', + ]); + + if (!empty($data['smtp_password'])) { + $data['smtp_password'] = Crypt::encryptString($data['smtp_password']); + } else { + unset($data['smtp_password']); + } + + $sender->update($data); + + return back()->with('success', 'Mittente "' . $sender->email_address . '" aggiornato.'); + } + + public function senderDestroy(int $id) + { + $this->authorizeDelete('settings'); + $sender = SenderAccount::findOrFail($id); + $sender->delete(); + + return back()->with('success', 'Mittente eliminato.'); + } + + public function senderTestSmtp(Request $request, int $id) + { + $this->authorizeWrite('settings'); + $request->validate(['email' => 'required|email']); + + $sender = SenderAccount::findOrFail($id); + + try { + $sender->sendEmail( + $request->email, + 'Test Configurazione SMTP - ' . config('app.name'), + 'Test email da ' . config('app.name') . ' - Configurazione SMTP mittente "' . $sender->email_address . '" funziona!' + ); + + return response()->json(['success' => true, 'message' => 'Email di test inviata a ' . $request->email]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Errore invio: ' . $e->getMessage()]); + } + } } \ No newline at end of file diff --git a/app/Http/Controllers/EmailController.php b/app/Http/Controllers/EmailController.php index 658ad289..04198e9a 100644 --- a/app/Http/Controllers/EmailController.php +++ b/app/Http/Controllers/EmailController.php @@ -6,6 +6,7 @@ use App\Models\EmailFolder; use App\Models\EmailMessage; use App\Models\EmailAttachment; use App\Models\EmailSetting; +use App\Models\SenderAccount; use App\Models\MailingList; use App\Models\Gruppo; use App\Models\Individuo; @@ -90,6 +91,7 @@ class EmailController extends Controller $mailingLists = MailingList::where('attiva', true)->get(); $gruppi = Gruppo::orderBy('nome')->get(); $individui = Individuo::orderBy('cognome')->orderBy('nome')->get(); + $senderAccounts = SenderAccount::active()->get(); $prefill = []; if ($request->reply_to) { @@ -108,7 +110,7 @@ class EmailController extends Controller } } - return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill')); + return view('email.compose', compact('folders', 'mailingLists', 'gruppi', 'individui', 'prefill', 'senderAccounts')); } public function send(Request $request) @@ -125,11 +127,6 @@ class EmailController extends Controller $validated = $request->validate($rules); - $settings = EmailSetting::getActive(); - if (!$settings) { - return back()->with('error', 'Account email non configurato.'); - } - $recipients = $this->resolveRecipients($request); \Illuminate\Support\Facades\Log::info('Email send attempt', [ @@ -137,44 +134,94 @@ class EmailController extends Controller 'recipients_count' => count($recipients), 'subject' => $validated['subject'], 'destinatario_tipo' => $request->destinatario_tipo, - 'to' => $validated['to'] ?? null, - 'individui' => $request->individui, - 'gruppo_id' => $request->gruppo_id, - 'mailing_list_id' => $request->mailing_list_id, + 'mittente_id' => $request->mittente_id, ]); if (empty($recipients)) { return back()->with('error', 'Nessun destinatario trovato. Seleziona almeno un destinatario dal menu a tendina o inserisci un indirizzo email manuale.')->withInput(); } + $useSender = $request->filled('mittente_id') && $request->mittente_id > 0; + + if ($useSender) { + return $this->sendWithSender($request, $validated, $recipients); + } + + $settings = EmailSetting::getActive(); + if (!$settings) { + return back()->with('error', 'Account email non configurato.'); + } + $imapSuccess = false; $imapError = null; + $attachments = $this->processAttachments($request); foreach ($recipients as $recipient) { try { - $this->sendViaImap($settings, $recipient, $validated['subject'], $validated['body'], $request->cc); + $bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false); + $this->sendViaImap($settings, $recipient, $validated['subject'], $bodyWithSig, $request->cc, $attachments); $imapSuccess = true; } catch (\Exception $e) { $imapError = $e->getMessage(); - \Illuminate\Support\Facades\Log::error('IMAP send error', ['error' => $e->getMessage()]); + \Illuminate\Support\Facades\Log::error('SMTP send error', ['error' => $e->getMessage()]); } } try { - $this->storeSentMessage($settings, $recipients, $validated['subject'], $validated['body'], $request->cc); - \Illuminate\Support\Facades\Log::info('Stored sent message'); + $bodyWithSig = $this->appendSignature($validated['body'], $settings->getSignature(), $settings->signature_enabled ?? false); + $this->storeSentMessage($settings, $recipients, $validated['subject'], $bodyWithSig, $request->cc); } catch (\Exception $e) { \Illuminate\Support\Facades\Log::error('Store sent message error', ['error' => $e->getMessage()]); return back()->with('error', 'Errore nel salvataggio: ' . $e->getMessage())->withInput(); } if (!$imapSuccess && $imapError) { - return redirect()->route('email.index', 'sent')->with('warning', 'Email salvata in Invio ma non inviata via IMAP: ' . $imapError); + return redirect()->route('email.index', 'sent')->with('warning', 'Email salvata in Invio ma non inviata via SMTP: ' . $imapError); } return redirect()->route('email.index', 'sent')->with('success', 'Email inviata con successo.'); } + private function sendWithSender(Request $request, array $validated, array $recipients) + { + $sender = SenderAccount::findOrFail($request->mittente_id); + $attachmentPaths = $this->resolveAttachmentPaths($request); + $ok = 0; + $errors = []; + + foreach ($recipients as $recipient) { + try { + $sender->sendEmail($recipient, $validated['subject'], $validated['body'], $attachmentPaths); + $ok++; + } catch (\Exception $e) { + $errors[] = $recipient . ': ' . $e->getMessage(); + } + } + + if ($sender->verify_email) { + $reportSubject = '✅ Invio: ' . $validated['subject'] . ' - ' . $sender->email_address; + $reportBody = "Report invio mittente: {$sender->email_address}\n" + . "Destinatari totali: " . count($recipients) . "\n" + . "Inviate con successo: {$ok}\n" + . "Fallite: " . count($errors) . "\n"; + if (!empty($errors)) { + $reportBody .= "\nDettaglio errori:\n" . implode("\n", $errors); + } + $sender->sendReport($reportSubject, $reportBody); + } + + if ($ok === 0 && !empty($errors)) { + return redirect()->route('email.index', 'sent')->with('error', 'Invio fallito: ' . implode('; ', $errors)); + } + + $message = $ok . ' email inviate con successo tramite "' . $sender->email_address . '".'; + if (!empty($errors)) { + $message .= ' ' . count($errors) . ' fallite.'; + } + + return redirect()->route('email.index', 'sent')->with('success', $message); + } + public function storeDraft(Request $request) { $this->authorizeWrite('email'); @@ -454,10 +501,8 @@ class EmailController extends Controller return $body . "\n\n---\n" . $signature; } - private function sendViaImap($settings, $to, $subject, $body, $cc = null) + private function sendViaImap($settings, $to, $subject, $body, $cc = null, array $attachmentPaths = []) { - $bodyWithSignature = $this->appendSignature($body, $settings->getSignature(), $settings->signature_enabled ?? false); - \Illuminate\Support\Facades\Log::info('sendViaSMTP start', ['to' => $to, 'subject' => $subject]); $smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword(); @@ -475,8 +520,6 @@ class EmailController extends Controller $encryption ); - \Illuminate\Support\Facades\Log::info('SMTP DSN', ['dsn' => $dsn]); - try { $transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn); $mailer = new \Symfony\Component\Mailer\Mailer($transport); @@ -498,11 +541,15 @@ class EmailController extends Controller ->from($fromAddress) ->to($to) ->subject($subject) - ->text($bodyWithSignature); + ->text($body); if ($cc) { $email->cc($cc); } + + foreach ($attachmentPaths as $attachmentPath) { + $email->attachFromPath($attachmentPath); + } $mailer->send($email); @@ -514,6 +561,45 @@ class EmailController extends Controller } } + private function processAttachments(Request $request): array + { + $paths = []; + if ($request->hasFile('allegati')) { + foreach ($request->file('allegati') as $file) { + if ($file->isValid()) { + $paths[] = $file->getRealPath(); + } + } + } + return $paths; + } + + private function resolveAttachmentPaths(Request $request): array + { + $paths = []; + + if ($request->hasFile('allegati')) { + foreach ($request->file('allegati') as $file) { + if ($file->isValid()) { + $paths[] = $file->getRealPath(); + } + } + } + + $documentIds = $request->input('documenti_selezionati'); + if (!empty($documentIds)) { + $ids = is_array($documentIds) ? $documentIds : explode(',', $documentIds); + $docs = Documento::whereIn('id', $ids)->get(); + foreach ($docs as $doc) { + if ($doc->file_path && \Illuminate\Support\Facades\Storage::exists($doc->file_path)) { + $paths[] = \Illuminate\Support\Facades\Storage::path($doc->file_path); + } + } + } + + return $paths; + } + private function storeSentMessage($settings, $recipients, $subject, $body, $cc = null) { $sentFolder = EmailFolder::where('type', 'sent')->first(); diff --git a/app/Http/Controllers/MailingController.php b/app/Http/Controllers/MailingController.php index c670fc55..4773e812 100644 --- a/app/Http/Controllers/MailingController.php +++ b/app/Http/Controllers/MailingController.php @@ -1,12 +1,17 @@ get(); + $senderAccounts = SenderAccount::active()->get(); $documentiSelezionati = collect(); if ($request->has('documenti_selezionati')) { @@ -31,7 +37,7 @@ class MailingController extends Controller $documentiSelezionati = Documento::whereIn('id', $docIds)->get(); } - return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati')); + return view('mailing.nuovo', compact('individui', 'liste', 'documenti', 'documentiSelezionati', 'senderAccounts')); } public function invia(Request $request) @@ -44,8 +50,14 @@ class MailingController extends Controller 'destinatari_ids' => 'nullable|string', 'documenti_selezionati' => 'nullable|string', 'allegato' => 'nullable|file|max:10240', + 'mittente_id' => 'nullable|integer|exists:sender_accounts,id', ]); + $sender = null; + if (!empty($data['mittente_id'])) { + $sender = SenderAccount::findOrFail($data['mittente_id']); + } + $documentiAllegati = []; if ($request->hasFile('allegato')) { @@ -72,13 +84,90 @@ class MailingController extends Controller $documentiAllegati = array_merge($documentiAllegati, $docIds); } - $destinatari = []; + $destinatari = collect(); if (!empty($data['destinatari_ids'])) { $ids = array_filter(array_map('trim', explode(',', $data['destinatari_ids']))); $destinatari = Individuo::with('contatti')->whereIn('id', $ids)->get(); + } elseif (!empty($data['lista_id'])) { + $lista = MailingList::with('contatti.individuo.contatti')->find($data['lista_id']); + if ($lista) { + $destinatari = $lista->contatti->filter(fn($c) => $c->opt_in)->map(fn($c) => $c->individuo)->filter(); + } } - return back()->with('success', 'Messaggio preparato per ' . count($destinatari) . ' destinatari con ' . count($documentiAllegati) . ' allegato(i).'); + $emails = $destinatari->flatMap(fn($ind) => $ind->contatti->where('tipo', 'email')->pluck('valore')) + ->filter()->unique()->values(); + + if ($emails->isEmpty()) { + return back()->with('error', 'Nessun destinatario email valido trovato.')->withInput(); + } + + $attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati); + + $messaggio = MailingMessaggio::create([ + 'mailing_list_id' => $data['lista_id'] ?? null, + 'user_id' => auth()->id(), + 'oggetto' => $data['oggetto'], + 'corpo' => $data['corpo'], + 'stato' => 'in_coda', + 'totale_destinatari' => $emails->count(), + 'mittente_nome' => $sender?->email_name, + 'mittente_email' => $sender?->email_address, + ]); + + $ok = 0; + $fail = 0; + $logFalliti = []; + + foreach ($emails as $email) { + try { + if ($sender) { + $sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths); + } else { + $this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths); + } + $ok++; + } catch (\Throwable $e) { + $fail++; + $logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()]; + Log::error('Mailing send failed', ['email' => $email, 'error' => $e->getMessage()]); + } + } + + $stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito'); + $messaggio->update([ + 'stato' => $stato, + 'invii_ok' => $ok, + 'invii_falliti' => $fail, + 'log_falliti' => $logFalliti, + 'inviato_al' => now(), + ]); + + $senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito'; + + if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) { + $reportSubject = '✅ Report Invio - ' . $data['oggetto']; + $reportBody = "Report invio mailing\n" + . "Mittente: {$sender->email_name} <{$sender->email_address}>\n" + . "Oggetto: {$data['oggetto']}\n" + . "Totale destinatari: {$emails->count()}\n" + . "Inviate con successo: {$ok}\n" + . "Fallite: {$fail}\n"; + if (!empty($logFalliti)) { + $reportBody .= "\nDettaglio errori:\n"; + foreach ($logFalliti as $f) { + $reportBody .= "- {$f['email']}: {$f['errore']}\n"; + } + } + $sender->sendReport($reportSubject, $reportBody); + } + + $msg = "Invio completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite."; + if ($ok > 0) { + return redirect('/mailing-liste')->with('success', $msg); + } + + return back()->with('error', $msg); } public function invio(Request $request) @@ -86,8 +175,9 @@ class MailingController extends Controller $this->authorizeWrite('mailing'); $liste = MailingList::where('attiva', true)->orderBy('nome')->get(); $documenti = Documento::orderBy('nome_file')->get(); + $senderAccounts = SenderAccount::active()->get(); - return view('mailing.invio', compact('liste', 'documenti')); + return view('mailing.invio', compact('liste', 'documenti', 'senderAccounts')); } public function invioElabora(Request $request) @@ -101,8 +191,14 @@ class MailingController extends Controller 'documenti_selezionati' => 'nullable|array', 'documenti_selezionati.*' => 'exists:documenti,id', 'allegato' => 'nullable|file|max:10240', + 'mittente_id' => 'nullable|integer|exists:sender_accounts,id', ]); + $sender = null; + if (!empty($data['mittente_id'])) { + $sender = SenderAccount::findOrFail($data['mittente_id']); + } + $documentiAllegati = []; if ($request->hasFile('allegato')) { @@ -133,10 +229,148 @@ class MailingController extends Controller ->with('individuo.contatti') ->get(); - $destinatari = $contatti->map(function($c) { - return $c->individuo->contatti->where('tipo', 'email')->first()?->valore; - })->filter()->unique()->values(); + $emails = $contatti->map(fn($c) => $c->individuo?->contatti->where('tipo', 'email')->first()?->valore) + ->filter()->unique()->values(); - return back()->with('success', 'Messaggio pronto per l\'invio a ' . $destinatari->count() . ' destinatari unici da ' . count($data['liste']) . ' lista(e).'); + if ($emails->isEmpty()) { + return back()->with('error', 'Nessun destinatario email valido trovato nelle liste selezionate.'); + } + + $attachmentPaths = $this->resolveMailingAttachmentPaths($documentiAllegati); + + $listeNomi = MailingList::whereIn('id', $data['liste'])->pluck('nome')->implode(', '); + + $messaggio = MailingMessaggio::create([ + 'mailing_list_id' => null, + 'user_id' => auth()->id(), + 'oggetto' => $data['oggetto'], + 'corpo' => $data['corpo'], + 'stato' => 'in_coda', + 'totale_destinatari' => $emails->count(), + 'mittente_nome' => $sender?->email_name, + 'mittente_email' => $sender?->email_address, + ]); + + $ok = 0; + $fail = 0; + $logFalliti = []; + + foreach ($emails as $email) { + try { + if ($sender) { + $sender->sendEmail($email, $data['oggetto'], $data['corpo'], $attachmentPaths); + } else { + $this->sendViaSystem($email, $data['oggetto'], $data['corpo'], $attachmentPaths); + } + $ok++; + } catch (\Throwable $e) { + $fail++; + $logFalliti[] = ['email' => $email, 'errore' => $e->getMessage()]; + Log::error('Mailing bulk send failed', ['email' => $email, 'error' => $e->getMessage()]); + } + } + + $stato = $fail === 0 ? 'inviato' : ($ok > 0 ? 'parziale' : 'fallito'); + $messaggio->update([ + 'stato' => $stato, + 'invii_ok' => $ok, + 'invii_falliti' => $fail, + 'log_falliti' => $logFalliti, + 'inviato_al' => now(), + ]); + + $senderLabel = $sender ? '"' . $sender->email_address . '"' : 'sistema predefinito'; + + if ($sender && $sender->verify_email && ($ok > 0 || $stato === 'fallito')) { + $reportSubject = '✅ Report Invio Massivo - ' . $data['oggetto']; + $reportBody = "Report invio massivo\n" + . "Mittente: {$sender->email_name} <{$sender->email_address}>\n" + . "Liste: {$listeNomi}\n" + . "Oggetto: {$data['oggetto']}\n" + . "Totale destinatari: {$emails->count()}\n" + . "Inviate con successo: {$ok}\n" + . "Fallite: {$fail}\n"; + if (!empty($logFalliti)) { + $reportBody .= "\nDettaglio errori:\n"; + foreach ($logFalliti as $f) { + $reportBody .= "- {$f['email']}: {$f['errore']}\n"; + } + } + $sender->sendReport($reportSubject, $reportBody); + } + + $msg = "Invio massivo completato tramite {$senderLabel}: {$ok} inviate, {$fail} fallite."; + if ($ok > 0) { + return redirect('/')->with('success', $msg); + } + + return back()->with('error', $msg); } -} \ No newline at end of file + + private function sendViaSystem(string $to, string $subject, string $body, array $attachmentPaths = []): void + { + $settings = \App\Models\EmailSetting::getActive(); + if (!$settings) { + throw new \Exception('Account email principale non configurato.'); + } + + $smtpPassword = $settings->getDecryptedSmtpPassword() ?? $settings->getDecryptedPassword(); + $encryption = $settings->smtp_encryption ?? 'tls'; + $scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp'; + + $dsn = sprintf( + '%s://%s:%s@%s:%d?encryption=%s', + $scheme, + rawurlencode($settings->smtp_username ?? $settings->email_address), + rawurlencode($smtpPassword), + $settings->smtp_host ?? 'smtp.gmail.com', + $settings->smtp_port ?? 587, + $encryption + ); + + $transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn); + $mailer = new \Symfony\Component\Mailer\Mailer($transport); + + $fromName = $settings->email_name ?? ''; + if (filter_var($fromName, FILTER_VALIDATE_EMAIL)) { + $fromName = 'Glastree'; + } else { + $fromName = preg_replace('/[^\p{L}\p{N}\s]/u', '', $fromName); + $fromName = trim($fromName); + if (empty($fromName) || strlen($fromName) > 50) { + $fromName = 'Glastree'; + } + } + + $fromAddress = \Symfony\Component\Mime\Address::create($settings->email_address, $fromName); + + $email = (new \Symfony\Component\Mime\Email()) + ->from($fromAddress) + ->to($to) + ->subject($subject) + ->text($body); + + foreach ($attachmentPaths as $path) { + $email->attachFromPath($path); + } + + $mailer->send($email); + } + + private function resolveMailingAttachmentPaths(array $documentIds): array + { + $paths = []; + if (empty($documentIds)) { + return $paths; + } + + $docs = Documento::whereIn('id', $documentIds)->get(); + foreach ($docs as $doc) { + if ($doc->file_path && Storage::exists($doc->file_path)) { + $paths[] = Storage::path($doc->file_path); + } + } + + return $paths; + } +} diff --git a/app/Models/MailingMessaggio.php b/app/Models/MailingMessaggio.php index f690a353..98fc0f5c 100644 --- a/app/Models/MailingMessaggio.php +++ b/app/Models/MailingMessaggio.php @@ -10,11 +10,13 @@ class MailingMessaggio extends Model protected $table = 'mailing_messaggi'; protected $fillable = [ 'tenant_id', 'mailing_list_id', 'user_id', 'oggetto', 'corpo', - 'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti' + 'canale', 'stato', 'inviato_al', 'totale_destinatari', 'invii_ok', 'invii_falliti', + 'log_falliti', 'mittente_nome', 'mittente_email', ]; protected $casts = [ 'inviato_al' => 'datetime', + 'log_falliti' => 'array', ]; public function tenant(): BelongsTo diff --git a/app/Models/SenderAccount.php b/app/Models/SenderAccount.php new file mode 100644 index 00000000..ac2ee4de --- /dev/null +++ b/app/Models/SenderAccount.php @@ -0,0 +1,146 @@ + 'boolean', + 'smtp_port' => 'integer', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function getDecryptedPassword(): string + { + if (empty($this->smtp_password)) { + return ''; + } + + try { + return Crypt::decryptString($this->smtp_password); + } catch (\Exception $e) { + return $this->smtp_password; + } + } + + public function sanitizeName(): string + { + $name = $this->email_name ?? ''; + + if (filter_var($name, FILTER_VALIDATE_EMAIL)) { + return 'Glastree'; + } + + $name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name); + $name = trim($name); + + return empty($name) ? 'Glastree' : $name; + } + + public function getReplyTo(): ?string + { + if ($this->reply_to) { + return $this->reply_to; + } + + if ($this->verify_email) { + return $this->verify_email; + } + + return null; + } + + public function buildDsn(): string + { + $password = $this->getDecryptedPassword(); + $username = $this->smtp_username ?? $this->email_address; + $encryption = $this->smtp_encryption ?? 'tls'; + $scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp'; + + return sprintf( + '%s://%s:%s@%s:%d?encryption=%s', + $scheme, + rawurlencode($username), + rawurlencode($password), + $this->smtp_host ?? 'smtp.gmail.com', + $this->smtp_port ?? 587, + $encryption + ); + } + + public function buildSymfonyEmail(string $to, string $subject, string $body): \Symfony\Component\Mime\Email + { + $fromName = $this->sanitizeName(); + $fromAddress = \Symfony\Component\Mime\Address::create($this->email_address, $fromName); + + $email = (new \Symfony\Component\Mime\Email()) + ->from($fromAddress) + ->to($to) + ->subject($subject) + ->text($body); + + $replyTo = $this->getReplyTo(); + if ($replyTo) { + $email->replyTo($replyTo); + } + + return $email; + } + + public function sendEmail(string $to, string $subject, string $body, array $attachmentPaths = []): void + { + $email = $this->buildSymfonyEmail($to, $subject, $body); + + foreach ($attachmentPaths as $path) { + $email->attachFromPath($path); + } + + $transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn()); + $mailer = new \Symfony\Component\Mailer\Mailer($transport); + $mailer->send($email); + } + + public function sendReport(string $subject, string $body): bool + { + if (empty($this->verify_email)) { + return false; + } + + try { + $this->sendEmail($this->verify_email, $subject, $body); + return true; + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Sender report email failed', [ + 'sender' => $this->email_address, + 'error' => $e->getMessage(), + ]); + return false; + } + } +} diff --git a/database/migrations/2026_05_26_000002_create_sender_accounts_table.php b/database/migrations/2026_05_26_000002_create_sender_accounts_table.php new file mode 100644 index 00000000..8480705c --- /dev/null +++ b/database/migrations/2026_05_26_000002_create_sender_accounts_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('email_address')->unique(); + $table->string('email_name')->nullable(); + $table->string('smtp_host')->nullable(); + $table->integer('smtp_port')->default(587); + $table->string('smtp_encryption')->default('tls'); + $table->string('smtp_username')->nullable(); + $table->text('smtp_password')->nullable(); + $table->string('reply_to')->nullable(); + $table->string('verify_email')->nullable(); + $table->text('note')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sender_accounts'); + } +}; diff --git a/database/migrations/2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php b/database/migrations/2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php new file mode 100644 index 00000000..73fe6ee1 --- /dev/null +++ b/database/migrations/2026_05_26_000003_add_log_falliti_to_mailing_messaggi.php @@ -0,0 +1,26 @@ +json('log_falliti')->nullable()->after('invii_falliti'); + $table->string('mittente_nome')->nullable()->after('log_falliti'); + $table->string('mittente_email')->nullable()->after('mittente_nome'); + }); + } + + public function down(): void + { + Schema::table('mailing_messaggi', function (Blueprint $table) { + $table->dropColumn(['log_falliti', 'mittente_nome', 'mittente_email']); + }); + } +}; diff --git a/resources/views/admin/email-settings/index.blade.php b/resources/views/admin/email-settings/index.blade.php index a435c686..3e878d26 100644 --- a/resources/views/admin/email-settings/index.blade.php +++ b/resources/views/admin/email-settings/index.blade.php @@ -31,6 +31,9 @@ Firma + + Mittenti Aggiuntivi + @@ -278,6 +281,100 @@ + +
| Nome | +SMTP | +Reply-To | +Verify | +Stato | +Azioni | +|
|---|---|---|---|---|---|---|
| {{ $sender->email_address }} | +{{ $sender->email_name ?: '-' }} | +{{ $sender->smtp_host ?: '-' }}:{{ $sender->smtp_port ?: '-' }} | +{{ $sender->reply_to ?: '-' }} | +{{ $sender->verify_email ?: '-' }} | ++ @if($sender->is_active) + Attivo + @else + Disattivo + @endif + | ++ + + + | +
Nessun mittente aggiuntivo configurato.
+Usa il pulsante "Nuovo Mittente" per aggiungere un account di invio dedicato (es. Newsletter).
+no-reply@parrocchia.it).