diff --git a/config/services.yaml b/config/services.yaml index 11dafd66..e6826abb 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -123,6 +123,7 @@ services: OpenDxp\Bundle\AdminBundle\Service\Element\EditLockService: ~ OpenDxp\Bundle\AdminBundle\Service\Email\UnusableRecipientDetector: ~ + OpenDxp\Bundle\AdminBundle\Service\Email\EmailLogListingFactory: ~ OpenDxp\Bundle\AdminBundle\Service\Login\LoginPageService: ~ diff --git a/public/js/opendxp/settings/email/log.js b/public/js/opendxp/settings/email/log.js index bd501666..190e5669 100644 --- a/public/js/opendxp/settings/email/log.js +++ b/public/js/opendxp/settings/email/log.js @@ -18,9 +18,17 @@ opendxp.registerNS('opendxp.settings.email.log'); opendxp.settings.email.log = Class.create({ filterField: null, + exportPrepareUrl: null, + exportProcessUrl: null, + exportDownloadUrl: null, + + exportConfirmThreshold: 1000, initialize: function(document) { this.document = document; + this.exportPrepareUrl = Routing.generate('opendxp_admin_email_exportemaillogs_prepare'); + this.exportProcessUrl = Routing.generate('opendxp_admin_email_exportemaillogs'); + this.exportDownloadUrl = Routing.generate('opendxp_admin_email_exportemaillogs_download'); this.filterField = new Ext.form.TextField({ width: 200, @@ -461,11 +469,17 @@ opendxp.settings.email.log = Class.create({ this.pagingtoolbar = opendxp.helpers.grid.buildDefaultPagingToolbar(this.store); + this.selectionColumn = new Ext.selection.CheckboxModel(); + var toolbar = Ext.create('Ext.Toolbar', { cls: 'opendxp_main_toolbar', items: [ '->', { + text: t('export_csv'), + iconCls: 'opendxp_icon_export', + handler: this.doExport.bind(this) + }, '-', { text: t('filter') + '/' + t('search'), xtype: 'tbtext', style: 'margin: 0 10px 0 0;' @@ -477,6 +491,7 @@ opendxp.settings.email.log = Class.create({ frame: false, store: this.store, columns : gridColumns, + selModel: this.selectionColumn, columnLines: true, stripeRows: true, border: true, @@ -496,6 +511,89 @@ opendxp.settings.email.log = Class.create({ return this.grid; }, + doExport: function () { + var params = {}; + + if (this.document) { + params.documentId = this.document.id; + } + + var selection = this.grid.getSelectionModel().getSelection(); + if (selection.length > 0) { + params['ids[]'] = selection.map(function (record) { + return record.get('id'); + }); + + this.exportPrepare(params); + return; + } + + // the applied filter, not the raw input: the field only takes effect on ENTER + var filter = this.store.getProxy().extraParams.filter; + if (!filter) { + this.exportPrepare(params); + return; + } + + Ext.MessageBox.confirm('', t('filter_active_message'), function (buttonValue) { + if (buttonValue === 'yes') { + params.filter = filter; + } + + this.exportPrepare(params); + }.bind(this)); + }, + + exportPrepare: function (params) { + Ext.Ajax.request({ + url: this.exportPrepareUrl, + method: 'POST', + params: params, + success: function (response) { + var rdata = Ext.decode(response.responseText); + + if (!rdata || !rdata.fileHandle) { + return; + } + + if (rdata.total <= this.exportConfirmThreshold) { + this.exportProcess(params, rdata.fileHandle); + return; + } + + var formatted = new Intl.NumberFormat(navigator.language).format(rdata.total); + + Ext.Msg.confirm(t('warning'), sprintf(t('email_log_export_confirmation'), '' + formatted + ''), function (buttonValue) { + if (buttonValue === 'yes') { + this.exportProcess(params, rdata.fileHandle); + } + }.bind(this)); + }.bind(this) + }); + }, + + exportProcess: function (params, fileHandle) { + this.grid.setLoading(t('please_wait')); + + Ext.Ajax.request({ + url: this.exportProcessUrl, + method: 'POST', + params: Ext.applyIf({fileHandle: fileHandle}, params), + callback: function () { + this.grid.setLoading(false); + }.bind(this), + success: function (response) { + var rdata = Ext.decode(response.responseText); + + if (!rdata || !rdata.success) { + return; + } + + opendxp.helpers.download(Ext.urlAppend(this.exportDownloadUrl, Ext.Object.toQueryString({fileHandle: fileHandle}))); + }.bind(this) + }); + }, + reload: function () { this.grid.store.reload(); diff --git a/src/Controller/Admin/EmailController.php b/src/Controller/Admin/EmailController.php index d7d19c22..a3106633 100644 --- a/src/Controller/Admin/EmailController.php +++ b/src/Controller/Admin/EmailController.php @@ -23,6 +23,10 @@ use OpenDxp\Bundle\AdminBundle\Handler\Email\Blocklist\UpdateBlocklistEntry\UpdateBlocklistEntryHandler; use OpenDxp\Bundle\AdminBundle\Handler\Email\BlocklistPayload; use OpenDxp\Bundle\AdminBundle\Handler\Email\DeleteEmailLog\DeleteEmailLogHandler; +use OpenDxp\Bundle\AdminBundle\Handler\Email\DoEmailLogExport\DoEmailLogExportHandler; +use OpenDxp\Bundle\AdminBundle\Handler\Email\DoEmailLogExport\DoEmailLogExportPayload; +use OpenDxp\Bundle\AdminBundle\Handler\Email\PrepareEmailLogExport\PrepareEmailLogExportHandler; +use OpenDxp\Bundle\AdminBundle\Handler\Email\PrepareEmailLogExport\PrepareEmailLogExportPayload; use OpenDxp\Bundle\AdminBundle\Handler\Email\GetBlocklist\GetBlocklistHandler; use OpenDxp\Bundle\AdminBundle\Handler\Email\GetEmailLogs\GetEmailLogsHandler; use OpenDxp\Bundle\AdminBundle\Handler\Email\GetEmailLogs\GetEmailLogsPayload; @@ -38,7 +42,9 @@ use OpenDxp\Bundle\AdminBundle\Payload\Common\IdQueryPayload; use OpenDxp\Bundle\AdminBundle\Security\AdminPermission; use OpenDxp\Http\RequestHelper; +use OpenDxp\Bundle\AdminBundle\Service\Grid\GridExportService; use OpenDxp\Security\CorePermission; +use RuntimeException; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -65,6 +71,39 @@ public function emailLogsAction( return $this->apiJson($handler($payload)); } + #[IsGranted(CorePermission::Emails->value)] + #[Route('/export-email-logs/prepare', name: 'opendxp_admin_email_exportemaillogs_prepare', methods: ['POST'])] + public function prepareEmailLogExportAction( + PrepareEmailLogExportHandler $handler, + PrepareEmailLogExportPayload $payload, + ): JsonResponse { + return $this->apiJson($handler($payload)); + } + + #[IsGranted(CorePermission::Emails->value)] + #[Route('/export-email-logs', name: 'opendxp_admin_email_exportemaillogs', methods: ['POST'])] + public function doEmailLogExportAction( + DoEmailLogExportHandler $handler, + DoEmailLogExportPayload $payload, + ): JsonResponse { + $handler($payload); + + return $this->apiOk(); + } + + #[IsGranted(CorePermission::Emails->value)] + #[Route('/export-email-logs/download', name: 'opendxp_admin_email_exportemaillogs_download', methods: ['GET'])] + public function downloadEmailLogExportAction( + GridExportService $gridExportService, + #[MapQueryParameter] ?string $fileHandle = null, + ): Response { + try { + return $gridExportService->downloadCsvFile($fileHandle ?? ''); + } catch (RuntimeException) { + throw $this->createNotFoundException('CSV file not found'); + } + } + #[IsGranted(CorePermission::Emails->value)] #[Route('/show-email-log', name: 'opendxp_admin_email_showemaillog', methods: ['GET'])] public function showEmailLogAction( diff --git a/src/Handler/Email/DoEmailLogExport/DoEmailLogExportHandler.php b/src/Handler/Email/DoEmailLogExport/DoEmailLogExportHandler.php new file mode 100644 index 00000000..844a4c40 --- /dev/null +++ b/src/Handler/Email/DoEmailLogExport/DoEmailLogExportHandler.php @@ -0,0 +1,123 @@ +listingFactory->create($payload->documentId, $payload->filter, $payload->ids); + $list->setLimit(self::PAGE_SIZE); + $list->setOffset($offset); + + $entries = $list->getEmailLogs(); + + foreach ($entries as $entry) { + fputcsv($temp, $this->toRow($entry), self::DELIMITER, '"', ''); + } + + $offset += self::PAGE_SIZE; + + } while (count($entries) === self::PAGE_SIZE); + + rewind($temp); + + Storage::get('temp')->writeStream( + $this->gridExportService->getCsvFile($payload->fileHandle), + $temp + ); + } catch (FilesystemException $exception) { + Logger::err($exception->getMessage()); + + throw new AdminOperationFailedException( + sprintf('export file could not be written: %s', $payload->fileHandle) + ); + } finally { + if (is_resource($temp)) { + fclose($temp); + } + } + } + + /** + * @return array + */ + private function toRow(Log $entry): array + { + return [ + $entry->getId(), + $entry->getDocumentId(), + date('Y-m-d H:i:s', $entry->getSentDate()), + $entry->getFrom(), + $entry->getReplyTo(), + $entry->getTo(), + $entry->getCc(), + $entry->getBcc(), + $entry->getSubject(), + json_encode($entry->getParams(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $entry->getError(), + ]; + } +} diff --git a/src/Handler/Email/DoEmailLogExport/DoEmailLogExportPayload.php b/src/Handler/Email/DoEmailLogExport/DoEmailLogExportPayload.php new file mode 100644 index 00000000..d65a7a4a --- /dev/null +++ b/src/Handler/Email/DoEmailLogExport/DoEmailLogExportPayload.php @@ -0,0 +1,44 @@ +request->getString('fileHandle'), + documentId: $request->request->has('documentId') ? (int) $request->request->getString('documentId') : null, + filter: $request->request->has('filter') ? $request->request->getString('filter') : null, + ids: array_map(intval(...), $request->request->all('ids')), + ); + } +} diff --git a/src/Handler/Email/GetEmailLogs/GetEmailLogsHandler.php b/src/Handler/Email/GetEmailLogs/GetEmailLogsHandler.php index 517cc21d..977e51e7 100644 --- a/src/Handler/Email/GetEmailLogs/GetEmailLogsHandler.php +++ b/src/Handler/Email/GetEmailLogs/GetEmailLogsHandler.php @@ -16,58 +16,24 @@ namespace OpenDxp\Bundle\AdminBundle\Handler\Email\GetEmailLogs; -use OpenDxp\Model\Tool; +use OpenDxp\Bundle\AdminBundle\Service\Email\EmailLogListingFactory; final class GetEmailLogsHandler { - public function __invoke(GetEmailLogsPayload $payload): GetEmailLogsResult + public function __construct(private readonly EmailLogListingFactory $listingFactory) { - $list = new Tool\Email\Log\Listing(); + } - if ($payload->documentId !== null) { - $list->setCondition('documentId = ' . $payload->documentId); - } + public function __invoke(GetEmailLogsPayload $payload): GetEmailLogsResult + { + $list = $this->listingFactory->create($payload->documentId, $payload->filter); $list->setLimit($payload->limit); $list->setOffset($payload->start); - $list->setOrderKey('sentDate'); - $list->setOrder('DESC'); - - if ($payload->filter !== null) { - $filter = $payload->filter === '*' ? '' : $payload->filter; - - $filter = str_replace('%', '*', $filter); - $filter = htmlspecialchars($filter, ENT_QUOTES); - - if (strpos($filter, '@')) { - $parts = explode(' ', $filter); - $parts = array_map(static function ($part) { - if (strpos($part, '@')) { - return '"' . $part . '"'; - } - - return $part; - }, $parts); - $filter = implode(' ', $parts); - } - - if (str_starts_with($filter, '@')) { - $filter = str_replace('@', '', $filter); - } - - $condition = '( MATCH (`from`,`to`,`cc`,`bcc`,`subject`,`params`) AGAINST (' . $list->quote($filter) . ' IN BOOLEAN MODE) )'; - - if ($payload->documentId !== null) { - $condition .= 'AND documentId = ' . $payload->documentId; - } - - $list->setCondition($condition); - } - $data = $list->load(); $jsonData = []; - foreach ($data as $entry) { + foreach ($list->getEmailLogs() as $entry) { $tmp = $entry->getObjectVars(); unset($tmp['bodyHtml'], $tmp['bodyText']); $jsonData[] = $tmp; diff --git a/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportHandler.php b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportHandler.php new file mode 100644 index 00000000..cac245cf --- /dev/null +++ b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportHandler.php @@ -0,0 +1,40 @@ +listingFactory->create($payload->documentId, $payload->filter, $payload->ids); + + $fileHandle = uniqid('email-log-export-', false); + Storage::get('temp')->write($this->gridExportService->getCsvFile($fileHandle), ''); + + return new PrepareEmailLogExportResult(fileHandle: $fileHandle, total: $list->getTotalCount()); + } +} diff --git a/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportPayload.php b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportPayload.php new file mode 100644 index 00000000..ffe9aa14 --- /dev/null +++ b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportPayload.php @@ -0,0 +1,42 @@ +request->has('documentId') ? (int) $request->request->getString('documentId') : null, + filter: $request->request->has('filter') ? $request->request->getString('filter') : null, + ids: array_map(intval(...), $request->request->all('ids')), + ); + } +} diff --git a/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportResult.php b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportResult.php new file mode 100644 index 00000000..79a66a15 --- /dev/null +++ b/src/Handler/Email/PrepareEmailLogExport/PrepareEmailLogExportResult.php @@ -0,0 +1,28 @@ +get('request_stack'); + + if (!$requestStack->getMainRequest()?->hasSession()) { + return []; + } + + return (new GridColumnConfigSessionGateway($requestStack))->getHelperColumns(); + } + /** * gets value for given object and getter, including inherited values * diff --git a/src/Service/Email/EmailLogListingFactory.php b/src/Service/Email/EmailLogListingFactory.php new file mode 100644 index 00000000..353f0b57 --- /dev/null +++ b/src/Service/Email/EmailLogListingFactory.php @@ -0,0 +1,83 @@ +quote($this->normalizeFilter($filter)) + ); + } + + if ($conditions !== []) { + $list->setCondition(implode(' AND ', $conditions), $variables); + } + + $list->setOrderKey(['sentDate', 'id']); + $list->setOrder(['DESC', 'DESC']); + + return $list; + } + + private function normalizeFilter(string $filter): string + { + $filter = $filter === '*' ? '' : $filter; + + $filter = str_replace('%', '*', $filter); + $filter = htmlspecialchars($filter, ENT_QUOTES); + + // A bare address would be split on "@", so keep addresses together as phrases. + if (strpos($filter, '@')) { + $parts = explode(' ', $filter); + $parts = array_map(static function ($part) { + if (strpos($part, '@')) { + return '"' . $part . '"'; + } + + return $part; + }, $parts); + $filter = implode(' ', $parts); + } + + if (str_starts_with($filter, '@')) { + $filter = str_replace('@', '', $filter); + } + + return $filter; + } +} diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml index 441db92c..1766bbfa 100644 --- a/translations/admin.de.yaml +++ b/translations/admin.de.yaml @@ -331,6 +331,7 @@ email_log_resend_window_error_message: 'Ein Fehler ist aufgetreten. Die E-Mail k email_log_resend_window_msg: 'Bitte bestätige, dass du die E-Mail wiederholt an alle Empfänger versenden möchtest.' email_log_resend_window_success_message: 'Die E-Mail wurde erfolgreich an alle Empfänger versendet.' email_log_sent_Date: 'Datum gesendet' +email_log_export_confirmation: 'Sie sind dabei, %s Email-Log-Einträge zu exportieren. Möchten Sie fortfahren?' email_log_subject: 'Subject' email_log_to: 'An' email_logs: 'Versendete E-Mails' diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml index 2e3f94e6..b799165e 100644 --- a/translations/admin.en.yaml +++ b/translations/admin.en.yaml @@ -331,6 +331,7 @@ email_log_resend_window_error_message: 'An error occurred. The email has not bee email_log_resend_window_msg: 'Please confirm that you want to send the email again to all recipients.' email_log_resend_window_success_message: 'The email has been sent successfully to all recipients.' email_log_sent_Date: 'Date sent' +email_log_export_confirmation: 'You are about to export %s email log entries. Do you want to continue?' email_log_subject: 'Subject' email_log_to: 'To' email_logs: 'Sent Emails'