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'