From 9c5469449999a4500cd982200643ace40411de68 Mon Sep 17 00:00:00 2001 From: Peter Alexandre Date: Tue, 4 Aug 2026 22:02:45 +0200 Subject: [PATCH] fix(mailer): use noreply@bewelcome.org as From to pass DMARC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contact form submissions from Yahoo/Gmail/Outlook users were silently rejected by Mailcow (score 31+/15) because Rspamd enforces DMARC policy. When Lion relays with From: user@yahoo.fr, SPF and DKIM both fail for yahoo.fr → DMARC p=reject triggers. Fix: sendFeedbackEmail and sendCommentReportedFeedbackEmail now send with From: noreply@bewelcome.org (which Lion is authorised to sign) and add Reply-To: so staff can still reply directly to the user. Also adds the reporter email to the feedback email body so Znuny can attribute the ticket correctly after the From change. Adds unit tests covering From/Reply-To headers for both methods. --- src/Service/Mailer.php | 19 ++-- templates/emails/feedback.html.twig | 5 +- tests/Service/MailerTest.php | 162 ++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 tests/Service/MailerTest.php diff --git a/src/Service/Mailer.php b/src/Service/Mailer.php index ce9fbcba6d..7c6818b5bd 100644 --- a/src/Service/Mailer.php +++ b/src/Service/Mailer.php @@ -97,10 +97,11 @@ public function sendCommentReportedFeedbackEmail(Member $member, $parameters): b $feedbackCategory = $feedbackCategoryRepository->findOneBy(['name' => 'Comment_issue']); return $this->sendTemplateEmail( - new Address($member->getEmail()), + new Address(self::NO_REPLY_EMAIL_ADDRESS, 'BeWelcome'), new Address($feedbackCategory->getEmailToNotify(), 'Comment Issue'), 'comment.feedback', - $parameters + $parameters, + $member->getEmail(), ); } @@ -147,7 +148,8 @@ public function sendNotificationEmail(Address $sender, Member $receiver, $parame } /** - * This feeds the feedback given by a user into the OTRS queues. + * Sends contact/feedback form submissions to the helpdesk queue. + * From: is always noreply@bewelcome.org to pass DMARC; the reporter's address goes in Reply-To. */ public function sendFeedbackEmail($sender, Address $receiver, $parameters): bool { @@ -155,10 +157,11 @@ public function sendFeedbackEmail($sender, Address $receiver, $parameters): bool . str_replace('_', ' ', ($parameters['IdCategory'])->getName()) . "'"; return $this->sendTemplateEmail( - $sender, + self::NO_REPLY_EMAIL_ADDRESS, $receiver, 'feedback', - $parameters + $parameters, + \is_string($sender) ? $sender : null, ); } @@ -298,7 +301,7 @@ private function getBeWelcomeAddress(Member $sender, $email): Address * * @return bool */ - private function sendTemplateEmail($sender, $receiver, string $template, array $parameters): bool + private function sendTemplateEmail($sender, $receiver, string $template, array $parameters, ?string $replyTo = null): bool { $currentLocale = $this->translator->getLocale(); $success = true; @@ -337,6 +340,10 @@ private function sendTemplateEmail($sender, $receiver, string $template, array $ } $email->from($sender); + if (null !== $replyTo) { + $email->replyTo(new Address($replyTo)); + } + try { $this->mailer->send($email); } catch (TransportExceptionInterface $e) { diff --git a/templates/emails/feedback.html.twig b/templates/emails/feedback.html.twig index 63c4086ef4..85ba9c55af 100644 --- a/templates/emails/feedback.html.twig +++ b/templates/emails/feedback.html.twig @@ -1,5 +1,8 @@ {% if member is not null %} - Member {{ member.username }} has sent some feedback.
+ Member {{ member.username }} ({{ FeedbackEmail }}) has sent some feedback.
+
+{% elseif FeedbackEmail is not null %} + From: {{ FeedbackEmail }}

{% endif %} {{ FeedbackQuestion|raw }} diff --git a/tests/Service/MailerTest.php b/tests/Service/MailerTest.php new file mode 100644 index 0000000000..8f6561a2e4 --- /dev/null +++ b/tests/Service/MailerTest.php @@ -0,0 +1,162 @@ +createStub(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); + $translator->method('trans')->willReturnArgument(0); + + $this->innerMailer = $this->createMock(MailerInterface::class); + + $this->mailer = new Mailer( + $this->createStub(EntityManagerInterface::class), + $this->createStub(UrlGeneratorInterface::class), + $translator, + $this->innerMailer, + $this->createStub(Logger::class), + ); + } + + public function testSendFeedbackEmailUsesNoreplyAsFrom(): void + { + $captured = null; + $this->innerMailer + ->expects($this->once()) + ->method('send') + ->with($this->callback(static function (Email $email) use (&$captured) { + $captured = $email; + + return true; + })); + + $category = $this->createStub(FeedbackCategory::class); + $category->method('getName')->willReturn('Safety_Concern'); + + $this->mailer->sendFeedbackEmail( + 'reporter@yahoo.fr', + new Address('abuse@bewelcome.org'), + [ + 'IdCategory' => $category, + 'FeedbackQuestion' => 'Test feedback', + 'member' => null, + 'no_reply_needed' => false, + 'browser' => 'Firefox', + 'host' => 'bewelcome.org', + 'version' => '1.0', + ] + ); + + $this->assertNotNull($captured); + + $from = $captured->getFrom(); + $this->assertCount(1, $from); + $this->assertSame('noreply@bewelcome.org', $from[0]->getAddress(), 'From must be noreply to pass DMARC'); + + $replyTo = $captured->getReplyTo(); + $this->assertCount(1, $replyTo); + $this->assertSame('reporter@yahoo.fr', $replyTo[0]->getAddress(), 'Reply-To must be the reporter so staff can reply'); + } + + public function testSendFeedbackEmailNullFallbackUsesNoreplyWithNoReplyTo(): void + { + $captured = null; + $this->innerMailer + ->expects($this->once()) + ->method('send') + ->with($this->callback(static function (Email $email) use (&$captured) { + $captured = $email; + + return true; + })); + + $category = $this->createStub(FeedbackCategory::class); + $category->method('getName')->willReturn('General'); + + // AboutModel falls back to 'feedback@bewelcome.org' when FeedbackEmail is null + $this->mailer->sendFeedbackEmail( + 'feedback@bewelcome.org', + new Address('feedback@bewelcome.org'), + [ + 'IdCategory' => $category, + 'FeedbackQuestion' => 'Test feedback', + 'member' => null, + 'no_reply_needed' => true, + 'browser' => 'Chrome', + 'host' => 'bewelcome.org', + 'version' => '1.0', + ] + ); + + $from = $captured->getFrom(); + $this->assertSame('noreply@bewelcome.org', $from[0]->getAddress()); + } + + public function testSendCommentReportedFeedbackEmailUsesNoreplyAsFrom(): void + { + $captured = null; + $this->innerMailer + ->expects($this->once()) + ->method('send') + ->with($this->callback(static function (Email $email) use (&$captured) { + $captured = $email; + + return true; + })); + + $member = $this->createStub(Member::class); + $member->method('getEmail')->willReturn('member@gmail.com'); + + $category = $this->createStub(FeedbackCategory::class); + $category->method('getEmailToNotify')->willReturn('account@bewelcome.org'); + + $repo = $this->createStub(EntityRepository::class); + $repo->method('findOneBy')->willReturn($category); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($repo); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); + $translator->method('trans')->willReturnArgument(0); + + $mailer = new Mailer( + $em, + $this->createStub(UrlGeneratorInterface::class), + $translator, + $this->innerMailer, + $this->createStub(Logger::class), + ); + + $mailer->sendCommentReportedFeedbackEmail($member, ['subject' => 'Comment feedback']); + + $this->assertNotNull($captured); + + $from = $captured->getFrom(); + $this->assertCount(1, $from); + $this->assertSame('noreply@bewelcome.org', $from[0]->getAddress(), 'From must be noreply to pass DMARC'); + + $replyTo = $captured->getReplyTo(); + $this->assertCount(1, $replyTo); + $this->assertSame('member@gmail.com', $replyTo[0]->getAddress(), 'Reply-To must be the member so staff can reply'); + } +}