diff --git a/Classes/Aspect/DebuggingAspect.php b/Classes/Aspect/DebuggingAspect.php deleted file mode 100644 index bdeae35..0000000 --- a/Classes/Aspect/DebuggingAspect.php +++ /dev/null @@ -1,85 +0,0 @@ -send())") - * @return void - */ - public function interceptEmails(\Neos\Flow\Aop\JoinPointInterface $joinPoint) { - - if ($this->settings['interceptAll']['active'] || $this->settings['bccAll']['active']) { - /** - * @var \Neos\SwiftMailer\Message $message - */ - $message = $joinPoint->getProxy(); - - if ($this->settings['interceptAll']['active']) { - - $oldTo = $message->getTo(); - $oldCc = $message->getCc(); - $oldBcc = $message->getBcc(); - - foreach ($this->settings['interceptAll']['noInterceptPatterns'] as $pattern) { - if (preg_match($pattern, key($oldTo))) { - // let the mail through but clean all cc and bcc fields - $message->setCc(array()); - $message->setBcc(array()); - return; - } - } - - // stop if this aspect is executed twice (happens if QueueAdaptor is installed) - if ($message->isIntercepted()) { - return; - } - - $interceptedRecipients = key($oldTo) . ($oldCc ? ' CC: ' . key($oldCc) : '') . ($oldBcc ? ' BCC: ' . key($oldBcc) : ''); - $message->setSubject('[intercepted '.$interceptedRecipients.'] '.$message->getSubject()); - $message->setIntercepted(true); - - $message->setCc(array()); - $message->setBcc(array()); - - $first = true; - foreach ($this->settings['interceptAll']['recipients'] as $email) { - if ($first) { - $message->setTo($email); - } else { - $message->addCc($email); - } - $first = false; - } - } - - if ($this->settings['bccAll']['active']) { - foreach ($this->settings['bccAll']['recipients'] as $email) { - $message->addBcc($email); - } - } - } - } -} - -?> diff --git a/Classes/Command/MailerCommandController.php b/Classes/Command/MailerCommandController.php new file mode 100644 index 0000000..28af6a0 --- /dev/null +++ b/Classes/Command/MailerCommandController.php @@ -0,0 +1,24 @@ +abstractMailerService->sendTest($to); + } +} diff --git a/Classes/DataSource/EmailReferenceDataSource.php b/Classes/DataSource/EmailReferenceDataSource.php new file mode 100644 index 0000000..6c91d81 --- /dev/null +++ b/Classes/DataSource/EmailReferenceDataSource.php @@ -0,0 +1,51 @@ +parents('[instanceof FormatD.DesignSystem:Site]') + ->find('[instanceof FormatD.Mailer:Document.Email]') + ->get(); + + $data = []; + foreach ($emailNodes as $emailNode) { + $data[] = [ + 'label' => $this->nodeLabelGenerator->getLabel($emailNode), + 'value' => $emailNode->aggregateId, + 'icon' => static::$icon + ]; + } + + return $data; + } +} diff --git a/Classes/Factories/MailFactory.php b/Classes/Factories/MailFactory.php new file mode 100644 index 0000000..68c674c --- /dev/null +++ b/Classes/Factories/MailFactory.php @@ -0,0 +1,79 @@ +from($from) + ->subject($subject); + + if (is_array($to)) { + $mail->to(...$to); + } else { + $mail->to($to); + } + + if ($replyTo) { + if (is_array($replyTo)) { + $mail->replyTo(...$replyTo); + } else { + $mail->replyTo($replyTo); + } + } + + if ($cc) { + if (is_array($cc)) { + $mail->cc(...$cc); + } else { + $mail->cc($cc); + } + } + + if ($bcc) { + if (is_array($bcc)) { + $mail->bcc(...$bcc); + } else { + $mail->bcc($bcc); + } + } + + if ($text) { + $mail->text($text); + } + + if ($html) { + $mail->html($html); + } + + return $mail; + } +} diff --git a/Classes/Factories/MailerFactory.php b/Classes/Factories/MailerFactory.php new file mode 100644 index 0000000..e58f977 --- /dev/null +++ b/Classes/Factories/MailerFactory.php @@ -0,0 +1,35 @@ +configuration['dsn'] ?? null; + + if (is_array($dsn)) { + $transport = Transport::fromDsns($dsn); + } elseif (is_string($dsn)) { + $transport = Transport::fromDsn($dsn); + } else { + throw new \InvalidArgumentException("Mailer needs a transport dsn configuration"); + } + + return new Mailer($transport); + } +} diff --git a/Classes/Form/Finishers/EmailFinisher.php b/Classes/Form/Finishers/EmailFinisher.php new file mode 100644 index 0000000..10e3cc7 --- /dev/null +++ b/Classes/Form/Finishers/EmailFinisher.php @@ -0,0 +1,136 @@ + self::CONTENT_TYPE_HTML, + self::FORMAT_PLAINTEXT => self::CONTENT_TYPE_PLAINTEXT, + ]; + + protected $defaultOptions = array( + 'senderAddress' => '', + 'senderName' => '', + 'recipientAddress' => '', + 'recipientName' => '', + 'format' => self::FORMAT_HTML, + 'attachAllPersistentResources' => false, + 'attachments' => [], + ); + + /** + * @return void + */ + public function initializeObject() { + $this->defaultOptions['senderAddress'] = $this->configuration['defaultFrom']['address']; + $this->defaultOptions['senderName'] = $this->configuration['defaultFrom']['name']; + $this->defaultOptions['recipientAddress'] = $this->configuration['defaultTo']['address']; + $this->defaultOptions['recipientName'] = $this->configuration['defaultTo']['name']; + } + + /** + * Executes this finisher + * @see AbstractFinisher::execute() + * + * @return void + * @throws FinisherException + */ + protected function executeInternal() + { + if (!class_exists(Mailer::class)) { + throw new FinisherException('"symfony/mailer" doesn\'t seem to be installed, but is required for the EmailFinisher to work!', 1714142034); + } + + $formValues = $this->finisherContext->getFormValues(); + $templateNode = $this->parseOption('templateNode'); + $subject = $this->parseOption('subject'); + $senderAddress = $this->parseOption('senderAddress'); + $senderName = $this->parseOption('senderName'); + $recipientAddress = $this->parseOption('recipientAddress'); + $recipientName = $this->parseOption('recipientName'); + $replyToAddress = $this->parseOption('replyToAddress'); + $carbonCopyAddress = $this->parseOption('carbonCopyAddress'); + $blindCarbonCopyAddress = $this->parseOption('blindCarbonCopyAddress'); + $format = $this->parseOption('format'); + + if ($templateNode === null) { + throw new FinisherException('The option "templateNode" must be set for the EmailFinisher.', 1714141943); + } + if ($subject === null) { + throw new FinisherException('The option "subject" must be set for the EmailFinisher.', 1327060320); + } + if ($recipientAddress === null) { + throw new FinisherException('The option "recipientAddress" must be set for the EmailFinisher.', 1327060200); + } + if (is_array($recipientAddress) && !empty($recipientName)) { + throw new FinisherException('The option "recipientName" cannot be used with multiple recipients in the EmailFinisher.', 1483365977); + } + if ($senderAddress === null) { + throw new FinisherException('The option "senderAddress" must be set for the EmailFinisher.', 1327060210); + } + + $emailHtml = $this->abstractMailerService->getHtml($this->abstractMailerService->getNodeById($templateNode)); + $parsedHtml = $this->replaceMarkerWithFormValues($formValues, $emailHtml); + + $this->abstractMailerService->send( + $subject, + new Address($recipientAddress, $recipientName), + new Address($senderAddress, $senderName), + '', + $parsedHtml, + $replyToAddress ? new Address($replyToAddress) : null, + $carbonCopyAddress ? new Address($carbonCopyAddress) : null, + $blindCarbonCopyAddress ? new Address($blindCarbonCopyAddress) : null + ); + } + + protected function replaceMarkerWithFormValues($formValues, $emailHtml) + { + preg_match_all('#\#\#\#(.*?)\#\#\##', $emailHtml, $matches); + + if (isset($matches[1])) { + foreach ($matches[1] as $match) { + $nestedMatch = explode('.', $match); + $replacement = ''; + + if (count($nestedMatch) > 1 && isset($formValues[$nestedMatch[0]][$nestedMatch[1]])) { + $replacement = $formValues[$nestedMatch[0]][$nestedMatch[1]]; + } elseif (isset($formValues[$match])) { + $replacement = $formValues[$match]; + } + + $emailHtml = str_replace("###{$match}###", $replacement, $emailHtml); + } + } + + return $emailHtml; + } +} diff --git a/Classes/Form/FormElements/AssetAttachment.php b/Classes/Form/FormElements/AssetAttachment.php new file mode 100644 index 0000000..f29111e --- /dev/null +++ b/Classes/Form/FormElements/AssetAttachment.php @@ -0,0 +1,37 @@ +assetRepository->findByIdentifier($elementValue); + $elementValue = $asset->getResource(); + } +} diff --git a/Classes/Service/AbstractMailerService.php b/Classes/Service/AbstractMailerService.php index 45b7361..1f6d9f3 100644 --- a/Classes/Service/AbstractMailerService.php +++ b/Classes/Service/AbstractMailerService.php @@ -1,108 +1,169 @@ defaultFrom = array($this->mailSettings['defaultFrom']['address'] => $this->mailSettings['defaultFrom']['name']); - } + use InterceptionTrait; - /** - * Creates new message object and stores it as processedMessage - * - * @return \Neos\SwiftMailer\Message - */ - public function createMessage() { - $message = $this->objectManager->get('Neos\SwiftMailer\Message'); - $message->setFrom($this->defaultFrom); - $this->processedMessage = $message; - return $message; - } + #[Flow\Inject(name: "FormatD.Mailer:MailerLogger")] + protected $mailerLogger; - /** - * Creates StandaloneView from templatePath and assigns default variables - * - * @param $templatePath - * @return \Neos\FluidAdaptor\View\StandaloneView - */ - public function createStandaloneView($templatePath) { - $view = $this->objectManager->get('Neos\FluidAdaptor\View\StandaloneView'); - $view->setTemplatePathAndFilename('resource://'.$templatePath); - $view->setFormat('html'); - $view->assign('baseUri', $this->baseUri); - return $view; - } + #[Flow\InjectConfiguration(package: "Neos.Flow", path: "http.baseUri")] + protected string $baseUri; + + #[Flow\Inject] + protected ContentRepositoryService $contentRepositoryService; + + #[Flow\Inject] + protected MailerFactory $mailerFactory; + + #[Flow\Inject] + protected MailFactory $mailFactory; + + protected MailerInterface $mailer; + + protected Address $defaultFromAddress; + + protected $client; + + public function initializeObject() + { + $this->mailer = $this->mailerFactory->createMailer(); + $this->defaultFromAddress = new Address($this->configuration['defaultFrom']['address'], $this->configuration['defaultFrom']['name']); + + $clientOptions = []; + $clientOptions = $this->setSslVerification($clientOptions); + $this->client = new Client($clientOptions); + } + + protected function setSslVerification(array $clientOptions): array + { + $flowContext = Bootstrap::getEnvironmentConfigurationSetting('FLOW_CONTEXT'); + $clientOptions['verify'] = str_contains($flowContext, 'Development') ? false : true; + + return $clientOptions; + } + + public function send($subject, $to, $from, $text, $html, $replyTo = null, $cc = null, $bcc = null) + { + $mail = $this->mailFactory->createMail( + $subject, + $to, + $from ? $from : $this->defaultFromAddress, + $text, + $html + ); + + if ($replyTo !== null) { + $mail->addReplyTo($replyTo); + } + + if ($cc !== null) { + $mail->addCc($cc); + } + + if ($bcc !== null) { + $mail->addBcc($bcc); + } + + if ($this->configuration['interceptAll']['active'] || $this->configuration['bccAll']['active']) { + $mail = $this->intercept($mail); + } + + $this->mailer->send($mail); + } + + /** + * @param array|Address|string $to + */ + public function sendTest($to) + { + $mail = $this->mailFactory->createMail( + 'Format D Mailer // Test E-Mail', + $to, + $this->defaultFromAddress, + 'Hello guys, this is a test e-mail', + $this->getHtml($this->getNodeById($this->configuration['templateNodes']['test'])) + ); + + $this->mailer->send($mail); + } - /** - * Convert inline images in html to attached ones - * - * @param string $html - * @return string - */ - protected function attachHtmlInlineImages($html) { - return preg_replace_callback('#(]*[ ]?src=")([^"]+)("[^>]*>)#', array($this, 'attachHtmlInlineImage'), $html); + public function getNodeById(string $id) + { + $contentRepository = $this->contentRepositoryService->getContentRepository(); + $contentGraph = $this->contentRepositoryService->getContentGraph($contentRepository); + + $generalizations = $contentRepository->getVariationGraph()->getRootGeneralizations(); + $dimensionSpacePoint = reset($generalizations); + + $subgraph = $contentGraph->getSubgraph( + $dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions(), + ); + + return $subgraph->findNodeById(NodeAggregateId::fromString($id)); } + public function getHtml(Node $emailNode) + { + $emailUri = $this->contentRepositoryService->uriForNode($emailNode); + + try { + $response = $this->client->request('GET', $emailUri); + } catch (ClientException $e) { + $this->mailerLogger->error("MAILER_ERROR :: " . $e->getResponse()->getBody()->getContents()); + } + + if ($response->getStatusCode() !== 200) { + $this->mailerLogger->error("MAILER_ERROR :: " . $response->getStatusCode()); + } + + $newsletterContent = $response->getBody()->getContents(); + + # attach images + if ($this->configuration['attachEmbeddedImages']) { + $newsletterContent = $this->attachHtmlInlineImages($newsletterContent); + } + + # @todo add attachment handling + + return $newsletterContent; + } + + protected function attachHtmlInlineImages($html) + { + return preg_replace_callback('#(]*[ ]?src=")([^"]+)("[^>]*>)#', array($this, 'attachHtmlInlineImage'), $html); + } + /** * Substitution function called by preg_replace_callback * * @param $match * @return string */ - public function attachHtmlInlineImage($match) { + public function attachHtmlInlineImage($match) + { $completeMatch = $match[0]; $imgTagStart = $match[1]; $path = $match[2]; @@ -114,12 +175,12 @@ public function attachHtmlInlineImage($match) { } // only use local embed if nothing else can work (legacy mode) - if (!isset($this->mailSettings['localEmbed']) || $this->mailSettings['localEmbed'] === false) { + if (!isset($this->configuration['localEmbed']) || $this->configuration['localEmbed'] === false) { // if in cli we do not know the baseurl so we request the file locally if (FLOW_SAPITYPE == 'CLI' && !preg_match('#^http.*#', $path)) { $path = FLOW_PATH_WEB . $path; } - } else if ($this->mailSettings['localEmbed']) { + } else if ($this->configuration['localEmbed']) { if (preg_match('#^http.*#', $path) && $this->baseUri) { // if we know the baseUri we remove it to be able to convert the path to a local path $path = str_replace($this->baseUri, "", $path, $replaceCount); @@ -130,60 +191,12 @@ public function attachHtmlInlineImage($match) { } } - if ($this->mailSettings['attachEmbeddedImages']) { - $this->processedMessage->attach(\Swift_Attachment::fromPath(urldecode($path))); - } + if ($this->configuration['attachEmbeddedImages']) { + #$this->processedMessage->attach(\Swift_Attachment::fromPath(urldecode($path))); + # @todo attach resource to symfony mail + } - return $imgTagStart.$this->processedMessage->embed(\Swift_Image::fromPath(urldecode($path))).'"'.$imgTagEnd; + #return $imgTagStart . $this->processedMessage->embed(\Swift_Image::fromPath(urldecode($path))) . '"' . $imgTagEnd; + return $imgTagStart . ' ' . $imgTagEnd; } - - /** - * Sets the mailcontent from a standalone view - * - * @param \Neos\SwiftMailer\Message $message - * @param \Neos\FluidAdaptor\View\StandaloneView $view - * @param bool $embedImages - */ - protected function setMailContentFromStandaloneView(\Neos\SwiftMailer\Message $message, \Neos\FluidAdaptor\View\StandaloneView $view, $embedImages = false) { - - $subject = trim($view->renderSection('subject')); - $html = trim($view->renderSection('html')); - $plain = trim($view->renderSection('plain', [], true)); - - if($embedImages) $html = $this->attachHtmlInlineImages($html); - - $message->setSubject($subject); - if ($html) $message->addPart($html,'text/html','utf-8'); - if ($plain) $message->addPart($plain,'text/plain','utf-8'); - } - - /** - * Sends a message - * - * @param \Neos\SwiftMailer\Message $message - */ - protected function sendMail(\Neos\SwiftMailer\Message $message) { - $message->send(); - } - - /** - * Sends test email to check the configuration - * - * @param string|array $to - * @return void - */ - public function sendTestMail($to) { - - $mail = $this->createMessage(); - - $view = $this->createStandaloneView('FormatD.Mailer/Private/Templates/Notifications/TestMail.html'); - $view->assign('teststring', 'HelloWorld'); - - $this->setMailContentFromStandaloneView($mail, $view, true); - $mail->setTo($to); - - $this->sendMail($mail); - } } - -?> \ No newline at end of file diff --git a/Classes/Service/ContentRepositoryService.php b/Classes/Service/ContentRepositoryService.php new file mode 100644 index 0000000..89982cb --- /dev/null +++ b/Classes/Service/ContentRepositoryService.php @@ -0,0 +1,100 @@ +serverRequestFactory->createServerRequest('GET', new Uri($this->baseUri)); + + if (isset($this->baseUri) && is_string($this->baseUri) && !empty($this->baseUri)) { + /** @var RouteParameters $routingParameters */ + $routingParameters = $httpRequest->getAttribute(ServerRequestAttributes::ROUTING_PARAMETERS) ?? RouteParameters::createEmpty(); + $routingParameters = $routingParameters->withParameter('requestUriHost', $this->baseUri); + $routingParameters = $routingParameters->withParameter('siteNodeName', $this->siteConfig['siteNodeName']); + $routingParameters = $routingParameters->withParameter('contentRepositoryId', $this->siteConfig['contentRepositoryId']); + + $httpRequestAttributes = $httpRequest->getAttributes(); + $httpRequestAttributes[ServerRequestAttributes::ROUTING_PARAMETERS] = $routingParameters; + $reflectedHttpRequest = new \ReflectionObject($httpRequest); + $reflectedHttpRequestAttributes = $reflectedHttpRequest->getProperty('attributes'); + $reflectedHttpRequestAttributes->setAccessible(true); + $reflectedHttpRequestAttributes->setValue($httpRequest, $httpRequestAttributes); + } + + $this->actionRequest = $this->actionRequestFactory->createActionRequest($httpRequest); + } + + public function getContentRepository(string $contentRepositoryId = 'default'): ContentRepository + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepositoryId); + return $this->contentRepositoryRegistry->get($contentRepositoryId); + } + + public function getWorkspace(ContentRepository $contentRepository, string $workspaceName = 'live'): Workspace + { + return $contentRepository->findWorkspaceByName(WorkspaceName::fromString($workspaceName)); + } + + public function getContentGraph(ContentRepository $contentRepository, string $workspaceName = 'live'): ContentGraphInterface + { + return $contentRepository->getContentGraph(WorkspaceName::fromString($workspaceName)); + } + + public function uriForNode(Node $node, ActionRequest $actionRequest = null, $absolute = true, $format = 'html'): UriInterface + { + $request = $actionRequest ? $actionRequest : $this->actionRequest; + return $this->nodeUriBuilderFactory + ->forActionRequest($request) + ->uriFor( + NodeAddress::fromNode($node), + $absolute ? Options::createEmpty()->withCustomFormat($format)->withForceAbsolute() : Options::createEmpty()->withCustomFormat($format) + ) + ; + } +} diff --git a/Classes/Traits/InterceptionTrait.php b/Classes/Traits/InterceptionTrait.php index 55a8a25..7d4439a 100644 --- a/Classes/Traits/InterceptionTrait.php +++ b/Classes/Traits/InterceptionTrait.php @@ -1,36 +1,64 @@ intercepted; - } - - /** - * @param bool $intercepted - */ - public function setIntercepted(bool $intercepted): void - { - $this->intercepted = $intercepted; - } + #[Flow\InjectConfiguration(package: "FormatD.Mailer")] + protected array $configuration = []; -} + /** + * @param Email $mail + */ + protected function intercept($mail) + { + if ($this->configuration['interceptAll']['active']) { + $mail = $this->interceptAll($mail); + } + + if ($this->configuration['bccAll']['active']) { + foreach ($this->configuration['bccAll']['recipients'] as $email) { + $mail = $mail->addBcc($email); + } + } + + return $mail; + } + + /** + * @param Email $mail + */ + protected function interceptAll($mail) + { + $originalTo = $mail->getTo(); + $originalCc = $mail->getCc(); + $originalBcc = $mail->getBcc(); -?> + foreach ($this->configuration['interceptAll']['noInterceptPatterns'] as $pattern) { + if (preg_match($pattern, key($originalTo))) { + $mail->to(new Address('somewhere@example.com')); + $mail->bcc(new Address('somewhere@example.com')); + return; + } + } + + # @todo check IF and HOW this needs to be adapted to work with job / mail queue + $interceptedRecipients = $originalTo[0]->getAddress() . ($originalCc ? ' CC: ' . $originalCc[0]->getAddress() : '') . ($originalBcc ? ' BCC: ' . $originalBcc[0]->getAddress() : ''); + $mail->subject('[intercepted ' . $interceptedRecipients . '] ' . $mail->getSubject()); + + $mail->cc(new Address('somewhere@example.com')); + $mail->bcc(new Address('somewhere@example.com')); + + $first = true; + foreach ($this->configuration['interceptAll']['recipients'] as $email) { + $first ? $mail->to($email) : $mail->addCc($email); + $first = false; + } + + return $mail; + } +} diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml new file mode 100644 index 0000000..22cb4d0 --- /dev/null +++ b/Configuration/Objects.yaml @@ -0,0 +1,8 @@ +'FormatD.Mailer:MailerLogger': + className: Psr\Log\LoggerInterface + scope: singleton + factoryObjectName: Neos\Flow\Log\PsrLoggerFactoryInterface + factoryMethodName: get + arguments: + 1: + value: MailerLogger diff --git a/Configuration/Settings.Neos.Flow.yaml b/Configuration/Settings.Neos.Flow.yaml new file mode 100644 index 0000000..be02e8b --- /dev/null +++ b/Configuration/Settings.Neos.Flow.yaml @@ -0,0 +1,14 @@ +Neos: + Flow: + log: + psr3: + Neos\Flow\Log\PsrLoggerFactory: + MailerLogger: + default: + class: Neos\Flow\Log\Backend\FileBackend + options: + logFileURL: '%FLOW_PATH_DATA%Logs/Mailer.log' + severityThreshold: '%LOG_INFO%' + createParentDirectories: true + maximumLogFileSize: 1048576 + logFilesToKeep: 1 diff --git a/Configuration/Settings.Neos.Form.yaml b/Configuration/Settings.Neos.Form.yaml new file mode 100644 index 0000000..954c6aa --- /dev/null +++ b/Configuration/Settings.Neos.Form.yaml @@ -0,0 +1,13 @@ +Neos: + Form: + presets: + default: + formElementTypes: + 'FormatD.Mailer:AssetAttachment': + superTypes: + 'Neos.Form:FormElement': true + implementationClassName: FormatD\Mailer\Form\FormElements\AssetAttachment + finisherPresets: + 'FormatD.Mailer:EmailFinisher': + implementationClassName: FormatD\Mailer\Form\Finishers\EmailFinisher + options: { } diff --git a/Configuration/Settings.Neos.Neos.yaml b/Configuration/Settings.Neos.Neos.yaml new file mode 100644 index 0000000..f885bba --- /dev/null +++ b/Configuration/Settings.Neos.Neos.yaml @@ -0,0 +1,5 @@ +Neos: + Neos: + fusion: + autoInclude: + FormatD.Mailer: true diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 8630952..6dc44ca 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -1,6 +1,9 @@ - FormatD: Mailer: + site: + siteNodeName: 'format-d-website' + contentRepositoryId: 'default' + dsn: 'smtp://user:pass@smtp.example.com:25' localEmbed: false attachEmbeddedImages: false interceptAll: @@ -12,4 +15,9 @@ FormatD: recipients: [] defaultFrom: address: 'example@example.com' - name: 'Example' \ No newline at end of file + name: 'Example From' + defaultTo: + address: 'example@example.com' + name: 'Example To' + templateNodes: + test: '' diff --git a/NodeTypes/Content/AssetAttachment.yaml b/NodeTypes/Content/AssetAttachment.yaml new file mode 100644 index 0000000..e5e730a --- /dev/null +++ b/NodeTypes/Content/AssetAttachment.yaml @@ -0,0 +1,23 @@ +FormatD.Mailer:AssetAttachment: + superTypes: + Neos.Form.Builder:FormElement: true + ui: + label: 'Attachment' + icon: 'icon-paperclip' + position: 90 + inspector: + groups: + 'formElement': + icon: 'icon-paperclip' + group: 'form.custom' + properties: + asset: + type: 'Neos\Media\Domain\Model\Asset' + ui: + label: 'Asset' + reloadIfChanged: true + inspector: + group: 'formElement' + editorOptions: + constraints: + mediaTypes: [ 'application/zip', 'application/pdf', 'application/msword', 'application/msexcel', 'application/mspowerpoint', 'application/powerpoint', 'application/vnd.ms-powerpoint', 'application/x-mspowerpoint', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.oasis.opendocument.presentation', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ] diff --git a/NodeTypes/Content/EmailFinisher.yaml b/NodeTypes/Content/EmailFinisher.yaml new file mode 100644 index 0000000..1bebf65 --- /dev/null +++ b/NodeTypes/Content/EmailFinisher.yaml @@ -0,0 +1,32 @@ +FormatD.Mailer:EmailFinisher: + superTypes: + Neos.Form.Builder:EmailFinisher: true + ui: + label: 'Email Finisher' + help: + message: 'Configure e-mail options and select a node based e-mail template.' + properties: + recipientAddress: + validation: ~ + senderAddress: + validation: ~ + templateSource: [ ] + templateNode: + type: string + ui: + label: 'E-Mail Template' + inspector: + group: 'finisher' + position: 'start' + editor: 'Neos.Neos/Inspector/Editors/SelectBoxEditor' + editorOptions: + allowEmpty: true + dataSourceIdentifier: formatd-mailer-email-reference + format: + defaultValue: 'html' + ui: + inspector: + editorOptions: + values: + 'plaintext': [] + 'multipart': [] diff --git a/NodeTypes/Document/Email.fusion b/NodeTypes/Document/Email.fusion new file mode 100644 index 0000000..1f78168 --- /dev/null +++ b/NodeTypes/Document/Email.fusion @@ -0,0 +1,45 @@ + +prototype(FormatD.Mailer:Document.Email) < prototype(Neos.Neos:Page) { + + head { + metaDescriptionTag > + metaKeywordsTag > + metaRobotsTag > + canonicalLink > + alternateLanguageLinks > + twitterCard > + openGraphMetaTags > + facebookMetaTags > + structuredData > + formatDComponentLoaderRegistry > + + email = FormatD.Mailer:Fragment.Head + } + + bodyTag.attributes.style = 'word-spacing:normal;' + + body = Neos.Fusion:Component { + + header = Neos.Neos:ContentCollection { + nodePath = 'header' + } + + main = Neos.Neos:ContentCollection { + nodePath = 'main' + } + + footer = Neos.Neos:ContentCollection { + nodePath = 'footer' + } + + renderer = Neos.Fusion:Join { + main = afx` + + ` + } + } +} diff --git a/NodeTypes/Document/Email.yaml b/NodeTypes/Document/Email.yaml new file mode 100644 index 0000000..d24de84 --- /dev/null +++ b/NodeTypes/Document/Email.yaml @@ -0,0 +1,16 @@ +FormatD.Mailer:Document.Email: + superTypes: + 'Neos.Neos:Document': true + ui: + label: 'Email' + icon: 'envelope' + position: 99 + childNodes: + header: + type: 'Neos.Neos:ContentCollection' + position: 'before main' + main: + type: 'Neos.Neos:ContentCollection' + footer: + type: 'Neos.Neos:ContentCollection' + position: 'after main' diff --git a/README.md b/README.md index 18508ba..fe10000 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,23 @@ # FormatD.Mailer -A Template Mailer for Neos Flow or/and CMS Projects. +A template mailer using Symfony Mailer to configure, sent and intercept e-mails in Flow and Neos. ## What does it do? This package provides a service class intended to be used as base class for sending fusion templates as mails. -In addition it counatins a debugging aspect for cutting off and/or redirecting all mails (sent by Swiftmailer) in a development environment. - - -## Kompatiblität - -Versioning scheme: - - 1.0.0 - | | | - | | Bugfix Releases (non breaking) - | Neos Compatibility Releases (non breaking except framework dependencies) - Feature Releases (breaking) - -Releases und compatibility: - -| Package-Version | Neos Flow Version | -|-----------------|------------------------| -| 1.1.x | >= 6.x | -| 1.0.x | 4.x - 5.x | +In addition it contains a debugging aspect for cutting off and/or redirecting all mails in a development environment. ## Using the service in you own plugins to use fluid templates for mails -Configure default from address: +Configure smtp data (`dsn`) and default from address (`defaultFrom`): ``` FormatD: Mailer: + dsn: 'smtp://user:pass@smtp.example.com:25' defaultFrom: address: 'example@example.com' name: 'Example' @@ -42,10 +25,25 @@ FormatD: Extend AbstractMailerService and add methods as needed following the example of sendTestMail(). +## Use `FormatD.Mailer:Document.Email` node type as e-mail templates +You can create e-mail templates from the Neos backend by choosing and creating and "E-Mail" document node. This node can either +* be referenced by its ID in the Settings.yaml to be sent via some controller action +* or chosen in the Form Finisher `FormatD.Mailer:EmailFinisher` under "E-Mail Template" to select an e-mail template for sending in the Form Builder + +### Get form values in e-mail templates +In the e-mail node in Neos, you can set markers to reflect the form values you want to submit. +E.g., you have a form builder form with the elements (ids!) "firstName" and "lastName" and want to display the values in your e-mail. You can write any text and set markers, like so: +First name: ###firstName### +Last name: ###lastName### +Topics: ###i-need-help-with.topic### -## intersept all mails in a dev environment +### Overriding e-mail template +The e-mail templates consist of various fusion prototypes which you can extend or completely override in your site package as you see fit. +`FormatD.Mailer:Document.Email` is a `Neos.Neos:Page` with multiple fragments for the e-mail head and body. -Configure swiftmailer to intersept all mails send by your neos installation (not only by the service). +## Intercept all e-mails in a dev environment + +Configure mailer to intercept all mails send by your Neos installation (not only by the service). This is an example which intercepts all mails and redirects them to example@example.com and secondexample@example.com: ``` @@ -60,7 +58,7 @@ FormatD: ``` -## Handling Embedded Images +## Handling embedded images The method `AbstractMailerService->setMailContentFromStandaloneView()` has a parameter to embed all images into the mail body. This is handy if you have an installation that is protected by a .htaccess file for example, or the user may not have access to the internet when reading the email. GMail also cannot display images if they are included from a local domain. @@ -74,7 +72,26 @@ FormatD: localEmbed: true ``` -## Disable embed for specific images +## Disable embedding for specific images You can disable image embedding for specific images by adding `data-fdmailer-embed="disable"` as data attribute to the image tag. -This is useful for tracking pixels where you dont want the local embedding. \ No newline at end of file +This is useful for tracking pixels where you dont want the local embedding. + + +## Compatibilty + +Versioning scheme: + + 1.0.0 + | | | + | | Bugfix Releases (non breaking) + | Neos Compatibility Releases (non breaking except framework dependencies) + Feature Releases (breaking) + +Releases und compatibility: + +| Package Version | Neos Flow Version | +|-----------------|------------------------| +| 2.0.x | >= 9.x | +| 1.1.x | >= 6.x | +| 1.0.x | 4.x - 5.x | \ No newline at end of file diff --git a/Resources/Private/Fusion/Fragments/Body.fusion b/Resources/Private/Fusion/Fragments/Body.fusion new file mode 100644 index 0000000..011e96f --- /dev/null +++ b/Resources/Private/Fusion/Fragments/Body.fusion @@ -0,0 +1,107 @@ + +prototype(FormatD.Mailer:Fragment.Body) < prototype(Neos.Fusion:Component) { + + @propTypes { + headerContent = ${propTypes.string} + mainContent = ${propTypes.string} + footerContent = ${propTypes.string} + } + + headerContent = 'Hello World' + mainContent = 'Content' + footerContent = '© Format D GmbH 2024' + + renderer = afx` +
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+

+

+ +
+
{props.headerContent}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{props.mainContent}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
{props.footerContent}
+
+
+
+ +
+
+ +
+ ` +} diff --git a/Resources/Private/Fusion/Fragments/Body.mjml b/Resources/Private/Fusion/Fragments/Body.mjml new file mode 100644 index 0000000..d6db11f --- /dev/null +++ b/Resources/Private/Fusion/Fragments/Body.mjml @@ -0,0 +1,28 @@ + + + + + + + {props.headerContent} + + + + + + + + {props.mainContent} + + + + + + + + {props.footerContent} + + + + + diff --git a/Resources/Private/Fusion/Fragments/Head.fusion b/Resources/Private/Fusion/Fragments/Head.fusion new file mode 100644 index 0000000..c73b5a9 --- /dev/null +++ b/Resources/Private/Fusion/Fragments/Head.fusion @@ -0,0 +1,10 @@ +prototype(FormatD.Mailer:Fragment.Head) < prototype(Neos.Fusion:Component) { + + renderer = afx` + + + + + + ` +} diff --git a/Resources/Private/Fusion/Overrides/Elements/AssetAttachment.fusion b/Resources/Private/Fusion/Overrides/Elements/AssetAttachment.fusion new file mode 100644 index 0000000..921035f --- /dev/null +++ b/Resources/Private/Fusion/Overrides/Elements/AssetAttachment.fusion @@ -0,0 +1,19 @@ +prototype(FormatD.Mailer:AssetAttachment) < prototype(Neos.Form:HiddenField) { + label > + @process.wrap.attributes.class = ${elementHasValidationErrors ? 'form--group-hidden error' : 'form--group-hidden'} + fieldContainer.field.attributes.name = "asset-attachment" + fieldContainer.field.attributes.value = ${element.properties.asset.identifier} + + validationErrors = Neos.Fusion:Tag { + tagName = 'span' + attributes { + class = 'help-inline' + } + content = Neos.Fusion:Loop { + items = ${elementValidationErrors} + itemName = 'error' + itemRenderer = ${error.message} + } + @if.hasValidationErrors = ${elementHasValidationErrors} + } +} diff --git a/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Elements/AssetAttachment.fusion b/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Elements/AssetAttachment.fusion new file mode 100644 index 0000000..420f06f --- /dev/null +++ b/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Elements/AssetAttachment.fusion @@ -0,0 +1,3 @@ +prototype(FormatD.Mailer:AssetAttachment.Definition) < prototype(Neos.Form.Builder:FormElement.Definition) { + formElementType = 'FormatD.Mailer:AssetAttachment' +} diff --git a/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Finisher/EmailFinisher.Definition.fusion b/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Finisher/EmailFinisher.Definition.fusion new file mode 100644 index 0000000..be9a063 --- /dev/null +++ b/Resources/Private/Fusion/Overrides/Neos.Form.Builder/Finisher/EmailFinisher.Definition.fusion @@ -0,0 +1,20 @@ +prototype(FormatD.Mailer:EmailFinisher.Definition) < prototype(Neos.Form.Builder:Finisher.Definition) { + formElementType = 'FormatD.Mailer:EmailFinisher' + + options { + templateNode = ${null} + subject = ${null} + recipientAddress = ${null} + recipientName = '' + senderAddress = ${null} + senderName = '' + replyToAddress = ${null} + carbonCopyAddress = ${null} + blindCarbonCopyAddress = ${null} + format = 'html' + variables = Neos.Fusion:DataStructure { + baseUri = ${Configuration.setting('Neos.Flow.http.baseUri')} + domainName = ${String.pregReplace(Configuration.setting('Neos.Flow.http.baseUri'), "/https?:\/\/(.*)\//", "$1")} + } + } +} \ No newline at end of file diff --git a/Resources/Private/Fusion/Root.fusion b/Resources/Private/Fusion/Root.fusion new file mode 100644 index 0000000..abc63d6 --- /dev/null +++ b/Resources/Private/Fusion/Root.fusion @@ -0,0 +1,10 @@ +include: Overrides/**/* +include: Fragments/**/* +include: nodetypes://FormatD.Mailer/**/*.fusion + +root { + emailTemplates { + condition = ${q(documentNode).is('[instanceof FormatD.Mailer:Document.Email]')} + renderer = FormatD.Mailer:Document.Email + } +} diff --git a/Resources/Private/Templates/Notifications/TestMail.html b/Resources/Private/Templates/Notifications/TestMail.html deleted file mode 100644 index d814bba..0000000 --- a/Resources/Private/Templates/Notifications/TestMail.html +++ /dev/null @@ -1,26 +0,0 @@ - -This is a Test E-Mail - - - - - - - - - -
-

- This is a test e-mail formatted with HTML
- {teststring}
- Baseuri: {baseUri} -

-
- -
- - - This is a test e-mail formatted with plaintext - {teststring} - Baseuri: {baseUri} - \ No newline at end of file diff --git a/composer.json b/composer.json index 6679b53..afe4ab8 100644 --- a/composer.json +++ b/composer.json @@ -1,98 +1,100 @@ { - "name": "formatd/mailer", - "description": "Wrapper for Swiftmailer in Neos Flow Projects", - "type": "neos-package", - "license": "MIT", - "require": { - "neos/flow": "^6.0 || ^7.0 || ^8.0", - "neos/swiftmailer": "^7.3" - }, - "suggest": [], - "autoload": { - "psr-4": { - "FormatD\\Mailer\\": "Classes/" - } - }, - "extra": { - "neos": { - "package-key": "FormatD.Mailer" - }, - "applied-flow-migrations": [ - "TYPO3.FLOW3-201201261636", - "TYPO3.Fluid-201205031303", - "TYPO3.FLOW3-201205292145", - "TYPO3.FLOW3-201206271128", - "TYPO3.FLOW3-201209201112", - "TYPO3.Flow-201209251426", - "TYPO3.Flow-201211151101", - "TYPO3.Flow-201212051340", - "TYPO3.TypoScript-130516234520", - "TYPO3.TypoScript-130516235550", - "TYPO3.TYPO3CR-130523180140", - "TYPO3.Neos.NodeTypes-201309111655", - "TYPO3.Flow-201310031523", - "TYPO3.Flow-201405111147", - "TYPO3.Neos-201407061038", - "TYPO3.Neos-201409071922", - "TYPO3.TYPO3CR-140911160326", - "TYPO3.Neos-201410010000", - "TYPO3.TYPO3CR-141101082142", - "TYPO3.Neos-20141113115300", - "TYPO3.Fluid-20141113120800", - "TYPO3.Flow-20141113121400", - "TYPO3.Fluid-20141121091700", - "TYPO3.Neos-20141218134700", - "TYPO3.Fluid-20150214130800", - "TYPO3.Neos-20150303231600", - "TYPO3.TYPO3CR-20150510103823", - "TYPO3.Flow-20151113161300", - "TYPO3.Form-20160601101500", - "TYPO3.Flow-20161115140400", - "TYPO3.Flow-20161115140430", - "Neos.Flow-20161124204700", - "Neos.Flow-20161124204701", - "Neos.Twitter.Bootstrap-20161124204912", - "Neos.Form-20161124205254", - "Neos.Flow-20161124224015", - "Neos.Party-20161124225257", - "Neos.Eel-20161124230101", - "Neos.Imagine-20161124231742", - "Neos.Media-20161124233100", - "Neos.NodeTypes-20161125002300", - "Neos.Neos-20161125002322", - "Neos.ContentRepository-20161125012000", - "Neos.Fusion-20161125013710", - "Neos.Fusion-20161125104701", - "Neos.NodeTypes-20161125104800", - "Neos.Neos-20161125104802", - "Neos.Neos-20161125122412", - "Neos.Flow-20161125124112", - "Neos.SwiftMailer-20161130105617", - "TYPO3.FluidAdaptor-20161130112935", - "Neos.Fusion-20161201202543", - "Neos.Neos-20161201222211", - "Neos.Fusion-20161202215034", - "Neos.ContentRepository.Search-20161210231100", - "Neos.Fusion-20161219092345", - "Neos.ContentRepository-20161219093512", - "Neos.Media-20161219094126", - "Neos.Neos-20161219094403", - "Neos.Neos-20161219122512", - "Neos.Fusion-20161219130100", - "Neos.Neos-20161220163741", - "Neos.Neos-20170115114620", - "Neos.Fusion-20170120013047", - "Neos.Flow-20170125103800", - "Neos.Seo-20170127154600", - "Neos.Flow-20170127183102", - "Neos.Fusion-20180211175500", - "Neos.Fusion-20180211184832", - "Neos.Flow-20180415105700", - "Neos.Neos-20180907103800", - "Neos.Neos.Ui-20190319094900", - "Neos.Flow-20190425144900", - "Neos.Flow-20190515215000", - "Neos.NodeTypes-20190917101945" - ] - } -} + "name": "formatd/mailer", + "description": "Send mails in Neos with symfony mailer", + "type": "neos-package", + "license": "MIT", + "require": { + "php": ">=8.1", + "neos/flow": "^8.0 || ^9.0", + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "symfony/mailer": "^7.0" + }, + "suggest": [], + "autoload": { + "psr-4": { + "FormatD\\Mailer\\": "Classes/" + } + }, + "extra": { + "neos": { + "package-key": "FormatD.Mailer" + }, + "applied-flow-migrations": [ + "TYPO3.FLOW3-201201261636", + "TYPO3.Fluid-201205031303", + "TYPO3.FLOW3-201205292145", + "TYPO3.FLOW3-201206271128", + "TYPO3.FLOW3-201209201112", + "TYPO3.Flow-201209251426", + "TYPO3.Flow-201211151101", + "TYPO3.Flow-201212051340", + "TYPO3.TypoScript-130516234520", + "TYPO3.TypoScript-130516235550", + "TYPO3.TYPO3CR-130523180140", + "TYPO3.Neos.NodeTypes-201309111655", + "TYPO3.Flow-201310031523", + "TYPO3.Flow-201405111147", + "TYPO3.Neos-201407061038", + "TYPO3.Neos-201409071922", + "TYPO3.TYPO3CR-140911160326", + "TYPO3.Neos-201410010000", + "TYPO3.TYPO3CR-141101082142", + "TYPO3.Neos-20141113115300", + "TYPO3.Fluid-20141113120800", + "TYPO3.Flow-20141113121400", + "TYPO3.Fluid-20141121091700", + "TYPO3.Neos-20141218134700", + "TYPO3.Fluid-20150214130800", + "TYPO3.Neos-20150303231600", + "TYPO3.TYPO3CR-20150510103823", + "TYPO3.Flow-20151113161300", + "TYPO3.Form-20160601101500", + "TYPO3.Flow-20161115140400", + "TYPO3.Flow-20161115140430", + "Neos.Flow-20161124204700", + "Neos.Flow-20161124204701", + "Neos.Twitter.Bootstrap-20161124204912", + "Neos.Form-20161124205254", + "Neos.Flow-20161124224015", + "Neos.Party-20161124225257", + "Neos.Eel-20161124230101", + "Neos.Imagine-20161124231742", + "Neos.Media-20161124233100", + "Neos.NodeTypes-20161125002300", + "Neos.Neos-20161125002322", + "Neos.ContentRepository-20161125012000", + "Neos.Fusion-20161125013710", + "Neos.Fusion-20161125104701", + "Neos.NodeTypes-20161125104800", + "Neos.Neos-20161125104802", + "Neos.Neos-20161125122412", + "Neos.Flow-20161125124112", + "Neos.SwiftMailer-20161130105617", + "TYPO3.FluidAdaptor-20161130112935", + "Neos.Fusion-20161201202543", + "Neos.Neos-20161201222211", + "Neos.Fusion-20161202215034", + "Neos.ContentRepository.Search-20161210231100", + "Neos.Fusion-20161219092345", + "Neos.ContentRepository-20161219093512", + "Neos.Media-20161219094126", + "Neos.Neos-20161219094403", + "Neos.Neos-20161219122512", + "Neos.Fusion-20161219130100", + "Neos.Neos-20161220163741", + "Neos.Neos-20170115114620", + "Neos.Fusion-20170120013047", + "Neos.Flow-20170125103800", + "Neos.Seo-20170127154600", + "Neos.Flow-20170127183102", + "Neos.Fusion-20180211175500", + "Neos.Fusion-20180211184832", + "Neos.Flow-20180415105700", + "Neos.Neos-20180907103800", + "Neos.Neos.Ui-20190319094900", + "Neos.Flow-20190425144900", + "Neos.Flow-20190515215000", + "Neos.NodeTypes-20190917101945" + ] + } +} \ No newline at end of file