$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 |
3 |
12 |
13 |
14 | {{ date(item.createdAt) }}
15 | (
16 | {{ $tc('frosh-mail-archive.detail.resend-grid.currently-selected') }}
17 | )
18 |
19 |
23 | {{ date(item.createdAt) }}
24 |
25 |
26 |
27 |
28 |
29 |
34 |
39 |
44 | {{ translateState(item.transportState) }}
45 |
46 |
47 |
48 |
49 |
53 | {{ $tc('frosh-mail-archive.detail.resend-grid.navigate') }}
54 |
55 |
56 |
57 |
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 |
3 |
8 |
9 |
10 |
11 |
12 |
13 | {{ $tc('sw-settings.index.title') }}
14 |
19 | {{ $tc('frosh-mail-archive.title') }}
20 |
21 |
22 |
23 |
24 |
33 |
34 |
35 | {{ element }}
36 | <
37 | {{ index }}
38 | >
39 |
40 |
41 |
42 |
43 |
44 | {{ date(item.createdAt, {hour: '2-digit', minute: '2-digit', second: '2-digit'}) }}
45 |
46 |
47 |
48 |
49 | {{ translateState(item.transportState) }}
50 |
51 |
59 |
66 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
90 |
91 |
92 |
93 |
98 | {{ $tc('frosh-mail-archive.list.actions.bulkResendAction') }}
99 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
119 |
120 |
124 |
129 |
135 |
140 |
145 |
148 |
152 |
153 |
154 |
155 |
160 | {{ $tc('frosh-mail-archive.list.sidebar.filters.resetFilter') }}
161 |
162 |
163 |
164 |
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 |
3 | {{ archive.subject }}
4 |
5 |
6 |
7 |
13 | {{ $t('frosh-mail-archive.detail.toolbar.downloadEml') }}
14 |
15 |
21 | {{ $t('frosh-mail-archive.detail.toolbar.resend') }}
22 |
23 |
24 |
25 |
26 |
27 |
33 | {{ $t('frosh-mail-archive.detail.alert.transportFailed') }}
34 |
35 |
39 |
44 |
45 |
46 | {{ $tc('frosh-mail-archive.detail.metadata.receiver') }}
47 |
48 | {{ receiverText }}
49 |
50 |
51 |
52 | {{ $tc('frosh-mail-archive.detail.metadata.sender') }}
53 |
54 | {{ senderText }}
55 |
56 |
57 |
58 | {{ $tc('frosh-mail-archive.detail.metadata.subject') }}
59 |
60 | {{ archive.subject }}
61 |
62 |
63 |
64 | {{ $tc('frosh-mail-archive.detail.metadata.sentDate') }}
65 |
66 | {{ createdAtDate }}
67 |
68 |
69 |
70 | {{ $tc('frosh-mail-archive.detail.metadata.salesChannel') }}
71 |
72 |
73 | {{ archive.salesChannel.name }}
74 |
75 | -
76 |
77 |
78 |
79 | {{ $tc('frosh-mail-archive.detail.metadata.customer') }}
80 |
81 |
82 |
85 | {{ archive.customer.customerNumber }}
86 | -
87 | {{ archive.customer.firstName }}
88 | {{ archive.customer.lastName }}
89 |
90 |
91 | -
92 |
93 |
94 |
95 | {{ $tc('frosh-mail-archive.detail.metadata.order') }}
96 |
97 |
98 |
101 | {{ archive.order.orderNumber }}
102 |
103 |
104 | -
105 |
106 |
107 |
108 | {{ $tc('frosh-mail-archive.detail.metadata.flow') }}
109 |
110 |
111 |
114 | {{ archive.flow.name }}
115 |
116 |
117 | -
118 |
119 |
120 |
121 |
126 |
130 |
131 | HTML
132 |
137 |
138 |
139 |
140 | Plain
141 |
146 |
147 |
148 |
152 |
153 |
158 |
162 | {{ $t('frosh-mail-archive.detail.attachments.attachments-incomplete-alert') }}
163 |
164 |
165 | {{ $t('frosh-mail-archive.detail.attachments.no-attachments-alert') }}
166 |
167 |
168 |
173 |
174 |
175 | {{ $t('frosh-mail-archive.detail.attachments.size-unknown') }}
176 |
177 |
178 |
179 | {{ formatSize(item.fileSize) }}
180 |
181 |
182 |
183 |
184 |
190 |
191 |
192 |
193 |
194 |
195 |
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 |
--------------------------------------------------------------------------------