From fabc35e729f6bca464e47f3062354d72eb514f9f Mon Sep 17 00:00:00 2001 From: Daeng Deni Mardaeni Date: Wed, 11 Jun 2025 11:41:57 +0700 Subject: [PATCH] feat(webstatement): tingkatkan proses pengiriman email dengan PHPMailer - **Migrasi ke PHPMailer:** - Mengganti penggunaan `Illuminate\Support\Facades\Mail` ke PHPMailer untuk pengiriman email. - Menambahkan service baru `PHPMailerService` dengan dukungan autentikasi NTLM/GSSAPI. - Mengintegrasikan logika pengiriman email ke dalam `StatementEmail` menggunakan PHPMailer. - Memindahkan logika attachment dan body email ke helper method pada kelas `StatementEmail`. - **Perbaikan Logging dan Penanganan Error:** - Menambah logging lebih mendetail pada proses pengiriman email, termasuk informasi seperti penerima, subjek, dan status pengiriman. - Menambahkan fallback untuk pembuatan konten HTML jika terjadi kegagalan rendering pada template Blade. - Menambahkan pengecekan dan logging untuk kegagalan pengiriman email dengan mekanisme exception handling. - **Peningkatan Template Email:** - Memperbaiki elemen ulasan pada template email untuk mendukung tampilan yang lebih bersih menggunakan `list-style-type: none`. - Memodifikasi markup footer untuk memberikan batas terformat lebih baik. - **Optimasi Proses Backend:** - Menambahkan delay antar pengiriman email untuk menghindari rate limiting pada koneksi NTLM/GSSAPI. - Menyediakan format nama attachment dinamis berdasarkan rekening dan periode laporan. - Memanfaatkan konfigurasi enkripsi dinamis, dengan fallback untuk pengujian/development. Signed-off-by: Daeng Deni Mardaeni --- app/Jobs/SendStatementEmailJob.php | 24 +- app/Mail/StatementEmail.php | 188 +++++++++++--- app/Services/PHPMailerService.php | 289 +++++++++++++++++++++ resources/views/statements/email.blade.php | 32 +-- 4 files changed, 468 insertions(+), 65 deletions(-) create mode 100644 app/Services/PHPMailerService.php diff --git a/app/Jobs/SendStatementEmailJob.php b/app/Jobs/SendStatementEmailJob.php index 6984a11..98a970d 100644 --- a/app/Jobs/SendStatementEmailJob.php +++ b/app/Jobs/SendStatementEmailJob.php @@ -9,7 +9,6 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Modules\Webstatement\Models\Account; use Modules\Webstatement\Models\PrintStatementLog; @@ -19,6 +18,7 @@ use Modules\Basicdata\Models\Branch; /** * Job untuk mengirim email PDF statement ke nasabah * Mendukung pengiriman per rekening, per cabang, atau seluruh cabang + * Menggunakan PHPMailer dengan dukungan NTLM/GSSAPI */ class SendStatementEmailJob implements ShouldQueue { @@ -47,7 +47,7 @@ class SendStatementEmailJob implements ShouldQueue $this->batchId = $batchId ?? uniqid('batch_'); $this->logId = $logId; - Log::info('SendStatementEmailJob created', [ + Log::info('SendStatementEmailJob created with PHPMailer', [ 'period' => $this->period, 'request_type' => $this->requestType, 'target_value' => $this->targetValue, @@ -320,12 +320,15 @@ class SendStatementEmailJob implements ShouldQueue // Dapatkan path absolut file $absolutePdfPath = Storage::path($pdfPath); - // Kirim email - // Add delay between email sends to prevent rate limiting - sleep(1); // 2 second delay - Mail::to($emailAddress)->send( - new StatementEmail($statementLog, $absolutePdfPath, false) - ); + // Buat instance StatementEmail dengan PHPMailer + $statementEmail = new StatementEmail($statementLog, $absolutePdfPath, false); + + // Kirim email menggunakan PHPMailer + $emailSent = $statementEmail->send($emailAddress); + + if (!$emailSent) { + throw new \Exception("Failed to send email to {$emailAddress} for account {$account->account_number}"); + } // Update status log dengan email yang digunakan $statementLog->update([ @@ -334,7 +337,7 @@ class SendStatementEmailJob implements ShouldQueue 'email_address' => $emailAddress // Simpan email yang digunakan untuk tracking ]); - Log::info('Email sent for account', [ + Log::info('Email sent via PHPMailer for account', [ 'account_number' => $account->account_number, 'branch_code' => $account->branch_code, 'email' => $emailAddress, @@ -342,6 +345,9 @@ class SendStatementEmailJob implements ShouldQueue 'pdf_path' => $pdfPath, 'batch_id' => $this->batchId ]); + + // Add delay between email sends to prevent rate limiting + sleep(2); // 2 second delay for NTLM/GSSAPI connections } /** diff --git a/app/Mail/StatementEmail.php b/app/Mail/StatementEmail.php index ee77925..51cb2af 100644 --- a/app/Mail/StatementEmail.php +++ b/app/Mail/StatementEmail.php @@ -2,19 +2,22 @@ namespace Modules\Webstatement\Mail; -use Illuminate\Bus\Queueable; -use Illuminate\Mail\Mailable; -use Illuminate\Queue\SerializesModels; use Modules\Webstatement\Models\Account; use Modules\Webstatement\Models\PrintStatementLog; +use Modules\Webstatement\Services\PHPMailerService; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\View; -class StatementEmail extends Mailable +/** + * Service untuk mengirim email statement menggunakan PHPMailer + * dengan dukungan autentikasi NTLM/GSSAPI + */ +class StatementEmail { - use Queueable, SerializesModels; - protected $statement; protected $filePath; protected $isZip; + protected $phpMailerService; /** * Create a new message instance. @@ -29,15 +32,69 @@ class StatementEmail extends Mailable $this->statement = $statement; $this->filePath = $filePath; $this->isZip = $isZip; + $this->phpMailerService = new PHPMailerService(); } /** - * Build the message. - * Membangun struktur email dengan attachment statement + * Kirim email statement * - * @return $this + * @param string $emailAddress + * @return bool */ - public function build() + public function send(string $emailAddress): bool + { + try { + // Generate subject + $subject = $this->generateSubject(); + + // Generate email body + $body = $this->generateEmailBody(); + + // Generate attachment name + $attachmentName = $this->generateAttachmentName(); + + // Determine MIME type + $mimeType = $this->isZip ? 'application/zip' : 'application/pdf'; + + // Send email using PHPMailer + $result = $this->phpMailerService->sendEmail( + $emailAddress, + $subject, + $body, + $this->filePath, + $attachmentName, + $mimeType + ); + + Log::info('Statement email sent via PHPMailer', [ + 'to' => $emailAddress, + 'subject' => $subject, + 'attachment' => $attachmentName, + 'account_number' => $this->statement->account_number, + 'period' => $this->statement->period_from, + 'success' => $result + ]); + + return $result; + + } catch (\Exception $e) { + Log::error('Failed to send statement email via PHPMailer', [ + 'to' => $emailAddress, + 'account_number' => $this->statement->account_number, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return false; + } + } + + /** + * Generate email subject + * + * @return string + */ + protected function generateSubject(): string { $subject = 'Statement Rekening Bank Artha Graha Internasional'; @@ -47,33 +104,92 @@ class StatementEmail extends Mailable $subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y'); } - $email = $this->subject($subject) - ->view('webstatement::statements.email') - ->with([ - 'statement' => $this->statement, - 'accountNumber' => $this->statement->account_number, - 'periodFrom' => $this->statement->period_from, - 'periodTo' => $this->statement->period_to, - 'isRange' => $this->statement->is_period_range, - 'requestType' => $this->statement->request_type, - 'batchId' => $this->statement->batch_id, - 'accounts' => Account::where('account_number', $this->statement->account_number)->first() - ]); - - if ($this->isZip) { - $fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip"; - $email->attach($this->filePath, [ - 'as' => $fileName, - 'mime' => 'application/zip', - ]); - } else { - $fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf"; - $email->attach($this->filePath, [ - 'as' => $fileName, - 'mime' => 'application/pdf', - ]); + // Add batch info for batch requests + if ($this->statement->request_type && $this->statement->request_type !== 'single_account') { + $subject .= " [{$this->statement->request_type}]"; } - return $email; + if ($this->statement->batch_id) { + $subject .= " [Batch: {$this->statement->batch_id}]"; + } + + return $subject; + } + + /** + * Generate email body HTML + * + * @return string + */ + protected function generateEmailBody(): string + { + try { + // Get account data + $account = Account::where('account_number', $this->statement->account_number)->first(); + + // Prepare data for view + $data = [ + 'statement' => $this->statement, + 'accountNumber' => $this->statement->account_number, + 'periodFrom' => $this->statement->period_from, + 'periodTo' => $this->statement->period_to, + 'isRange' => $this->statement->is_period_range, + 'requestType' => $this->statement->request_type, + 'batchId' => $this->statement->batch_id, + 'accounts' => $account + ]; + + // Render view to HTML + return View::make('webstatement::statements.email', $data)->render(); + + } catch (\Exception $e) { + Log::error('Failed to generate email body', [ + 'account_number' => $this->statement->account_number, + 'error' => $e->getMessage() + ]); + + // Fallback to simple HTML + return $this->generateFallbackEmailBody(); + } + } + + /** + * Generate fallback email body + * + * @return string + */ + protected function generateFallbackEmailBody(): string + { + $periodText = $this->statement->is_period_range + ? "periode {$this->statement->period_from} sampai {$this->statement->period_to}" + : "periode " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y'); + + return " + + +

Statement Rekening Bank Artha Graha Internasional

+

Yth. Nasabah,

+

Terlampir adalah statement rekening Anda untuk {$periodText}.

+

Nomor Rekening: {$this->statement->account_number}

+

Terima kasih atas kepercayaan Anda.

+
+

Salam,
Bank Artha Graha Internasional

+ + + "; + } + + /** + * Generate attachment filename + * + * @return string + */ + protected function generateAttachmentName(): string + { + if ($this->isZip) { + return "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip"; + } else { + return "{$this->statement->account_number}_{$this->statement->period_from}.pdf"; + } } } diff --git a/app/Services/PHPMailerService.php b/app/Services/PHPMailerService.php new file mode 100644 index 0000000..78d6b32 --- /dev/null +++ b/app/Services/PHPMailerService.php @@ -0,0 +1,289 @@ +mailer = new PHPMailer(true); + $this->configureSMTP(); + + Log::info('PHPMailerService initialized with NTLM/GSSAPI support'); + } + + /** + * Konfigurasi SMTP dengan dukungan NTLM/GSSAPI dan fallback untuk development + */ + protected function configureSMTP(): void + { + try { + // Server settings + $this->mailer->isSMTP(); + $this->mailer->Host = config('mail.mailers.phpmailer.host', env('MAIL_HOST')); + $this->mailer->Port = config('mail.mailers.phpmailer.port', env('MAIL_PORT', 587)); + + // Deteksi apakah perlu autentikasi berdasarkan username + $username = config('mail.mailers.phpmailer.username', env('MAIL_USERNAME')); + $password = config('mail.mailers.phpmailer.password', env('MAIL_PASSWORD')); + + // Hanya aktifkan autentikasi jika username dan password tersedia + if (!empty($username) && $username !== 'null' && !empty($password) && $password !== 'null') { + $this->mailer->SMTPAuth = true; + $this->mailer->Username = $username; + $this->mailer->Password = $password; + + Log::info('SMTP authentication enabled', [ + 'username' => $username, + 'host' => $this->mailer->Host + ]); + + // Dukungan NTLM/GSSAPI untuk production + $authType = config('mail.mailers.phpmailer.auth_type', env('MAIL_AUTH_TYPE', 'NTLM')); + + if (strtoupper($authType) === 'NTLM') { + $this->mailer->AuthType = 'NTLM'; + $this->mailer->Realm = config('mail.mailers.phpmailer.realm', env('MAIL_REALM', '')); + $this->mailer->Workstation = config('mail.mailers.phpmailer.workstation', env('MAIL_WORKSTATION', '')); + + Log::info('NTLM authentication configured', [ + 'realm' => $this->mailer->Realm, + 'workstation' => $this->mailer->Workstation + ]); + } elseif (strtoupper($authType) === 'GSSAPI') { + $this->mailer->AuthType = 'XOAUTH2'; + Log::info('GSSAPI authentication configured'); + } + } else { + // Untuk development server seperti Mailpit + $this->mailer->SMTPAuth = false; + + Log::info('SMTP authentication disabled for development', [ + 'host' => $this->mailer->Host, + 'port' => $this->mailer->Port + ]); + } + + // Encryption configuration + $encryption = config('mail.mailers.phpmailer.encryption', env('MAIL_ENCRYPTION')); + $port = $this->mailer->Port; + + if (!empty($encryption) && $encryption !== 'null') { + if ($encryption === 'tls' && ($port == 587 || $port == 25)) { + $this->mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + Log::info('Using STARTTLS encryption', ['port' => $port]); + } elseif ($encryption === 'ssl' && ($port == 465 || $port == 993)) { + $this->mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + Log::info('Using SSL encryption', ['port' => $port]); + } + } else { + // Untuk development/testing server + $this->mailer->SMTPSecure = false; + $this->mailer->SMTPAutoTLS = false; + Log::info('Using no encryption (plain text)', ['port' => $port]); + } + + // Tambahan konfigurasi untuk kompatibilitas + $this->mailer->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + + // Debug mode - COMMENTED OUT + // if (config('app.debug')) { + // $this->mailer->SMTPDebug = SMTP::DEBUG_SERVER; + // } + + // Timeout settings + $this->mailer->Timeout = config('mail.mailers.phpmailer.timeout', 30); + $this->mailer->SMTPKeepAlive = true; + + Log::info('SMTP configured successfully', [ + 'host' => $this->mailer->Host, + 'port' => $this->mailer->Port, + 'auth_enabled' => $this->mailer->SMTPAuth, + 'encryption' => $encryption ?: 'none', + 'smtp_secure' => $this->mailer->SMTPSecure ?: 'none' + ]); + + } catch (Exception $e) { + Log::error('Failed to configure SMTP', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } + + /** + * Kirim email dengan attachment + * + * @param string $to Email tujuan + * @param string $subject Subjek email + * @param string $body Body email (HTML) + * @param string|null $attachmentPath Path file attachment + * @param string|null $attachmentName Nama file attachment + * @param string|null $mimeType MIME type attachment + * @return bool + */ + /** + * Kirim email dengan handling khusus untuk development dan production + * + * @param string $to Email tujuan + * @param string $subject Subjek email + * @param string $body Body email (HTML) + * @param string|null $attachmentPath Path file attachment + * @param string|null $attachmentName Nama file attachment + * @param string|null $mimeType MIME type attachment + * @return bool + */ + public function sendEmail( + string $to, + string $subject, + string $body, + ?string $attachmentPath = null, + ?string $attachmentName = null, + ?string $mimeType = 'application/pdf' + ): bool { + try { + // Reset recipients dan attachments + $this->mailer->clearAddresses(); + $this->mailer->clearAttachments(); + + // Set sender + $fromAddress = config('mail.from.address', env('MAIL_FROM_ADDRESS')); + $fromName = config('mail.from.name', env('MAIL_FROM_NAME')); + + if (!empty($fromAddress)) { + $this->mailer->setFrom($fromAddress, $fromName); + } else { + // Fallback untuk development + $this->mailer->setFrom('noreply@localhost', 'Development Server'); + } + + // Add recipient + $this->mailer->addAddress($to); + + // Content + $this->mailer->isHTML(true); + $this->mailer->Subject = $subject; + $this->mailer->Body = $body; + $this->mailer->AltBody = strip_tags($body); + + // Attachment + if ($attachmentPath && file_exists($attachmentPath)) { + $this->mailer->addAttachment( + $attachmentPath, + $attachmentName ?: basename($attachmentPath), + 'base64', + $mimeType + ); + + Log::info('Attachment added to email', [ + 'path' => $attachmentPath, + 'name' => $attachmentName, + 'mime_type' => $mimeType, + 'file_size' => filesize($attachmentPath) + ]); + } + + // Attempt to send + $result = $this->mailer->send(); + + Log::info('Email sent successfully via PHPMailer', [ + 'to' => $to, + 'subject' => $subject, + 'has_attachment' => !is_null($attachmentPath), + 'host' => $this->mailer->Host, + 'port' => $this->mailer->Port, + 'auth_enabled' => $this->mailer->SMTPAuth + ]); + + return $result; + + } catch (Exception $e) { + Log::error('Failed to send email via PHPMailer', [ + 'to' => $to, + 'subject' => $subject, + 'host' => $this->mailer->Host, + 'port' => $this->mailer->Port, + 'auth_enabled' => $this->mailer->SMTPAuth, + 'error' => $e->getMessage(), + 'error_code' => $e->getCode(), + 'trace' => $e->getTraceAsString() + ]); + + return false; + } + } + + /** + * Test koneksi SMTP dengan fallback encryption + * + * @return bool + */ + public function testConnection(): bool + { + try { + // Coba koneksi dengan konfigurasi saat ini + $this->mailer->smtpConnect(); + $this->mailer->smtpClose(); + + Log::info('SMTP connection test successful with current config'); + return true; + + } catch (Exception $e) { + Log::warning('SMTP connection failed, trying fallback', [ + 'error' => $e->getMessage() + ]); + + // Fallback: coba tanpa encryption + try { + $this->mailer->SMTPSecure = false; + $this->mailer->SMTPAutoTLS = false; + + $this->mailer->smtpConnect(); + $this->mailer->smtpClose(); + + Log::info('SMTP connection successful with fallback (no encryption)'); + return true; + + } catch (Exception $fallbackError) { + Log::error('SMTP connection test failed completely', [ + 'original_error' => $e->getMessage(), + 'fallback_error' => $fallbackError->getMessage() + ]); + + return false; + } + } + } + + /** + * Dapatkan instance PHPMailer + * + * @return PHPMailer + */ + public function getMailer(): PHPMailer + { + return $this->mailer; + } +} diff --git a/resources/views/statements/email.blade.php b/resources/views/statements/email.blade.php index ae4665c..5589b70 100644 --- a/resources/views/statements/email.blade.php +++ b/resources/views/statements/email.blade.php @@ -89,25 +89,21 @@ Silahkan gunakan password Electronic Statement Anda untuk membukanya.

Password standar Elektronic Statement ini adalah ddMonyyyyxx (contoh: 01Aug1970xx) dimana : -