├── .github ├── cherry-pick-bot.yml └── workflows │ ├── build.yml │ ├── check-snippets.yml │ ├── store-info.yml │ └── code-style.yml ├── src ├── Resources │ ├── app │ │ └── administration │ │ │ └── src │ │ │ ├── main.js │ │ │ ├── module │ │ │ └── frosh-mail-archive │ │ │ │ ├── page │ │ │ │ ├── frosh-mail-archive-index │ │ │ │ │ ├── frosh-mail-archive-index.scss │ │ │ │ │ ├── frosh-mail-archive-index.twig │ │ │ │ │ └── index.js │ │ │ │ └── frosh-mail-archive-detail │ │ │ │ │ ├── frosh-mail-archive-detail.scss │ │ │ │ │ ├── index.js │ │ │ │ │ └── frosh-mail-archive-detail.twig │ │ │ │ ├── index.js │ │ │ │ ├── component │ │ │ │ └── frosh-mail-resend-history │ │ │ │ │ ├── frosh-mail-resend-history.html.twig │ │ │ │ │ └── index.js │ │ │ │ └── snippet │ │ │ │ ├── en-GB.json │ │ │ │ └── de-DE.json │ │ │ └── init │ │ │ ├── api.init.js │ │ │ └── api_client.js │ ├── config │ │ ├── routes.yaml │ │ ├── plugin.png │ │ ├── services.xml │ │ └── config.xml │ └── store │ │ ├── icon.png │ │ ├── img-0.png │ │ ├── img-1.png │ │ ├── images │ │ ├── 1.png │ │ └── 2.png │ │ ├── store.json │ │ ├── en.md │ │ └── de.md ├── Task │ ├── CleanupTask.php │ └── CleanupTaskHandler.php ├── Migration │ ├── Migration1707002823DropEmlFiled.php │ ├── Migration1694714751TransportInfo.php │ ├── Migration1694604822AddSourceMailId.php │ ├── Migration1713775973AlterSubjectFieldLength.php │ ├── Migration1739741754AddFlowId.php │ ├── Migration1690743548AddEmlPath.php │ ├── Migration1739731953DropCustomerFK.php │ ├── Migration1598204175SenderToJson.php │ ├── Migration1739730285AddOrderId.php │ ├── Migration1691326842Attachment.php │ ├── Migration1739273849MigrateResentState.php │ └── Migration1575569953createTable.php ├── Subscriber │ ├── SendMailActionSubscriber.php │ ├── MailBeforeSentSubscriber.php │ ├── MailArchiveDeleteSubscriber.php │ └── MailTransportSubscriber.php ├── FroshPlatformMailArchive.php ├── Extension │ ├── Content │ │ └── Flow │ │ │ └── FlowExtension.php │ ├── Checkout │ │ ├── Order │ │ │ └── OrderExtension.php │ │ └── Customer │ │ │ └── CustomerExtension.php │ └── System │ │ └── SalesChannel │ │ └── SalesChannelExtension.php ├── Content │ └── MailArchive │ │ ├── MailArchiveException.php │ │ ├── MailArchiveAttachmentEntity.php │ │ ├── MailArchiveAttachmentDefinition.php │ │ ├── MailArchiveDefinition.php │ │ └── MailArchiveEntity.php ├── Services │ ├── FroshToolsChecker.php │ ├── EmlFileManager.php │ └── MailSender.php └── Controller │ └── Api │ └── MailArchiveController.php ├── phpstan.neon.dist ├── .gitignore ├── README.md ├── LICENSE.md ├── composer.json └── .shopware-extension.yml /.github/cherry-pick-bot.yml: -------------------------------------------------------------------------------- 1 | enabled: true 2 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/main.js: -------------------------------------------------------------------------------- 1 | import './init/api.init'; 2 | import './module/frosh-mail-archive'; 3 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | reportUnmatchedIgnoredErrors: false 3 | level: max 4 | paths: 5 | - src 6 | -------------------------------------------------------------------------------- /src/Resources/config/routes.yaml: -------------------------------------------------------------------------------- 1 | frosh_mail_archive: 2 | resource: ../../Controller/**/*Controller.php 3 | type: attribute 4 | -------------------------------------------------------------------------------- /src/Resources/store/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/store/icon.png -------------------------------------------------------------------------------- /src/Resources/store/img-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/store/img-0.png -------------------------------------------------------------------------------- /src/Resources/store/img-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/store/img-1.png -------------------------------------------------------------------------------- /src/Resources/config/plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/config/plugin.png -------------------------------------------------------------------------------- /src/Resources/store/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/store/images/1.png -------------------------------------------------------------------------------- /src/Resources/store/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfShopware/FroshPlatformMailArchive/HEAD/src/Resources/store/images/2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.settings/ 2 | /.buildpath 3 | /.project 4 | frosh-platform-mail-archive.css 5 | frosh-platform-mail-archive.js 6 | /vendor/ 7 | /src/Resources/public/ 8 | /composer.lock 9 | .php-cs-fixer.cache 10 | 11 | src/Resources/app/administration/src/.vite 12 | src/Resources/public 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/page/frosh-mail-archive-index/frosh-mail-archive-index.scss: -------------------------------------------------------------------------------- 1 | .sw-sidebar-item__scrollable-container { 2 | padding: 10px; 3 | } 4 | 5 | .frosh-mail-archive__data-grid-danger-icon { 6 | margin-left: 10px; 7 | color: #f00; 8 | } 9 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/init/api.init.js: -------------------------------------------------------------------------------- 1 | import ApiClient from './api_client'; 2 | 3 | const { Application } = Shopware; 4 | 5 | Application.addServiceProvider('froshMailArchiveService', (container) => { 6 | const initContainer = Application.getContainer('init'); 7 | return new ApiClient(initContainer.httpClient, container.loginService); 8 | }); 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build extension 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | uses: FriendsOfShopware/actions/.github/workflows/store-shopware-cli.yml@main 11 | with: 12 | extensionName: ${{ github.event.repository.name }} 13 | secrets: 14 | accountUser: ${{ secrets.ACCOUNT_USER }} 15 | accountPassword: ${{ secrets.ACCOUNT_PASSWORD }} 16 | ghToken: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /src/Resources/store/store.json: -------------------------------------------------------------------------------- 1 | { 2 | "storeAvailabilities": [ 3 | "International", 4 | "German" 5 | ], 6 | "standardLocale": "en_GB", 7 | "localizations": ["de_DE","en_GB","fr_FR","nl_NL","es_ES","it_IT"], 8 | "categories": ["BackendBearbeitung"], 9 | "productType": "extension", 10 | "responsive": true, 11 | "tags": { 12 | "en": [ 13 | "mail", 14 | "archive" 15 | ], 16 | "de": [ 17 | "mail", 18 | "archive" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Resources/store/en.md: -------------------------------------------------------------------------------- 1 | With this plugin you get a simple searchable archive for emails that are sent from Shopware. 2 | The e-mails can be found via the menu path Settings/Extensions/Main Archive. 3 | 4 | This plugin is part of [@FriendsOfShopware](https://store.shopware.com/en/friends-of-shopware.html). 5 | Maintainer from the plugin is: [Soner Sayakci](https://github.com/shyim) 6 | 7 | For questions or bugs please create a [Github Issue](https://github.com/FriendsOfShopware/FroshPlatformMailArchive/issues/new) 8 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Task/CleanupTask.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cleanup 5 | Aufräumen 6 | 7 | deleteMessageAfterDays 8 | 9 | 10 | Use 0 to disable this 11 | Benutze 0 um dieses Feature zu deaktivieren 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Migration/Migration1707002823DropEmlFiled.php: -------------------------------------------------------------------------------- 1 | executeStatement('ALTER TABLE `frosh_mail_archive` DROP COLUMN `eml`'); 20 | } 21 | 22 | public function updateDestructive(Connection $connection): void {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Migration/Migration1694714751TransportInfo.php: -------------------------------------------------------------------------------- 1 | executeStatement('ALTER TABLE `frosh_mail_archive` ADD `transport_state` VARCHAR(255) NULL;'); 20 | } 21 | 22 | public function updateDestructive(Connection $connection): void {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Migration/Migration1694604822AddSourceMailId.php: -------------------------------------------------------------------------------- 1 | executeStatement('ALTER TABLE `frosh_mail_archive` ADD `source_mail_id` BINARY(16) NULL;'); 20 | } 21 | 22 | public function updateDestructive(Connection $connection): void {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Migration/Migration1713775973AlterSubjectFieldLength.php: -------------------------------------------------------------------------------- 1 | executeStatement('ALTER TABLE `frosh_mail_archive` MODIFY `subject` VARCHAR(998) NOT NULL;'); 20 | } 21 | 22 | public function updateDestructive(Connection $connection): void {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Migration/Migration1739741754AddFlowId.php: -------------------------------------------------------------------------------- 1 | executeStatement(' 20 | ALTER TABLE `frosh_mail_archive` 21 | ADD COLUMN `flow_id` BINARY(16) NULL;'); 22 | } 23 | 24 | public function updateDestructive(Connection $connection): void {} 25 | } 26 | -------------------------------------------------------------------------------- /src/Migration/Migration1690743548AddEmlPath.php: -------------------------------------------------------------------------------- 1 | executeStatement('ALTER TABLE `frosh_mail_archive` 20 | ADD `eml_path` varchar(2048) NULL;'); 21 | } 22 | 23 | public function updateDestructive(Connection $connection): void {} 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FroshPlatformMailArchive 2 | 3 | ## Description 4 | 5 | This plugin adds an MailArchive to your Shopware-Administration stored in Database. 6 | 7 | ### Features 8 | 9 | * Save all outgoing mails 10 | * Linking to customer 11 | * Resend Mail 12 | * Save attachments 13 | * Download EML 14 | * Search in List 15 | 16 | ## Zip-Installation 17 | 18 | * Download the [latest plugin version](https://github.com/FriendsOfShopware/FroshPlatformMailArchive/releases/latest/) 19 | * Upload and install plugin using Plugin Manager 20 | 21 | ## Contributing 22 | 23 | Feel free to fork and send [pull requests](https://github.com/FriendsOfShopware/FroshPlatformMailArchive)! 24 | 25 | ## Licence 26 | 27 | This project uses the [MIT License](LICENSE.md). 28 | -------------------------------------------------------------------------------- /src/Migration/Migration1739731953DropCustomerFK.php: -------------------------------------------------------------------------------- 1 | executeStatement(' 20 | ALTER TABLE `frosh_mail_archive` 21 | DROP FOREIGN KEY `fk.frosh_mail_archive.customerId`; 22 | '); 23 | } 24 | 25 | public function updateDestructive(Connection $connection): void {} 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/store-info.yml: -------------------------------------------------------------------------------- 1 | name: Update Store Info 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | paths: 7 | - 'src/Resources/store/**' 8 | - '.shopware-extension.yml' 9 | - 'composer.json' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | update-store-info: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | - name: Install shopware-cli 19 | uses: shopware/shopware-cli-action@v1 20 | - name: Build 21 | shell: bash 22 | run: shopware-cli account producer extension info push . 23 | env: 24 | SHOPWARE_CLI_ACCOUNT_EMAIL: ${{ secrets.ACCOUNT_USER }} 25 | SHOPWARE_CLI_ACCOUNT_PASSWORD: ${{ secrets.ACCOUNT_PASSWORD }} 26 | -------------------------------------------------------------------------------- /src/Migration/Migration1598204175SenderToJson.php: -------------------------------------------------------------------------------- 1 | executeStatement('UPDATE frosh_mail_archive SET sender = JSON_OBJECT(sender, \'\')'); 20 | $connection->executeStatement('ALTER TABLE `frosh_mail_archive` 21 | CHANGE `sender` `sender` json NOT NULL AFTER `id`;'); 22 | } 23 | 24 | public function updateDestructive(Connection $connection): void {} 25 | } 26 | -------------------------------------------------------------------------------- /src/Migration/Migration1739730285AddOrderId.php: -------------------------------------------------------------------------------- 1 | executeStatement(' 20 | ALTER TABLE `frosh_mail_archive` 21 | ADD COLUMN `order_id` BINARY(16) NULL, 22 | ADD COLUMN `order_version_id` BINARY(16) NULL 23 | ; 24 | '); 25 | } 26 | 27 | public function updateDestructive(Connection $connection): void {} 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | cs: 11 | if: github.event_name != 'schedule' 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Run CS 18 | uses: shopware/github-actions/extension-verifier@main 19 | with: 20 | action: format 21 | 22 | check: 23 | runs-on: ubuntu-24.04 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | version-selection: [ 'lowest', 'highest'] 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Run Check 33 | uses: shopware/github-actions/extension-verifier@main 34 | with: 35 | action: check 36 | check-against: ${{ matrix.version-selection }} 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Friends of Shopware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Subscriber/SendMailActionSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onSendMailAction', 18 | ]; 19 | } 20 | 21 | public function onSendMailAction(FlowSendMailActionEvent $e): void 22 | { 23 | $flow = $e->getStorableFlow(); 24 | $customerId = $flow->getData(CustomerAware::CUSTOMER_ID); 25 | $orderId = $flow->getData(OrderAware::ORDER_ID); 26 | $flowId = $flow->getFlowState()->flowId; 27 | 28 | $e->getDataBag()->set('customerId', $customerId); 29 | $e->getDataBag()->set('orderId', $orderId); 30 | $e->getDataBag()->set('flowId', $flowId); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FroshPlatformMailArchive.php: -------------------------------------------------------------------------------- 1 | keepUserData()) { 17 | return; 18 | } 19 | 20 | $container = $this->container; 21 | if (!$container instanceof ContainerInterface) { 22 | return; 23 | } 24 | 25 | $connection = $container->get(Connection::class); 26 | if (!$connection instanceof Connection) { 27 | return; 28 | } 29 | 30 | $connection->executeStatement('DROP TABLE IF EXISTS frosh_mail_archive_attachment'); 31 | $connection->executeStatement('DROP TABLE IF EXISTS frosh_mail_archive'); 32 | } 33 | 34 | public function executeComposerCommands(): bool 35 | { 36 | return true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Extension/Content/Flow/FlowExtension.php: -------------------------------------------------------------------------------- 1 | add( 19 | (new OneToManyAssociationField( 20 | 'froshMailArchive', 21 | MailArchiveDefinition::class, 22 | 'flow_id', 23 | ))->addFlags(new SetNullOnDelete(false)), 24 | ); 25 | } 26 | 27 | public function getEntityName(): string 28 | { 29 | return FlowDefinition::ENTITY_NAME; 30 | } 31 | 32 | public function getDefinitionClass(): string 33 | { 34 | return FlowDefinition::class; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Extension/Checkout/Order/OrderExtension.php: -------------------------------------------------------------------------------- 1 | add( 19 | (new OneToManyAssociationField( 20 | 'froshMailArchive', 21 | MailArchiveDefinition::class, 22 | 'order_id', 23 | ))->addFlags(new SetNullOnDelete(false)), 24 | ); 25 | } 26 | 27 | public function getEntityName(): string 28 | { 29 | return OrderDefinition::ENTITY_NAME; 30 | } 31 | 32 | public function getDefinitionClass(): string 33 | { 34 | return OrderDefinition::class; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Subscriber/MailBeforeSentSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onMailBeforeSent', 17 | ]; 18 | } 19 | 20 | public function onMailBeforeSent(MailBeforeSentEvent $e): void 21 | { 22 | /** @var string $customerId */ 23 | $customerId = $e->getData()['customerId'] ?? ''; 24 | 25 | /** @var string $orderId */ 26 | $orderId = $e->getData()['orderId'] ?? ''; 27 | 28 | /** @var string $flowId */ 29 | $flowId = $e->getData()['flowId'] ?? ''; 30 | 31 | $e->getMessage()->getHeaders()->addTextHeader(MailSender::FROSH_CUSTOMER_ID_HEADER, $customerId); 32 | $e->getMessage()->getHeaders()->addTextHeader(MailSender::FROSH_ORDER_ID_HEADER, $orderId); 33 | $e->getMessage()->getHeaders()->addTextHeader(MailSender::FROSH_FLOW_ID_HEADER, $flowId); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Extension/System/SalesChannel/SalesChannelExtension.php: -------------------------------------------------------------------------------- 1 | add( 20 | new OneToManyAssociationField( 21 | 'froshMailArchive', 22 | MailArchiveDefinition::class, 23 | 'salesChannelId', 24 | ), 25 | ); 26 | } 27 | 28 | public function getEntityName(): string 29 | { 30 | return SalesChannelDefinition::ENTITY_NAME; 31 | } 32 | 33 | public function getDefinitionClass(): string 34 | { 35 | return SalesChannelDefinition::class; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Migration/Migration1691326842Attachment.php: -------------------------------------------------------------------------------- 1 | executeStatement('CREATE TABLE `frosh_mail_archive_attachment` ( 20 | `id` BINARY(16) NOT NULL, 21 | `mail_archive_id` BINARY(16) NULL, 22 | `file_name` VARCHAR(255) NOT NULL, 23 | `content_type` VARCHAR(255) NOT NULL, 24 | `file_size` INT(11) NOT NULL, 25 | `created_at` DATETIME(3) NOT NULL, 26 | `updated_at` DATETIME(3) NULL, 27 | PRIMARY KEY (`id`), 28 | KEY `fk.frosh_mail_archive_attachment.mail_archive_id` (`mail_archive_id`), 29 | CONSTRAINT `fk.frosh_mail_archive_attachment.mail_archive_id` FOREIGN KEY (`mail_archive_id`) REFERENCES `frosh_mail_archive` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 30 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 31 | '); 32 | } 33 | 34 | public function updateDestructive(Connection $connection): void {} 35 | } 36 | -------------------------------------------------------------------------------- /src/Migration/Migration1739273849MigrateResentState.php: -------------------------------------------------------------------------------- 1 | fetchFirstColumn('SELECT source_mail_id FROM frosh_mail_archive WHERE source_mail_id IS NOT NULL GROUP BY source_mail_id;'); 21 | 22 | if ($sourceMailIds === []) { 23 | return; 24 | } 25 | 26 | $updateQuery = $connection->createQueryBuilder(); 27 | $updateQuery->update('frosh_mail_archive'); 28 | $updateQuery->set('transport_state', '\'resent\''); 29 | $updateQuery->where('id IN (:ids)'); 30 | 31 | foreach (array_chunk($sourceMailIds, 1000) as $chunk) { 32 | $updateQuery->setParameter('ids', $chunk, ArrayParameterType::BINARY); 33 | $updateQuery->executeStatement(); 34 | } 35 | } 36 | 37 | public function updateDestructive(Connection $connection): void {} 38 | } 39 | -------------------------------------------------------------------------------- /src/Extension/Checkout/Customer/CustomerExtension.php: -------------------------------------------------------------------------------- 1 | add( 21 | (new OneToManyAssociationField( 22 | 'froshMailArchive', 23 | MailArchiveDefinition::class, 24 | 'customerId', 25 | ))->addFlags(new SetNullOnDelete(false)), 26 | ); 27 | } 28 | 29 | public function getEntityName(): string 30 | { 31 | return CustomerDefinition::ENTITY_NAME; 32 | } 33 | 34 | public function getDefinitionClass(): string 35 | { 36 | return CustomerDefinition::class; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/index.js: -------------------------------------------------------------------------------- 1 | import './page/frosh-mail-archive-index/index'; 2 | import './page/frosh-mail-archive-detail/index'; 3 | import './component/frosh-mail-resend-history'; 4 | 5 | Shopware.Module.register('frosh-mail-archive', { 6 | type: 'plugin', 7 | name: 'frosh-mail-archive.title', 8 | title: 'frosh-mail-archive.title', 9 | description: '', 10 | color: '#9AA8B5', 11 | icon: 'regular-envelope', 12 | entity: 'frosh_mail_archive', 13 | 14 | routes: { 15 | list: { 16 | component: 'frosh-mail-archive-index', 17 | path: 'list', 18 | meta: { 19 | privilege: 'frosh_mail_archive:read', 20 | parentPath: 'sw.settings.index.plugins', 21 | }, 22 | }, 23 | detail: { 24 | component: 'frosh-mail-archive-detail', 25 | path: 'detail/:id', 26 | meta: { 27 | parentPath: 'frosh.mail.archive.list', 28 | privilege: 'frosh_mail_archive:read', 29 | }, 30 | props: { 31 | default: ($route) => { 32 | return { archiveId: $route.params.id }; 33 | }, 34 | }, 35 | }, 36 | }, 37 | 38 | settingsItem: [ 39 | { 40 | group: 'plugins', 41 | to: 'frosh.mail.archive.list', 42 | icon: 'regular-envelope', 43 | name: 'frosh-mail-archive.title', 44 | privilege: 'frosh_mail_archive:read', 45 | }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /src/Migration/Migration1575569953createTable.php: -------------------------------------------------------------------------------- 1 | executeStatement('CREATE TABLE `frosh_mail_archive` ( 20 | `id` BINARY(16) NOT NULL, 21 | `sender` VARCHAR(255) NOT NULL, 22 | `receiver` JSON NOT NULL, 23 | `subject` VARCHAR(255) NOT NULL, 24 | `plainText` LONGTEXT NULL, 25 | `htmlText` LONGTEXT NULL, 26 | `eml` LONGTEXT NULL, 27 | `salesChannelId` BINARY(16) NULL, 28 | `customerId` BINARY(16) NULL, 29 | `created_at` DATETIME(3) NOT NULL, 30 | `updated_at` DATETIME(3) NULL, 31 | PRIMARY KEY (`id`), 32 | CONSTRAINT `json.frosh_mail_archive.receiver` CHECK (JSON_VALID(`receiver`)), 33 | KEY `fk.frosh_mail_archive.salesChannelId` (`salesChannelId`), 34 | KEY `fk.frosh_mail_archive.customerId` (`customerId`), 35 | CONSTRAINT `fk.frosh_mail_archive.salesChannelId` FOREIGN KEY (`salesChannelId`) REFERENCES `sales_channel` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, 36 | CONSTRAINT `fk.frosh_mail_archive.customerId` FOREIGN KEY (`customerId`) REFERENCES `customer` (`id`) ON DELETE SET NULL ON UPDATE CASCADE 37 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 38 | '); 39 | } 40 | 41 | public function updateDestructive(Connection $connection): void 42 | { 43 | // implement update destructive 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Content/MailArchive/MailArchiveException.php: -------------------------------------------------------------------------------- 1 | $parameter], 33 | ); 34 | } 35 | 36 | public static function parameterInvalidUuid(string $parameter): self 37 | { 38 | return new self( 39 | Response::HTTP_BAD_REQUEST, 40 | self::INVALID_UUID_CODE, 41 | 'Parameter "{{parameter}}" is not a valid UUID', 42 | ['parameter' => $parameter], 43 | ); 44 | } 45 | 46 | public static function unreadableEml(string $path): self 47 | { 48 | return new self( 49 | Response::HTTP_INTERNAL_SERVER_ERROR, 50 | self::UNREADABLE_EML_CODE, 51 | 'Cannot read eml file at "{{path}}" or file is empty', 52 | ['path' => $path], 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Content/MailArchive/MailArchiveAttachmentEntity.php: -------------------------------------------------------------------------------- 1 | mailArchiveId; 27 | } 28 | 29 | public function setMailArchiveId(string $mailArchiveId): void 30 | { 31 | $this->mailArchiveId = $mailArchiveId; 32 | } 33 | 34 | public function getMailArchive(): ?MailArchiveEntity 35 | { 36 | return $this->mailArchive; 37 | } 38 | 39 | public function setMailArchive(?MailArchiveEntity $mailArchive): void 40 | { 41 | $this->mailArchive = $mailArchive; 42 | } 43 | 44 | public function getFileName(): string 45 | { 46 | return $this->fileName; 47 | } 48 | 49 | public function setFileName(string $fileName): void 50 | { 51 | $this->fileName = $fileName; 52 | } 53 | 54 | public function getContentType(): string 55 | { 56 | return $this->contentType; 57 | } 58 | 59 | public function setContentType(string $contentType): void 60 | { 61 | $this->contentType = $contentType; 62 | } 63 | 64 | public function getFileSize(): int 65 | { 66 | return $this->fileSize; 67 | } 68 | 69 | public function setFileSize(int $fileSize): void 70 | { 71 | $this->fileSize = $fileSize; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Content/MailArchive/MailArchiveAttachmentDefinition.php: -------------------------------------------------------------------------------- 1 | addFlags(new PrimaryKey(), new Required()), 37 | new FkField('mail_archive_id', 'mailArchiveId', MailArchiveDefinition::class), 38 | new ManyToOneAssociationField('mailArchive', 'mail_archive_id', MailArchiveDefinition::class, 'id', false), 39 | 40 | (new StringField('file_name', 'fileName'))->addFlags(new Required()), 41 | (new StringField('content_type', 'contentType'))->addFlags(new Required()), 42 | (new IntField('file_size', 'fileSize'))->addFlags(new Required()), 43 | ]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Subscriber/MailArchiveDeleteSubscriber.php: -------------------------------------------------------------------------------- 1 | > $froshMailArchiveRepository 20 | */ 21 | public function __construct( 22 | private readonly EntityRepository $froshMailArchiveRepository, 23 | private readonly EmlFileManager $emlFileManager, 24 | ) {} 25 | 26 | public static function getSubscribedEvents(): array 27 | { 28 | return [ 29 | EntityDeleteEvent::class => 'beforeDelete', 30 | ]; 31 | } 32 | 33 | public function beforeDelete(EntityDeleteEvent $event): void 34 | { 35 | /** @var array $ids */ 36 | $ids = array_values($event->getIds(MailArchiveDefinition::ENTITY_NAME)); 37 | if (empty($ids)) { 38 | return; 39 | } 40 | 41 | $criteria = new Criteria($ids); 42 | $criteria->addFields(['emlPath']); 43 | $mails = $this->froshMailArchiveRepository->search($criteria, $event->getContext())->getEntities(); 44 | 45 | foreach ($mails as $mail) { 46 | $emlPath = $mail->get('emlPath'); 47 | if (empty($emlPath) || !\is_string($emlPath)) { 48 | continue; 49 | } 50 | 51 | $event->addSuccess(function () use ($emlPath): void { 52 | $this->emlFileManager->deleteEmlFile($emlPath); 53 | }); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Services/FroshToolsChecker.php: -------------------------------------------------------------------------------- 1 | > $froshMailArchiveRepository 25 | */ 26 | public function __construct( 27 | private readonly EntityRepository $froshMailArchiveRepository, 28 | ) {} 29 | 30 | public function collect(HealthCollection $collection): void 31 | { 32 | $criteria = new Criteria(); 33 | $criteria->addFilter(new EqualsFilter('transportState', MailSender::TRANSPORT_STATE_FAILED)); 34 | 35 | $count = $this->froshMailArchiveRepository->searchIds($criteria, new Context(new SystemSource()))->getTotal(); 36 | 37 | $result = new SettingsResult(); 38 | $result->assign([ 39 | 'id' => 'frosh_mail_archive_failed', 40 | 'snippet' => 'Failed mails in MailArchive', 41 | 'current' => (string) $count, 42 | 'recommended' => '0', 43 | 'state' => $count === 0 ? SettingsResult::GREEN : SettingsResult::ERROR, 44 | ]); 45 | 46 | $collection->add($result); 47 | } 48 | } 49 | } else { 50 | class FroshToolsChecker {} 51 | } 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frosh/mail-platform-archive", 3 | "version": "3.5.5", 4 | "description": "Mail Archive", 5 | "type": "shopware-platform-plugin", 6 | "license": "MIT", 7 | "keywords": [ 8 | "mail", 9 | "archive" 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Frosh\\MailArchive\\": "src/" 14 | } 15 | }, 16 | "authors": [ 17 | { 18 | "name": "FriendsOfShopware", 19 | "homepage": "https://friendsofshopware.de" 20 | } 21 | ], 22 | "extra": { 23 | "shopware-plugin-class": "Frosh\\MailArchive\\FroshPlatformMailArchive", 24 | "plugin-icon": "src/Resources/config/plugin.png", 25 | "label": { 26 | "de-DE": "Mail Archive", 27 | "en-GB": "Mail Archive" 28 | }, 29 | "description": { 30 | "de-DE": "Mit diesem Plugin erhalten Sie ein einfaches durchsuchbares Archiv für E-Mails, die aus Shopware versendet werden. Die E-Mails sind erreichbar: Einstellungen/Erweiterungen/Main Archiv.", 31 | "en-GB": "With this plugin you get a simple searchable archive for emails that are sent from Shopware. The e-mails can be found via the menu path Settings/Extensions/Main Archive.." 32 | }, 33 | "manufacturerLink": { 34 | "de-DE": "https://github.com/FriendsOfShopware/FroshPlatformMailArchive", 35 | "en-GB": "https://github.com/FriendsOfShopware/FroshPlatformMailArchive" 36 | }, 37 | "supportLink": { 38 | "de-DE": "https://github.com/FriendsOfShopware/FroshPlatformMailArchive/issues", 39 | "en-GB": "https://github.com/FriendsOfShopware/FroshPlatformMailArchive/issues" 40 | } 41 | }, 42 | "require": { 43 | "shopware/core": "~6.6.0 || ~6.7.0", 44 | "zbateson/mail-mime-parser": "^3.0" 45 | }, 46 | "require-dev": { 47 | "frosh/tools": ">2.1.3" 48 | }, 49 | "conflict": { 50 | "frosh/tools": "<2.1.3" 51 | }, 52 | "config": { 53 | "allow-plugins": { 54 | "symfony/runtime": true 55 | } 56 | }, 57 | "scripts": { 58 | "format": "docker run --rm -v $(pwd):/ext shopware/shopware-cli:latest extension format /ext", 59 | "check": "docker run --rm -v $(pwd):/ext shopware/shopware-cli:latest extension validate --full /ext" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/component/frosh-mail-resend-history/frosh-mail-resend-history.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 58 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/component/frosh-mail-resend-history/index.js: -------------------------------------------------------------------------------- 1 | const { Criteria } = Shopware.Data; 2 | import template from './frosh-mail-resend-history.html.twig'; 3 | 4 | Shopware.Component.register('frosh-mail-resend-history', { 5 | props: { 6 | sourceMailId: { 7 | required: true, 8 | type: String, 9 | }, 10 | currentMailId: { 11 | required: true, 12 | type: String, 13 | }, 14 | }, 15 | template, 16 | data() { 17 | return { 18 | resentMails: [], 19 | isLoading: false, 20 | columns: [ 21 | { 22 | property: 'createdAt', 23 | label: this.$tc( 24 | 'frosh-mail-archive.detail.resend-grid.column-created-at' 25 | ), 26 | primary: true, 27 | }, 28 | { 29 | property: 'success', 30 | label: this.$tc( 31 | 'frosh-mail-archive.detail.resend-grid.column-state' 32 | ), 33 | sortable: false, 34 | }, 35 | ], 36 | }; 37 | }, 38 | inject: ['repositoryFactory'], 39 | computed: { 40 | mailArchiveRepository() { 41 | return this.repositoryFactory.create('frosh_mail_archive'); 42 | }, 43 | date() { 44 | return Shopware.Filter.getByName('date'); 45 | }, 46 | }, 47 | async created() { 48 | this.isLoading = true; 49 | await this.loadMails(); 50 | this.isLoading = false; 51 | }, 52 | methods: { 53 | translateState(state) { 54 | return this.$tc(`frosh-mail-archive.state.${state}`); 55 | }, 56 | async loadMails() { 57 | const criteria = new Criteria(); 58 | criteria.addFilter( 59 | Criteria.multi('OR', [ 60 | Criteria.equals('id', this.sourceMailId), 61 | Criteria.equals('sourceMailId', this.sourceMailId), 62 | ]) 63 | ); 64 | criteria.addSorting(Criteria.sort('createdAt', 'DESC')); 65 | 66 | this.resentMails = await this.mailArchiveRepository.search( 67 | criteria, 68 | Shopware.Context.api 69 | ); 70 | }, 71 | navigateToDetailPage(id) { 72 | this.$router.push({ 73 | name: 'frosh.mail.archive.detail', 74 | params: { id }, 75 | }); 76 | }, 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /src/Task/CleanupTaskHandler.php: -------------------------------------------------------------------------------- 1 | $scheduledTaskRepository 24 | */ 25 | public function __construct( 26 | EntityRepository $scheduledTaskRepository, 27 | private readonly SystemConfigService $configService, 28 | private readonly Connection $connection, 29 | private readonly EmlFileManager $emlFileManager, 30 | LoggerInterface $exceptionLogger, 31 | ) { 32 | parent::__construct($scheduledTaskRepository, $exceptionLogger); 33 | } 34 | 35 | public function run(): void 36 | { 37 | $days = $this->configService->getInt('FroshPlatformMailArchive.config.deleteMessageAfterDays'); 38 | 39 | if ($days === 0) { 40 | return; 41 | } 42 | 43 | $time = new \DateTime(); 44 | $time->modify(\sprintf('-%s days', $days)); 45 | 46 | $query = $this->connection->createQueryBuilder(); 47 | 48 | $query->select('id', 'eml_path'); 49 | $query->from(MailArchiveDefinition::ENTITY_NAME); 50 | $query->where( 51 | $query->expr()->lte( 52 | 'created_at', 53 | $query->createNamedParameter($time->format(Defaults::STORAGE_DATE_TIME_FORMAT)), 54 | ), 55 | ); 56 | 57 | $result = $query->executeQuery()->fetchAllAssociative(); 58 | 59 | if (\count($result) === 0) { 60 | return; 61 | } 62 | 63 | foreach ($result as $item) { 64 | if (empty($item['eml_path']) || !\is_string($item['eml_path'])) { 65 | continue; 66 | } 67 | 68 | $this->emlFileManager->deleteEmlFile($item['eml_path']); 69 | } 70 | 71 | $deleteQuery = $this->connection->createQueryBuilder(); 72 | $deleteQuery->delete(MailArchiveDefinition::ENTITY_NAME); 73 | $deleteQuery->where('id IN (:ids)'); 74 | $deleteQuery->setParameter('ids', \array_column($result, 'id'), ArrayParameterType::STRING); 75 | 76 | $deleteQuery->executeQuery(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Services/EmlFileManager.php: -------------------------------------------------------------------------------- 1 | filesystem->write($emlFilePath, $content); 44 | 45 | return $emlFilePath; 46 | } 47 | 48 | public function getEmlFileAsString(string $emlFilePath): false|string 49 | { 50 | try { 51 | $extension = \pathinfo($emlFilePath, \PATHINFO_EXTENSION); 52 | 53 | $content = $this->filesystem->read($emlFilePath); 54 | 55 | if ($extension === self::COMPRESSION_EXT_ZSTD) { 56 | return \zstd_uncompress($content); 57 | } 58 | 59 | return \gzuncompress($content); 60 | } catch (\Throwable) { 61 | return false; 62 | } 63 | } 64 | 65 | public function getEmlAsMessage(string $emlFilePath): false|IMessage 66 | { 67 | $emlResource = fopen('php://memory', 'r+b'); 68 | 69 | if (!\is_resource($emlResource)) { 70 | return false; 71 | } 72 | 73 | $content = $this->getEmlFileAsString($emlFilePath); 74 | 75 | if ($content === '' || $content === '0' || $content === false) { 76 | return false; 77 | } 78 | 79 | fwrite($emlResource, $content); 80 | rewind($emlResource); 81 | 82 | return (new MailMimeParser())->parse($emlResource, false); 83 | } 84 | 85 | public function deleteEmlFile(string $emlFilePath): void 86 | { 87 | if (!$this->filesystem->fileExists($emlFilePath)) { 88 | return; 89 | } 90 | 91 | $this->filesystem->delete($emlFilePath); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/init/api_client.js: -------------------------------------------------------------------------------- 1 | const ApiService = Shopware.Classes.ApiService; 2 | 3 | class ApiClient extends ApiService { 4 | constructor(httpClient, loginService, apiEndpoint = 'frosh-mail-archive') { 5 | super(httpClient, loginService, apiEndpoint); 6 | } 7 | 8 | resendMail(mailId) { 9 | const headers = this.getBasicHeaders({}); 10 | 11 | return this.httpClient 12 | .post( 13 | `_action/${this.getApiBasePath()}/resend-mail`, 14 | { 15 | mailId, 16 | }, 17 | { 18 | ...this.basicConfig, 19 | headers, 20 | } 21 | ) 22 | .then((response) => { 23 | return ApiService.handleResponse(response); 24 | }); 25 | } 26 | 27 | downloadMail(mailId) { 28 | const headers = this.getBasicHeaders({}); 29 | 30 | return this.httpClient 31 | .post( 32 | `_action/${this.getApiBasePath()}/content`, 33 | { 34 | mailId, 35 | }, 36 | { 37 | ...this.basicConfig, 38 | headers, 39 | } 40 | ) 41 | .then((response) => { 42 | const handledResponse = ApiService.handleResponse(response); 43 | 44 | if (!handledResponse.success) { 45 | return handledResponse; 46 | } 47 | 48 | const objectUrl = window.URL.createObjectURL( 49 | new Blob([handledResponse.content]) 50 | ); 51 | 52 | const link = document.createElement('a'); 53 | link.href = objectUrl; 54 | link.setAttribute('download', handledResponse.fileName); 55 | document.body.appendChild(link); 56 | link.click(); 57 | }); 58 | } 59 | 60 | downloadAttachment(attachmentId) { 61 | const headers = this.getBasicHeaders({}); 62 | 63 | return this.httpClient 64 | .post( 65 | `_action/${this.getApiBasePath()}/attachment`, 66 | { 67 | attachmentId, 68 | }, 69 | { 70 | ...this.basicConfig, 71 | headers, 72 | } 73 | ) 74 | .then((response) => { 75 | const handledResponse = ApiService.handleResponse(response); 76 | 77 | if (!handledResponse.success) { 78 | return handledResponse; 79 | } 80 | 81 | const link = document.createElement('a'); 82 | link.href = 83 | 'data:' + 84 | handledResponse.contentType + 85 | ';base64,' + 86 | handledResponse.content; 87 | link.setAttribute('download', handledResponse.fileName); 88 | document.body.appendChild(link); 89 | link.click(); 90 | }); 91 | } 92 | } 93 | 94 | export default ApiClient; 95 | -------------------------------------------------------------------------------- /.shopware-extension.yml: -------------------------------------------------------------------------------- 1 | store: 2 | availabilities: 3 | - German 4 | - International 5 | default_locale: en_GB 6 | localizations: 7 | - de_DE 8 | - en_GB 9 | categories: 10 | - BackendBearbeitung 11 | type: extension 12 | icon: src/Resources/store/icon.png 13 | automatic_bugfix_version_compatibility: true 14 | description: 15 | de: | 16 |

Mit diesem Plugin erhalten Sie ein einfaches durchsuchbares Archiv für E-Mails, die aus Shopware versendet werden.
17 | Die E-Mails sind über den Menüpfad erreichbar: Einstellungen/Erweiterungen/Main Archiv.

18 |

Dieses Plugin wird von @FriendsOfShopware 19 | entwickelt.
20 | Maintainer dieses Plugins ist: Soner Sayakci

21 |

Bei Fragen / Fehlern bitte ein Github Issue erstellen

22 | en: | 23 |

With this plugin you get a simple searchable archive for emails that are sent from Shopware.
24 | The e-mails can be found via the menu path Settings/Extensions/Main Archive.

25 |

This plugin is part of @FriendsOfShopware.
26 | Maintainer from the plugin is: Soner Sayakci

27 |

For questions or bugs please create a Github Issue

28 | installation_manual: 29 | de: "" 30 | en: "" 31 | tags: 32 | de: 33 | - mail 34 | - archive 35 | en: 36 | - mail 37 | - archive 38 | features: 39 | de: 40 | - Übersicht der versendenten E-Mails 41 | - Durchsuchbares Archiv 42 | - Erneutes versenden von E-Mails 43 | - Automatisches entfernen alter E-Mails 44 | - E-Mails, die nicht versendet werden konnten, werden markiert (ab 2.0.5) 45 | en: 46 | - Overview of sent emails 47 | - Searchable archive 48 | - Resend emails 49 | - Automatically remove old emails 50 | - Emails that could not be sent are marked as failed (from 2.0.5) 51 | images: 52 | - file: src/Resources/store/img-0.png 53 | activate: 54 | de: true 55 | en: true 56 | preview: 57 | de: true 58 | en: true 59 | priority: 0 60 | - file: src/Resources/store/img-1.png 61 | activate: 62 | de: true 63 | en: true 64 | preview: 65 | de: false 66 | en: false 67 | priority: 0 68 | build: 69 | zip: 70 | assets: 71 | enabled: true 72 | enable_es_build_for_admin: true 73 | enable_es_build_for_storefront: true 74 | 75 | changelog: 76 | enabled: true 77 | -------------------------------------------------------------------------------- /src/Subscriber/MailTransportSubscriber.php: -------------------------------------------------------------------------------- 1 | > $froshMailArchiveRepository 25 | */ 26 | public function __construct( 27 | private EntityRepository $froshMailArchiveRepository, 28 | private EmlFileManager $emlFileManager, 29 | ) {} 30 | 31 | public static function getSubscribedEvents(): array 32 | { 33 | return [ 34 | FailedMessageEvent::class => 'onMessageFailed', 35 | SentMessageEvent::class => 'onMessageSent', 36 | ]; 37 | } 38 | 39 | public function onMessageFailed(FailedMessageEvent $e): void 40 | { 41 | $message = $e->getMessage(); 42 | $this->updateArchiveState($message, MailSender::TRANSPORT_STATE_FAILED); 43 | } 44 | 45 | public function onMessageSent(SentMessageEvent $event): void 46 | { 47 | $message = $event->getMessage()->getOriginalMessage(); 48 | $this->updateArchiveState($message, MailSender::TRANSPORT_STATE_SENT); 49 | } 50 | 51 | private function updateArchiveState(RawMessage $message, string $newState): void 52 | { 53 | if (!$message instanceof Email) { 54 | return; 55 | } 56 | 57 | $context = new Context(new SystemSource()); 58 | $archiveId = $this->getArchiveIdByMessage($message); 59 | 60 | if (!$archiveId) { 61 | return; 62 | } 63 | 64 | $this->emlFileManager->writeFile($archiveId, $message->toString()); 65 | 66 | $attachments = $this->getAttachments($message); 67 | $this->froshMailArchiveRepository->update([[ 68 | 'id' => $archiveId, 69 | 'transportState' => $newState, 70 | 'attachments' => $attachments, 71 | ]], $context); 72 | } 73 | 74 | /** 75 | * @return array 76 | */ 77 | private function getAttachments(Email $message): array 78 | { 79 | $attachments = $message->getAttachments(); 80 | 81 | return array_map(static fn(DataPart $attachment) => [ 82 | 'fileName' => $attachment->getFilename() ?? 'attachment', 83 | 'contentType' => $attachment->getContentType(), 84 | 'fileSize' => \strlen($attachment->getBody()), 85 | ], $attachments); 86 | } 87 | 88 | private function getArchiveIdByMessage(Email $message): ?string 89 | { 90 | $messageId = $message->getHeaders()->get(MailSender::FROSH_MESSAGE_ID_HEADER)?->getBody(); 91 | 92 | if (\is_string($messageId)) { 93 | return $messageId; 94 | } 95 | 96 | return null; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/snippet/en-GB.json: -------------------------------------------------------------------------------- 1 | { 2 | "frosh-mail-archive": { 3 | "title": "Mail Archive", 4 | "state": { 5 | "pending": "pending", 6 | "sent": "sent", 7 | "resent": "resent", 8 | "failed": "failed", 9 | "unknown": "unknown" 10 | }, 11 | "list": { 12 | "actions": { 13 | "bulkResendAction": "Resend", 14 | "bulkResendWarningTooltip": "Attention: Some mail providers have a limit on how many emails can be sent at the same time.", 15 | "resendAction": "Resend mail", 16 | "showAction": "Show" 17 | }, 18 | "columns": { 19 | "sentDate": "Sent date", 20 | "subject": "Subject", 21 | "receiver": "Recipients", 22 | "transportState": "State", 23 | "transportFailed": "Mail delivery failed." 24 | }, 25 | "sidebar": { 26 | "refresh": "Refresh", 27 | "filter": "Filter", 28 | "filters": { 29 | "search": "Search", 30 | "customer": "Customer", 31 | "salesChannel": "Sales Channel", 32 | "transportStateLabel": "State", 33 | "transportStatePlaceholder": "Select state", 34 | "resetFilter": "Reset filter" 35 | } 36 | } 37 | }, 38 | "detail": { 39 | "toolbar": { 40 | "customer": "Open customer", 41 | "resend": "Resend mail", 42 | "downloadEml": "Download mail" 43 | }, 44 | "metadata": { 45 | "title": "Metadata", 46 | "sentDate": "Sent date", 47 | "sender": "Sender", 48 | "receiver": "Recipients", 49 | "subject": "Subject", 50 | "salesChannel": "Sales Channel", 51 | "customer": "Customer", 52 | "order": "Order", 53 | "flow": "Flow" 54 | }, 55 | "content": { 56 | "title": "Content" 57 | }, 58 | "attachments": { 59 | "title": "Attachments", 60 | "size-unknown": "unknown", 61 | "download": "Download", 62 | "size": "File size", 63 | "file-name": "Name", 64 | "type": "MIME type", 65 | "no-attachments-alert": "This mail does not contain any attachments.", 66 | "attachments-incomplete-alert": "Some attachments might not show up while the mail is in pending state." 67 | }, 68 | "alert": { 69 | "transportFailed": "Mail delivery failed. Please check mailer settings and try again." 70 | }, 71 | "resend-success-notification": { 72 | "message": "Mail dispatch has started.", 73 | "title": "Mail Archive" 74 | }, 75 | "resend-error-notification": { 76 | "title": "Mail Archive", 77 | "message": "Mail could not be delivered" 78 | }, 79 | "resend-grid": { 80 | "navigate": "Show", 81 | "column-created-at": "Sent at", 82 | "column-state": "State", 83 | "currently-selected": "this", 84 | "title": "Resend history" 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/snippet/de-DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "frosh-mail-archive": { 3 | "title": "Mail Archiv", 4 | "state": { 5 | "pending": "ausstehend", 6 | "sent": "gesendet", 7 | "resent": "erneut gesendet", 8 | "failed": "fehlgeschlagen", 9 | "unknown": "unbekannt" 10 | }, 11 | "list": { 12 | "actions": { 13 | "bulkResendAction": "Erneut versenden", 14 | "bulkResendWarningTooltip": "Achtung: Manche MailProvider haben eine Begrenzung, wie viele Mails gleichzeitig versendet werden können.", 15 | "resendAction": "E-Mail erneut versenden", 16 | "showAction": "Anzeigen" 17 | }, 18 | "columns": { 19 | "sentDate": "Versanddatum", 20 | "subject": "Betreff", 21 | "receiver": "Empfänger", 22 | "transportState": "Status", 23 | "transportFailed": "Versand der E-Mail fehlgeschlagen" 24 | }, 25 | "sidebar": { 26 | "refresh": "Neuladen", 27 | "filter": "Filter", 28 | "filters": { 29 | "search": "Suche", 30 | "customer": "Kunde", 31 | "salesChannel": "Verkaufskanal", 32 | "transportStateLabel": "Status", 33 | "transportStatePlaceholder": "Status auswählen", 34 | "resetFilter": "Filter zurücksetzen" 35 | } 36 | } 37 | }, 38 | "detail": { 39 | "toolbar": { 40 | "customer": "Kunden öffnen", 41 | "resend": "E-Mail erneut versenden", 42 | "downloadEml": "Download E-Mail" 43 | }, 44 | "metadata": { 45 | "title": "Metadaten", 46 | "sentDate": "Versanddatum", 47 | "sender": "Absender", 48 | "receiver": "Empfänger", 49 | "subject": "Betreff", 50 | "salesChannel": "Verkaufskanal", 51 | "customer": "Kunde", 52 | "order": "Bestellung", 53 | "flow": "Flow" 54 | }, 55 | "content": { 56 | "title": "Inhalt" 57 | }, 58 | "attachments": { 59 | "title": "Anhänge", 60 | "size-unknown": "unbekannt", 61 | "download": "Herunterladen", 62 | "size": "Dateigröße", 63 | "file-name": "Name", 64 | "type": "MIME type", 65 | "no-attachments-alert": "Diese E-Mail enthält keine Anhänge.", 66 | "attachments-incomplete-alert": "Möglicherweise werden nicht alle Anhänge angezeigt, während die E-Mail im \"ausstehend\" Status ist." 67 | }, 68 | "alert": { 69 | "transportFailed": "E-Mail-Versand fehlgeschlagen. Bitte überprüfe deine Mailer-Einstellungen und versuche es erneut." 70 | }, 71 | "resend-success-notification": { 72 | "message": "Der E-Mail-Versand wurde gestartet.", 73 | "title": "Mail Archiv" 74 | }, 75 | "resend-error-notification": { 76 | "title": "Mail Archiv", 77 | "message": "Fehler beim Senden der E-Mail" 78 | }, 79 | "resend-grid": { 80 | "navigate": "Anzeigen", 81 | "column-created-at": "Gesendet am", 82 | "column-state": "Status", 83 | "currently-selected": "ausgewählt", 84 | "title": "Sendeverlauf" 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Content/MailArchive/MailArchiveDefinition.php: -------------------------------------------------------------------------------- 1 | Defaults::LIVE_VERSION, 48 | ]; 49 | } 50 | 51 | protected function defineFields(): FieldCollection 52 | { 53 | return new FieldCollection([ 54 | (new IdField('id', 'id'))->addFlags(new PrimaryKey(), new Required()), 55 | (new JsonField('sender', 'sender'))->addFlags(new Required()), 56 | (new JsonField('receiver', 'receiver'))->addFlags(new Required())->addFlags(new SearchRanking(SearchRanking::HIGH_SEARCH_RANKING)), 57 | (new StringField('subject', 'subject', 998))->addFlags(new Required())->addFlags(new SearchRanking(SearchRanking::HIGH_SEARCH_RANKING)), 58 | (new LongTextField('plainText', 'plainText'))->addFlags(new AllowHtml()), 59 | (new LongTextField('htmlText', 'htmlText'))->addFlags(new AllowHtml(), new SearchRanking(SearchRanking::LOW_SEARCH_RANKING)), 60 | new StringField('eml_path', 'emlPath', 2048), 61 | (new StringField('transport_state', 'transportState'))->addFlags(new Required()), 62 | 63 | (new OneToManyAssociationField('attachments', MailArchiveAttachmentDefinition::class, 'mail_archive_id', 'id'))->addFlags(new CascadeDelete()), 64 | 65 | new FkField('salesChannelId', 'salesChannelId', SalesChannelDefinition::class), 66 | new ManyToOneAssociationField('salesChannel', 'salesChannelId', SalesChannelDefinition::class, 'id', true), 67 | 68 | new FkField('customerId', 'customerId', CustomerDefinition::class), 69 | new ManyToOneAssociationField('customer', 'customerId', CustomerDefinition::class, 'id', false), 70 | 71 | new FkField('order_id', 'orderId', OrderDefinition::class), 72 | new ReferenceVersionField(OrderDefinition::class, 'order_version_id'), 73 | new ManyToOneAssociationField('order', 'order_id', OrderDefinition::class, 'id', false), 74 | 75 | new FkField('flow_id', 'flowId', FlowDefinition::class), 76 | new ManyToOneAssociationField('flow', 'flow_id', FlowDefinition::class, 'id', false), 77 | 78 | new FkField('source_mail_id', 'sourceMailId', self::class), 79 | new ManyToOneAssociationField('sourceMail', 'source_mail_id', self::class, 'id', false), 80 | new OneToManyAssociationField('sourceMails', self::class, 'sourceMailId'), 81 | ]); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/page/frosh-mail-archive-index/frosh-mail-archive-index.twig: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 22 | 23 | 111 | 112 | 165 | -------------------------------------------------------------------------------- /src/Services/MailSender.php: -------------------------------------------------------------------------------- 1 | > $froshMailArchiveRepository 41 | * @param EntityRepository $customerRepository 42 | */ 43 | public function __construct( 44 | private readonly AbstractMailSender $mailSender, 45 | private readonly RequestStack $requestStack, 46 | private readonly EntityRepository $froshMailArchiveRepository, 47 | private readonly EntityRepository $customerRepository, 48 | private readonly EmlFileManager $emlFileManager, 49 | ) {} 50 | 51 | public function send(Email $email, ?Envelope $envelope = null): void 52 | { 53 | $id = Uuid::randomHex(); 54 | $email->getHeaders()->remove(self::FROSH_MESSAGE_ID_HEADER); 55 | $email->getHeaders()->addHeader(self::FROSH_MESSAGE_ID_HEADER, $id); 56 | 57 | $metadata = $this->getMailMetadata($email); 58 | 59 | // save the mail first, to make sure it exists in the database when we want to update its state 60 | $this->saveMail($id, $email, $metadata); 61 | $this->mailSender->send($email, $envelope); // @phpstan-ignore-line 62 | } 63 | 64 | public function getDecorated(): AbstractMailSender 65 | { 66 | return $this->mailSender; 67 | } 68 | 69 | /** 70 | * @param array $metadata 71 | */ 72 | private function saveMail(string $id, Email $message, array $metadata): void 73 | { 74 | $emlPath = $this->emlFileManager->writeFile($id, $message->toString()); 75 | 76 | $context = new Context(new SystemSource()); 77 | $this->froshMailArchiveRepository->create([ 78 | [ 79 | 'id' => $id, 80 | 'sender' => [$message->getFrom()[0]->getAddress() => $message->getFrom()[0]->getName()], 81 | 'receiver' => $this->convertAddress($message->getTo()), 82 | 'subject' => $message->getSubject() ?? '', 83 | 'plainText' => nl2br((string) $message->getTextBody()), 84 | 'htmlText' => $message->getHtmlBody(), 85 | 'emlPath' => $emlPath, 86 | 'salesChannelId' => $this->getCurrentSalesChannelId(), 87 | 'customerId' => $metadata['customerId'] ?? $this->getCustomerIdByMail($message->getTo()), 88 | 'sourceMailId' => $this->getSourceMailId($context), 89 | 'transportState' => self::TRANSPORT_STATE_PENDING, 90 | 'orderId' => $metadata['orderId'], 91 | 'flowId' => $metadata['flowId'], 92 | ], 93 | ], $context); 94 | } 95 | 96 | private function getCurrentSalesChannelId(): ?string 97 | { 98 | if (!$this->requestStack->getMainRequest() instanceof Request) { 99 | return null; 100 | } 101 | 102 | $salesChannelId = $this->requestStack->getMainRequest()->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID); 103 | if (!\is_string($salesChannelId)) { 104 | return null; 105 | } 106 | 107 | return $salesChannelId; 108 | } 109 | 110 | private function getSourceMailId(Context $context): ?string 111 | { 112 | $request = $this->requestStack->getMainRequest(); 113 | if (!$request instanceof Request) { 114 | return null; 115 | } 116 | 117 | $route = $request->attributes->get('_route'); 118 | if ($route !== 'api.action.frosh-mail-archive.resend-mail') { 119 | return null; 120 | } 121 | 122 | $sourceMailId = $request->request->get('mailId'); 123 | 124 | if (!\is_string($sourceMailId)) { 125 | throw MailArchiveException::parameterMissing('mailId in request'); 126 | } 127 | 128 | /** @var MailArchiveEntity|null $sourceMail */ 129 | $sourceMail = $this->froshMailArchiveRepository->search(new Criteria([$sourceMailId]), $context)->first(); 130 | 131 | // In case the source Mail is a resend, we want to save the original source mail id 132 | return $sourceMail?->getSourceMailId() ?? $sourceMailId; 133 | } 134 | 135 | /** 136 | * @param Address[] $to 137 | */ 138 | private function getCustomerIdByMail(array $to): ?string 139 | { 140 | $criteria = new Criteria(); 141 | 142 | /** @var list $addresses */ 143 | $addresses = \array_map(fn(Address $mail) => $mail->getAddress(), $to); 144 | 145 | $criteria->addFilter(new EqualsAnyFilter('email', $addresses)); 146 | 147 | $context = new Context(new SystemSource()); 148 | 149 | return $this->customerRepository->searchIds($criteria, $context)->firstId(); 150 | } 151 | 152 | /** 153 | * @param Address[] $addresses 154 | * 155 | * @return array 156 | */ 157 | private function convertAddress(array $addresses): array 158 | { 159 | $list = []; 160 | 161 | foreach ($addresses as $address) { 162 | $list[$address->getAddress()] = $address->getName(); 163 | } 164 | 165 | return $list; 166 | } 167 | 168 | /** 169 | * @return array 170 | */ 171 | private function getMailMetadata(Email $email): array 172 | { 173 | $customerIdHeader = $email->getHeaders()->get(self::FROSH_CUSTOMER_ID_HEADER); 174 | $email->getHeaders()->remove(self::FROSH_CUSTOMER_ID_HEADER); 175 | 176 | $orderIdHeader = $email->getHeaders()->get(self::FROSH_ORDER_ID_HEADER); 177 | $email->getHeaders()->remove(self::FROSH_ORDER_ID_HEADER); 178 | 179 | $flowIdHeader = $email->getHeaders()->get(self::FROSH_FLOW_ID_HEADER); 180 | $email->getHeaders()->remove(self::FROSH_FLOW_ID_HEADER); 181 | 182 | return [ 183 | 'customerId' => $customerIdHeader?->getBodyAsString() ?: null, 184 | 'orderId' => $orderIdHeader?->getBodyAsString() ?: null, 185 | 'flowId' => $flowIdHeader?->getBodyAsString() ?: null, 186 | ]; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Content/MailArchive/MailArchiveEntity.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $sender; 23 | 24 | /** 25 | * @var array 26 | */ 27 | protected array $receiver; 28 | 29 | protected string $subject; 30 | 31 | protected ?string $plainText = null; 32 | 33 | protected ?string $htmlText = null; 34 | 35 | protected ?string $transportState = null; 36 | 37 | protected ?string $emlPath = null; 38 | 39 | protected ?string $salesChannelId = null; 40 | 41 | protected ?SalesChannelEntity $salesChannel = null; 42 | 43 | protected ?string $customerId = null; 44 | 45 | protected ?CustomerEntity $customer = null; 46 | 47 | protected ?string $orderId = null; 48 | 49 | protected ?string $orderVersionId = null; 50 | 51 | protected ?OrderEntity $order = null; 52 | 53 | protected ?string $flowId = null; 54 | 55 | protected ?FlowEntity $flow = null; 56 | 57 | /** 58 | * @var EntityCollection|null 59 | */ 60 | protected ?EntityCollection $attachments = null; 61 | 62 | protected ?string $sourceMailId = null; 63 | 64 | protected ?MailArchiveEntity $sourceMail = null; 65 | 66 | /** 67 | * @var EntityCollection|null 68 | */ 69 | protected ?EntityCollection $sourceMails = null; 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function getSender(): array 75 | { 76 | return $this->sender; 77 | } 78 | 79 | /** 80 | * @param array $sender 81 | */ 82 | public function setSender(array $sender): void 83 | { 84 | $this->sender = $sender; 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function getReceiver(): array 91 | { 92 | return $this->receiver; 93 | } 94 | 95 | /** 96 | * @param array $receiver 97 | */ 98 | public function setReceiver(array $receiver): void 99 | { 100 | $this->receiver = $receiver; 101 | } 102 | 103 | public function getSubject(): string 104 | { 105 | return $this->subject; 106 | } 107 | 108 | public function setSubject(string $subject): void 109 | { 110 | $this->subject = $subject; 111 | } 112 | 113 | public function getPlainText(): ?string 114 | { 115 | return $this->plainText; 116 | } 117 | 118 | public function setPlainText(?string $plainText): void 119 | { 120 | $this->plainText = $plainText; 121 | } 122 | 123 | public function getHtmlText(): ?string 124 | { 125 | return $this->htmlText; 126 | } 127 | 128 | public function setHtmlText(?string $htmlText): void 129 | { 130 | $this->htmlText = $htmlText; 131 | } 132 | 133 | public function getEmlPath(): ?string 134 | { 135 | return $this->emlPath; 136 | } 137 | 138 | public function setEmlPath(?string $emlPath): void 139 | { 140 | $this->emlPath = $emlPath; 141 | } 142 | 143 | public function getSalesChannelId(): ?string 144 | { 145 | return $this->salesChannelId; 146 | } 147 | 148 | public function setSalesChannelId(?string $salesChannelId): void 149 | { 150 | $this->salesChannelId = $salesChannelId; 151 | } 152 | 153 | public function getSalesChannel(): ?SalesChannelEntity 154 | { 155 | return $this->salesChannel; 156 | } 157 | 158 | public function setSalesChannel(?SalesChannelEntity $salesChannel): void 159 | { 160 | $this->salesChannel = $salesChannel; 161 | } 162 | 163 | public function getCustomerId(): ?string 164 | { 165 | return $this->customerId; 166 | } 167 | 168 | public function setCustomerId(?string $customerId): void 169 | { 170 | $this->customerId = $customerId; 171 | } 172 | 173 | public function getCustomer(): ?CustomerEntity 174 | { 175 | return $this->customer; 176 | } 177 | 178 | public function setCustomer(?CustomerEntity $customer): void 179 | { 180 | $this->customer = $customer; 181 | } 182 | 183 | /** 184 | * @return EntityCollection|null 185 | */ 186 | public function getAttachments(): ?EntityCollection 187 | { 188 | return $this->attachments; 189 | } 190 | 191 | /** 192 | * @param EntityCollection $attachments 193 | */ 194 | public function setAttachments(EntityCollection $attachments): void 195 | { 196 | $this->attachments = $attachments; 197 | } 198 | 199 | public function getSourceMailId(): ?string 200 | { 201 | return $this->sourceMailId; 202 | } 203 | 204 | public function setSourceMailId(string $sourceMailId): void 205 | { 206 | $this->sourceMailId = $sourceMailId; 207 | } 208 | 209 | public function getSourceMail(): ?MailArchiveEntity 210 | { 211 | return $this->sourceMail; 212 | } 213 | 214 | public function setSourceMail(MailArchiveEntity $sourceMail): void 215 | { 216 | $this->sourceMail = $sourceMail; 217 | } 218 | 219 | public function getTransportState(): ?string 220 | { 221 | return $this->transportState; 222 | } 223 | 224 | public function setTransportState(string $transportState): void 225 | { 226 | $this->transportState = $transportState; 227 | } 228 | 229 | /** 230 | * @return EntityCollection|null 231 | */ 232 | public function getSourceMails(): ?EntityCollection 233 | { 234 | return $this->sourceMails; 235 | } 236 | 237 | /** 238 | * @param EntityCollection $sourceMails 239 | */ 240 | public function setSourceMails(EntityCollection $sourceMails): void 241 | { 242 | $this->sourceMails = $sourceMails; 243 | } 244 | 245 | public function getOrderId(): ?string 246 | { 247 | return $this->orderId; 248 | } 249 | 250 | public function setOrderId(?string $orderId): void 251 | { 252 | $this->orderId = $orderId; 253 | } 254 | 255 | public function getOrder(): ?OrderEntity 256 | { 257 | return $this->order; 258 | } 259 | 260 | public function setOrder(?OrderEntity $order): void 261 | { 262 | $this->order = $order; 263 | } 264 | 265 | public function getFlowId(): ?string 266 | { 267 | return $this->flowId; 268 | } 269 | 270 | public function setFlowId(?string $flowId): void 271 | { 272 | $this->flowId = $flowId; 273 | } 274 | 275 | public function getFlow(): ?FlowEntity 276 | { 277 | return $this->flow; 278 | } 279 | 280 | public function setFlow(?FlowEntity $flow): void 281 | { 282 | $this->flow = $flow; 283 | } 284 | 285 | public function getOrderVersionId(): ?string 286 | { 287 | return $this->orderVersionId; 288 | } 289 | 290 | public function setOrderVersionId(?string $orderVersionId): void 291 | { 292 | $this->orderVersionId = $orderVersionId; 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/page/frosh-mail-archive-detail/index.js: -------------------------------------------------------------------------------- 1 | const { Component, Mixin } = Shopware; 2 | const { Criteria } = Shopware.Data; 3 | import template from './frosh-mail-archive-detail.twig'; 4 | import './frosh-mail-archive-detail.scss'; 5 | 6 | Component.register('frosh-mail-archive-detail', { 7 | template, 8 | inject: ['repositoryFactory', 'froshMailArchiveService'], 9 | 10 | data() { 11 | return { 12 | archive: null, 13 | resendIsLoading: false, 14 | resendIsSuccessful: false, 15 | downloadIsLoading: false, 16 | downloadIsSuccessful: false, 17 | resendCounter: 0, 18 | }; 19 | }, 20 | 21 | props: { 22 | archiveId: { 23 | type: String, 24 | required: true, 25 | }, 26 | }, 27 | 28 | mixins: [Mixin.getByName('notification')], 29 | 30 | created() { 31 | this.loadMail(); 32 | }, 33 | watch: { 34 | archiveId() { 35 | this.loadMail(); 36 | }, 37 | }, 38 | computed: { 39 | resendKey() { 40 | return this.archive.id + this.resendCounter; 41 | }, 42 | repository() { 43 | return this.repositoryFactory.create('frosh_mail_archive'); 44 | }, 45 | createdAtDate() { 46 | const locale = Shopware.State.getters.adminLocaleLanguage || 'en'; 47 | const options = { 48 | day: '2-digit', 49 | month: '2-digit', 50 | year: 'numeric', 51 | hour: '2-digit', 52 | minute: '2-digit', 53 | second: '2-digit', 54 | }; 55 | 56 | return new Intl.DateTimeFormat(locale, options).format( 57 | new Date(this.archive.createdAt) 58 | ); 59 | }, 60 | receiverText() { 61 | let text = []; 62 | 63 | Object.keys(this.archive.receiver).forEach((key) => { 64 | text.push(`${this.archive.receiver[key]} <${key}>`); 65 | }); 66 | 67 | return text.join(','); 68 | }, 69 | senderText() { 70 | let text = []; 71 | 72 | Object.keys(this.archive.sender).forEach((key) => { 73 | text.push(`${this.archive.sender[key]} <${key}>`); 74 | }); 75 | 76 | return text.join(','); 77 | }, 78 | htmlText() { 79 | return this.getContent(this.archive.htmlText); 80 | }, 81 | plainText() { 82 | return this.getContent(this.archive.plainText); 83 | }, 84 | attachmentsColumns() { 85 | return [ 86 | { 87 | property: 'fileName', 88 | label: this.$t( 89 | 'frosh-mail-archive.detail.attachments.file-name' 90 | ), 91 | rawData: true, 92 | }, 93 | { 94 | property: 'fileSize', 95 | label: this.$t( 96 | 'frosh-mail-archive.detail.attachments.size' 97 | ), 98 | rawData: true, 99 | }, 100 | { 101 | property: 'contentType', 102 | label: this.$t( 103 | 'frosh-mail-archive.detail.attachments.type' 104 | ), 105 | rawData: true, 106 | }, 107 | ]; 108 | }, 109 | }, 110 | 111 | methods: { 112 | loadMail() { 113 | const criteria = new Criteria(); 114 | criteria.addAssociation('attachments'); 115 | criteria.addAssociation('customer'); 116 | criteria.addAssociation('order'); 117 | criteria.addAssociation('flow'); 118 | 119 | this.repository 120 | .get(this.archiveId, Shopware.Context.api, criteria) 121 | .then((archive) => { 122 | this.archive = archive; 123 | }); 124 | }, 125 | getContent(html) { 126 | const binary = new TextEncoder().encode(html); 127 | let result = ''; 128 | binary.forEach(b => result += String.fromCharCode(b)); 129 | 130 | return ( 131 | 'data:text/html;charset=utf-8;base64,' + btoa(result) 132 | ); 133 | }, 134 | openCustomer() { 135 | this.$router.push({ 136 | name: 'sw.customer.detail', 137 | params: { id: this.archive.customer.id }, 138 | }); 139 | }, 140 | resendFinish() { 141 | this.resendIsSuccessful = false; 142 | }, 143 | downloadFinish() { 144 | this.downloadIsSuccessful = false; 145 | }, 146 | resendMail() { 147 | this.resendIsLoading = true; 148 | 149 | this.froshMailArchiveService 150 | .resendMail(this.archive.id) 151 | .then(() => { 152 | this.resendIsSuccessful = true; 153 | this.createNotificationSuccess({ 154 | title: this.$tc( 155 | 'frosh-mail-archive.detail.resend-success-notification.title' 156 | ), 157 | message: this.$tc( 158 | 'frosh-mail-archive.detail.resend-success-notification.message' 159 | ), 160 | }); 161 | }) 162 | .catch(() => { 163 | this.resendIsSuccessful = false; 164 | this.createNotificationError({ 165 | title: this.$tc( 166 | 'frosh-mail-archive.detail.resend-error-notification.title' 167 | ), 168 | message: this.$tc( 169 | 'frosh-mail-archive.detail.resend-error-notification.message' 170 | ), 171 | }); 172 | }) 173 | .finally(() => { 174 | this.resendIsLoading = false; 175 | this.resendCounter++; 176 | }); 177 | }, 178 | downloadMail() { 179 | this.downloadIsLoading = true; 180 | 181 | this.froshMailArchiveService 182 | .downloadMail(this.archive.id) 183 | .then(() => { 184 | this.downloadIsSuccessful = true; 185 | }) 186 | .catch(() => { 187 | this.downloadIsSuccessful = false; 188 | }) 189 | .finally(() => { 190 | this.downloadIsLoading = false; 191 | }); 192 | }, 193 | downloadAttachment(attachmentId) { 194 | this.froshMailArchiveService.downloadAttachment(attachmentId); 195 | }, 196 | formatSize(bytes) { 197 | const thresh = 1024; 198 | const dp = 1; 199 | let formatted = bytes; 200 | 201 | if (Math.abs(bytes) < thresh) { 202 | return bytes + ' B'; 203 | } 204 | 205 | const units = [ 206 | 'KiB', 207 | 'MiB', 208 | 'GiB', 209 | 'TiB', 210 | 'PiB', 211 | 'EiB', 212 | 'ZiB', 213 | 'YiB', 214 | ]; 215 | let index = -1; 216 | const reach = 10 ** dp; 217 | 218 | do { 219 | formatted /= thresh; 220 | ++index; 221 | } while ( 222 | Math.round(Math.abs(formatted) * reach) / reach >= thresh && 223 | index < units.length - 1 224 | ); 225 | 226 | return formatted.toFixed(dp) + ' ' + units[index]; 227 | }, 228 | }, 229 | }); 230 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/page/frosh-mail-archive-index/index.js: -------------------------------------------------------------------------------- 1 | const { Component, Mixin } = Shopware; 2 | const { Criteria } = Shopware.Data; 3 | const utils = Shopware.Utils; 4 | import template from './frosh-mail-archive-index.twig'; 5 | import './frosh-mail-archive-index.scss'; 6 | 7 | Component.register('frosh-mail-archive-index', { 8 | template, 9 | inject: ['repositoryFactory', 'froshMailArchiveService'], 10 | mixins: [Mixin.getByName('listing'), Mixin.getByName('notification')], 11 | 12 | metaInfo() { 13 | return { 14 | title: this.$createTitle(), 15 | }; 16 | }, 17 | 18 | data() { 19 | return { 20 | page: 1, 21 | limit: 25, 22 | total: 0, 23 | repository: null, 24 | items: null, 25 | isLoading: true, 26 | filter: { 27 | salesChannelId: null, 28 | transportState: null, 29 | customerId: null, 30 | term: null, 31 | }, 32 | selectedItems: {}, 33 | }; 34 | }, 35 | 36 | computed: { 37 | columns() { 38 | return [ 39 | { 40 | property: 'createdAt', 41 | dataIndex: 'createdAt', 42 | label: 'frosh-mail-archive.list.columns.sentDate', 43 | primary: true, 44 | routerLink: 'frosh.mail.archive.detail', 45 | }, 46 | { 47 | property: 'transportState', 48 | dataIndex: 'transportState', 49 | label: 'frosh-mail-archive.list.columns.transportState', 50 | allowResize: true, 51 | }, 52 | { 53 | property: 'subject', 54 | dataIndex: 'subject', 55 | label: 'frosh-mail-archive.list.columns.subject', 56 | allowResize: true, 57 | routerLink: 'frosh.mail.archive.detail', 58 | }, 59 | { 60 | property: 'receiver', 61 | dataIndex: 'receiver', 62 | label: 'frosh-mail-archive.list.columns.receiver', 63 | allowResize: true, 64 | }, 65 | ]; 66 | }, 67 | mailArchiveRepository() { 68 | return this.repositoryFactory.create('frosh_mail_archive'); 69 | }, 70 | date() { 71 | return Shopware.Filter.getByName('date'); 72 | }, 73 | transportStateOptions() { 74 | return [ 75 | { 76 | value: 'failed', 77 | label: this.translateState('failed'), 78 | }, 79 | { 80 | value: 'sent', 81 | label: this.translateState('sent'), 82 | }, 83 | { 84 | value: 'pending', 85 | label: this.translateState('pending'), 86 | }, 87 | { 88 | value: 'resent', 89 | label: this.translateState('resent'), 90 | }, 91 | ]; 92 | }, 93 | }, 94 | 95 | methods: { 96 | translateState(state) { 97 | return this.$tc(`frosh-mail-archive.state.${state}`); 98 | }, 99 | 100 | updateData(query) { 101 | for (const filter in this.filter) { 102 | this.filter[filter] = query[filter] ?? null; 103 | } 104 | }, 105 | 106 | saveFilters() { 107 | this.updateRoute( 108 | { 109 | limit: this.limit, 110 | page: this.page, 111 | term: this.term, 112 | sortBy: this.sortBy, 113 | sortDirection: this.sortDirection, 114 | naturalSorting: this.naturalSorting, 115 | }, 116 | this.filter 117 | ); 118 | }, 119 | 120 | getList() { 121 | this.isLoading = true; 122 | 123 | const criteria = new Criteria(this.page, this.limit); 124 | criteria.setTerm(this.term); 125 | 126 | if (this.filter.transportState) { 127 | criteria.addFilter( 128 | Criteria.equals( 129 | 'transportState', 130 | this.filter.transportState 131 | ) 132 | ); 133 | } 134 | 135 | if (this.filter.salesChannelId) { 136 | criteria.addFilter( 137 | Criteria.equals( 138 | 'salesChannelId', 139 | this.filter.salesChannelId 140 | ) 141 | ); 142 | } 143 | 144 | if (this.filter.customerId) { 145 | criteria.addFilter( 146 | Criteria.equals('customerId', this.filter.customerId) 147 | ); 148 | } 149 | 150 | if (this.filter.term) { 151 | criteria.setTerm(this.filter.term); 152 | } 153 | 154 | criteria.addSorting(Criteria.sort('createdAt', 'DESC')); 155 | 156 | return this.mailArchiveRepository 157 | .search(criteria, Shopware.Context.api) 158 | .then((searchResult) => { 159 | this.items = searchResult; 160 | this.total = searchResult.total; 161 | this.isLoading = false; 162 | this.saveFilters(); 163 | }); 164 | }, 165 | 166 | resendMail(item) { 167 | this.isLoading = true; 168 | 169 | this.froshMailArchiveService 170 | .resendMail(item.id) 171 | .then(async () => { 172 | this.createNotificationSuccess({ 173 | title: this.$tc( 174 | 'frosh-mail-archive.detail.resend-success-notification.title' 175 | ), 176 | message: this.$tc( 177 | 'frosh-mail-archive.detail.resend-success-notification.message' 178 | ), 179 | }); 180 | await this.getList(); 181 | }) 182 | .catch(() => { 183 | this.createNotificationError({ 184 | title: this.$tc( 185 | 'frosh-mail-archive.detail.resend-error-notification.title' 186 | ), 187 | message: this.$tc( 188 | 'frosh-mail-archive.detail.resend-error-notification.message' 189 | ), 190 | }); 191 | }) 192 | .finally(() => { 193 | this.isLoading = false; 194 | }); 195 | }, 196 | 197 | onBulkResendClick() { 198 | const ids = Object.keys(this.selectedItems); 199 | if (ids.length === 0) { 200 | return; 201 | } 202 | this.isLoading = true; 203 | 204 | Promise.all( 205 | ids.map((id) => { 206 | return this.froshMailArchiveService.resendMail(id); 207 | }) 208 | ).finally(async () => { 209 | this.$refs.table?.resetSelection(); 210 | await this.getList(); 211 | this.isLoading = false; 212 | }); 213 | }, 214 | 215 | onSelectionChanged(selection) { 216 | this.selectedItems = selection; 217 | }, 218 | 219 | resetFilter() { 220 | this.filter = { 221 | salesChannelId: null, 222 | customerId: null, 223 | term: null, 224 | }; 225 | }, 226 | }, 227 | 228 | watch: { 229 | filter: { 230 | deep: true, 231 | handler: utils.debounce(function () { 232 | this.getList(); 233 | }, 400), 234 | }, 235 | }, 236 | }); 237 | -------------------------------------------------------------------------------- /src/Resources/app/administration/src/module/frosh-mail-archive/page/frosh-mail-archive-detail/frosh-mail-archive-detail.twig: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 24 | 25 | 196 | -------------------------------------------------------------------------------- /src/Controller/Api/MailArchiveController.php: -------------------------------------------------------------------------------- 1 | ['api']])] 33 | class MailArchiveController extends AbstractController 34 | { 35 | /** 36 | * @param EntityRepository> $froshMailArchiveRepository 37 | * @param EntityRepository> $froshMailArchiveAttachmentRepository 38 | */ 39 | public function __construct( 40 | private readonly EntityRepository $froshMailArchiveRepository, 41 | private readonly EntityRepository $froshMailArchiveAttachmentRepository, 42 | #[Autowire(service: MailSender::class)] 43 | private readonly AbstractMailSender $mailSender, 44 | private readonly RequestStack $requestStack, 45 | private readonly EmlFileManager $emlFileManager, 46 | ) {} 47 | 48 | #[Route(path: '/api/_action/frosh-mail-archive/resend-mail', name: 'api.action.frosh-mail-archive.resend-mail')] 49 | public function resend(Request $request, Context $context): JsonResponse 50 | { 51 | $mailId = $request->request->get('mailId'); 52 | 53 | if (!\is_string($mailId)) { 54 | throw MailArchiveException::parameterMissing('mailId'); 55 | } 56 | 57 | $mailArchive = $this->froshMailArchiveRepository->search(new Criteria([$mailId]), $context)->first(); 58 | if (!$mailArchive instanceof MailArchiveEntity) { 59 | throw MailArchiveException::notFound(); 60 | } 61 | 62 | $mainRequest = $this->requestStack->getMainRequest(); 63 | if (!$mainRequest instanceof Request) { 64 | throw new \RuntimeException('Cannot get mainRequest'); 65 | } 66 | 67 | $mainRequest->attributes->set(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID, $mailArchive->getSalesChannelId()); 68 | 69 | $email = new Email(); 70 | $emlPath = $mailArchive->getEmlPath(); 71 | $isEml = $emlPath !== '' && $emlPath !== '0' && \is_string($emlPath); 72 | 73 | if ($isEml) { 74 | $this->enrichFromEml($emlPath, $email); 75 | } else { 76 | $this->enrichFromDatabase($mailArchive, $email); 77 | } 78 | 79 | $this->mailSender->send($email); 80 | 81 | $this->froshMailArchiveRepository->update([[ 82 | 'id' => $mailId, 83 | 'transportState' => MailSender::TRANSPORT_STATE_RESENT, 84 | ]], $context); 85 | 86 | return new JsonResponse([ 87 | 'success' => true, 88 | ]); 89 | } 90 | 91 | #[Route(path: '/api/_action/frosh-mail-archive/content')] 92 | public function download(Request $request, Context $context): JsonResponse 93 | { 94 | $mailId = $request->request->get('mailId'); 95 | 96 | if (!\is_string($mailId)) { 97 | throw MailArchiveException::parameterMissing('mailId'); 98 | } 99 | 100 | $mailArchive = $this->froshMailArchiveRepository->search(new Criteria([$mailId]), $context)->first(); 101 | if (!$mailArchive instanceof MailArchiveEntity) { 102 | throw MailArchiveException::notFound(); 103 | } 104 | 105 | $content = $this->getContent($mailArchive->getEmlPath()); 106 | 107 | if (empty($content)) { 108 | throw new \RuntimeException('Cannot read eml file or file is empty'); 109 | } 110 | 111 | $fileNameParts = []; 112 | 113 | if ($mailArchive->getCreatedAt() instanceof \DateTimeInterface) { 114 | $fileNameParts[] = $mailArchive->getCreatedAt()->format('Y-m-d_H-i-s'); 115 | } 116 | 117 | $fileNameParts[] = $mailArchive->getSubject(); 118 | 119 | $fileName = $this->getFileName($fileNameParts) . '.eml'; 120 | 121 | return new JsonResponse([ 122 | 'success' => true, 123 | 'content' => $content, 124 | 'fileName' => $fileName, 125 | ]); 126 | } 127 | 128 | #[Route(path: '/api/_action/frosh-mail-archive/attachment', name: 'api.action.frosh-mail-archive.attachment')] 129 | public function attachment(Request $request, Context $context): JsonResponse 130 | { 131 | $attachmentId = $request->request->getString('attachmentId'); 132 | if (!Uuid::isValid($attachmentId)) { 133 | throw MailArchiveException::parameterInvalidUuid('attachmentId'); 134 | } 135 | 136 | $criteria = new Criteria([$attachmentId]); 137 | $criteria->addAssociation('mailArchive'); 138 | 139 | $attachment = $this->froshMailArchiveAttachmentRepository->search($criteria, $context)->first(); 140 | if (!$attachment instanceof MailArchiveAttachmentEntity) { 141 | throw MailArchiveException::notFound(); 142 | } 143 | 144 | $mailArchive = $attachment->getMailArchive(); 145 | if (!$mailArchive instanceof MailArchiveEntity) { 146 | throw MailArchiveException::notFound(); 147 | } 148 | 149 | $emlPath = $mailArchive->getEmlPath(); 150 | $isEml = $emlPath !== '' && $emlPath !== '0' && \is_string($emlPath); 151 | 152 | if (!$isEml) { 153 | throw new \RuntimeException('Cannot read eml file or file is empty'); 154 | } 155 | 156 | $message = $this->emlFileManager->getEmlAsMessage($emlPath); 157 | 158 | if (empty($message)) { 159 | throw new \RuntimeException('Cannot read eml file or file is empty'); 160 | } 161 | 162 | $content = null; 163 | 164 | foreach ($message->getAllAttachmentParts() as $part) { 165 | if ($part->getFilename() === $attachment->getFileName()) { 166 | $content = $part->getContent(); 167 | 168 | break; 169 | } 170 | } 171 | 172 | if (empty($content)) { 173 | throw new \RuntimeException('Cannot find attachment in eml file'); 174 | } 175 | 176 | $fileNameParts = []; 177 | 178 | if ($mailArchive->getCreatedAt() instanceof \DateTimeInterface) { 179 | $fileNameParts[] = $mailArchive->getCreatedAt()->format('Y-m-d_H-i-s'); 180 | } 181 | 182 | $fileNameParts[] = $mailArchive->getSubject(); 183 | $fileNameParts[] = $attachment->getFileName(); 184 | 185 | $fileName = $this->getFileName($fileNameParts); 186 | 187 | return new JsonResponse([ 188 | 'success' => true, 189 | 'content' => \base64_encode($content), 190 | 'contentType' => $attachment->getContentType(), 191 | 'fileName' => $fileName, 192 | ]); 193 | } 194 | 195 | private function enrichFromEml(string $emlPath, Email $email): void 196 | { 197 | $message = $this->emlFileManager->getEmlAsMessage($emlPath); 198 | 199 | if ($message === false) { 200 | throw new \RuntimeException('Cannot read eml file'); 201 | } 202 | 203 | $email->html($message->getHtmlContent()); 204 | $email->text($message->getTextContent()); 205 | 206 | foreach ($message->getAllHeaders() as $header) { 207 | $headerValue = $this->getHeaderValue($header); 208 | 209 | // skip multipart/ headers due to multiple content types breaking the resent email 210 | if ($header->getName() === 'Content-Type' && \in_array($headerValue, ['multipart/alternative', 'multipart/mixed'], true)) { 211 | continue; 212 | } 213 | 214 | if ($header->getName() === 'Return-Path') { 215 | $headerValue = $this->determineReturnPath($headerValue); 216 | 217 | if ($headerValue === null) { 218 | continue; 219 | } 220 | } 221 | 222 | $email->getHeaders()->addHeader($header->getName(), $headerValue); 223 | } 224 | 225 | foreach ($message->getAllAttachmentParts() as $attachment) { 226 | if ($attachment->getContent() === null) { 227 | continue; 228 | } 229 | 230 | $email->attach($attachment->getContent(), $attachment->getFilename(), $attachment->getContentType()); 231 | } 232 | } 233 | 234 | private function enrichFromDatabase(MailArchiveEntity $mailArchive, Email $email): void 235 | { 236 | foreach ($mailArchive->getReceiver() as $mail => $name) { 237 | $email->addTo(new Address($mail, $name)); 238 | } 239 | 240 | foreach ($mailArchive->getSender() as $mail => $name) { 241 | $email->from(new Address($mail, $name)); 242 | } 243 | 244 | $email->subject($mailArchive->getSubject()); 245 | 246 | $email->html($mailArchive->getHtmlText()); 247 | $email->text($mailArchive->getPlainText()); 248 | } 249 | 250 | /** 251 | * @return string|array|\DateTimeImmutable|null 252 | */ 253 | private function getHeaderValue(IHeader $header): string|array|\DateTimeImmutable|null 254 | { 255 | if ($header instanceof AddressHeader) { 256 | /** @var AddressPart[] $addressParts */ 257 | $addressParts = $header->getParts(); 258 | 259 | return \array_map(function (AddressPart $part) use ($header) { 260 | if ($header->getName() === 'Return-Path') { 261 | return $part->getEmail(); 262 | } 263 | 264 | return new Address($part->getEmail(), $part->getName()); 265 | }, $addressParts); 266 | } 267 | 268 | if ($header instanceof DateHeader) { 269 | return $header->getDateTimeImmutable(); 270 | } 271 | 272 | return $header->getValue(); 273 | } 274 | 275 | /** 276 | * @param array $fileNameParts 277 | */ 278 | private function getFileName(array $fileNameParts): string 279 | { 280 | return (string) preg_replace( 281 | '/[\x00-\x1F\x7F-\xFF]/', 282 | '', 283 | \implode(' ', $fileNameParts), 284 | ); 285 | } 286 | 287 | private function getContent(?string $emlPath): false|string 288 | { 289 | if ($emlPath === '' || $emlPath === '0' || !\is_string($emlPath)) { 290 | return false; 291 | } 292 | 293 | return $this->emlFileManager->getEmlFileAsString($emlPath); 294 | } 295 | 296 | /** 297 | * @param \DateTimeImmutable|array|string|null $headerValue 298 | */ 299 | private function determineReturnPath(\DateTimeImmutable|array|string|null $headerValue): ?string 300 | { 301 | // Extract first item for return-path since Symfony/Mailer needs to be a string value here 302 | if (\is_array($headerValue)) { 303 | $headerValue = array_pop($headerValue); 304 | } 305 | 306 | // extract mail from: <"mail@example.com" > 307 | // see https://github.com/symfony/symfony/pull/59796 308 | if ($headerValue instanceof Address) { 309 | return $headerValue->getEncodedAddress(); 310 | } 311 | 312 | if (\is_string($headerValue)) { 313 | $regex = '/[<"]([^<>"\s]+@[^<>"\s]+)[>"]/'; 314 | preg_match($regex, $headerValue, $matches); 315 | if (isset($matches[1])) { 316 | $headerValue = $matches[1]; 317 | } 318 | } 319 | 320 | if (\is_string($headerValue)) { 321 | try { 322 | return (new Address($headerValue))->getEncodedAddress(); 323 | } catch (\Throwable) { 324 | // we don't care about invalid addresses 325 | } 326 | } 327 | 328 | return null; 329 | } 330 | } 331 | --------------------------------------------------------------------------------