mirror of
https://github.com/flarum/core.git
synced 2025-08-04 15:37:51 +02:00
feat: notification unsubscribe & email overhaul with HTML multipart (#3872)
This commit is contained in:
@@ -8,6 +8,7 @@ import type { IPageAttrs } from '../../common/components/Page';
|
||||
import type { AlertIdentifier } from '../../common/states/AlertManagerState';
|
||||
import type Mithril from 'mithril';
|
||||
import type { SaveSubmitEvent } from './AdminPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export interface MailSettings {
|
||||
data: {
|
||||
@@ -65,16 +66,58 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const fields = this.driverFields![this.setting('mail_driver')()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
const mailSettings = this.mailSettingItems().toArray();
|
||||
|
||||
return (
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'mail_from',
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
})}
|
||||
{mailSettings.map((settingComponent) => settingComponent)}
|
||||
{this.submitButton()}
|
||||
|
||||
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user!.email() })}</div>
|
||||
<Button className="Button Button--primary" disabled={this.sendingTest || this.isChanged()} onclick={() => this.sendTestEmail()}>
|
||||
{app.translator.trans('core.admin.email.send_test_mail_button')}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
mailSettingItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const fields = this.driverFields![this.setting('mail_driver')()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
items.add(
|
||||
'mail_from',
|
||||
this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'mail_from',
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
}),
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail_format',
|
||||
this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'mail_format',
|
||||
options: {
|
||||
multipart: app.translator.trans('core.admin.email.format.multipart_option'),
|
||||
plain: app.translator.trans('core.admin.email.format.plain_option'),
|
||||
html: app.translator.trans('core.admin.email.format.html_option'),
|
||||
},
|
||||
label: app.translator.trans('core.admin.email.format_heading'),
|
||||
help: app.translator.trans('core.admin.email.format_help'),
|
||||
}),
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail_driver',
|
||||
<div>
|
||||
{this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'mail_driver',
|
||||
@@ -104,16 +147,11 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
|
||||
</div>
|
||||
</FieldSet>
|
||||
)}
|
||||
{this.submitButton()}
|
||||
|
||||
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user!.email() })}</div>
|
||||
<Button className="Button Button--primary" disabled={this.sendingTest || this.isChanged()} onclick={() => this.sendTestEmail()}>
|
||||
{app.translator.trans('core.admin.email.send_test_mail_button')}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</div>
|
||||
</div>,
|
||||
60
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
|
@@ -126,6 +126,12 @@ core:
|
||||
description: "Configure the driver, settings and addresses your forum will use to send email."
|
||||
driver_heading: Choose a Driver
|
||||
driver_label: Driver
|
||||
format:
|
||||
multipart_option: Multipart (recommended)
|
||||
plain_option: Plain Text
|
||||
html_option: HTML
|
||||
format_heading: Outgoing Email Format
|
||||
format_help: "Choose the format that outgoing emails will be sent in. The recommended option is <code>multipart</code>, as this will allow your user's email client to display the most appropriate format."
|
||||
from_label: Sender
|
||||
mail_encryption_label: Encryption
|
||||
mail_host_label: Host
|
||||
@@ -727,6 +733,16 @@ core:
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.reset_your_password
|
||||
|
||||
# Translations in this namespace are displayed by the email unsubscribe interface.
|
||||
unsubscribe_email:
|
||||
title: Unsubscribe Confirmation
|
||||
return_to_forum: Back to {forumTitle}
|
||||
confirm_button: Confirm
|
||||
immediate_helptext: You will be immediately unsubscribed once you confirm.
|
||||
confirm_message: "You have requested to unsubscribe from \"{type}\" email notifications from {forumTitle}."
|
||||
success_message: "You have successfully unsubscribed from \"{type}\" notification from {forumTitle}. If you wish to receive them again, please [update your settings]({settingsLink})."
|
||||
invalid_message: "This unsubscribe link is invalid or has already been used. For any changes in your email notifications from {forumTitle}, please [check your settings]({settingsLink})."
|
||||
|
||||
# Translations in this namespace are used in messages output by the API.
|
||||
api:
|
||||
invalid_username_message: "The username may only contain letters, numbers, and dashes."
|
||||
@@ -737,13 +753,30 @@ core:
|
||||
|
||||
# Translations in this namespace are used in emails sent by the forum.
|
||||
email:
|
||||
greeting: "Hey {displayName},"
|
||||
signoff: "The {forumTitle} team"
|
||||
|
||||
# These translations are used by the "informational" email template.
|
||||
informational:
|
||||
default_title: "Information"
|
||||
footer: "This email was sent to {userEmail} as an informational service related to your account on [{forumTitle}]({forumUrl})."
|
||||
footer_plain: "This email was sent to {userEmail} as an informational service related to your account on {forumTitle}."
|
||||
|
||||
# These translations are used by the "notification" email template.
|
||||
notification:
|
||||
default_title: "Notification"
|
||||
footer:
|
||||
main_text: "This email was sent to {email} because you are subscribed to \"{type}\" notifications on [{forumTitle}]({forumUrl})."
|
||||
main_text_plain: "This email was sent to {email} because you are subscribed to \"{type}\" notifications on {forumTitle}."
|
||||
unsubscribe_text: "If you'd like to stop receiving this type of notification, [unsubscribe here]({unsubscribeLink})."
|
||||
unsubscribe_text_plain: "If you'd like to stop receiving this type of notification, unsubscribe here: {unsubscribeLink}"
|
||||
settings_text: "Manage your notification settings [here]({settingsLink})."
|
||||
settings_text_plain: "Manage your notification settings here: {settingsLink}"
|
||||
|
||||
# These translations are used in emails sent when users register new accounts.
|
||||
activate_account:
|
||||
subject: Activate Your New Account
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has signed up to {forum} with this email address.
|
||||
|
||||
If this was you, simply click the following link and your account will be activated:
|
||||
@@ -755,8 +788,6 @@ core:
|
||||
confirm_email:
|
||||
subject: Confirm Your New Email Address
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has changed their email address on {forum} to this one.
|
||||
|
||||
If this was you, simply click the following link and your email will be confirmed:
|
||||
@@ -768,8 +799,6 @@ core:
|
||||
reset_password:
|
||||
subject: => core.ref.reset_your_password
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has submitted a forgotten password request for your account on {forum}.
|
||||
|
||||
If this was you, click the following link to reset your password:
|
||||
@@ -781,8 +810,6 @@ core:
|
||||
send_test:
|
||||
subject: Flarum Email Test
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
This is a test email to confirm that your Flarum email configuration is working properly.
|
||||
|
||||
If this was you, this email means that your configuration works!
|
||||
|
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Flarum\Database\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
return Migration::createTable(
|
||||
'unsubscribe_tokens',
|
||||
function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->string('email_type');
|
||||
$table->string('token', 100)->unique();
|
||||
$table->timestamp('unsubscribed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||
|
||||
$table->index('user_id');
|
||||
$table->index('email_type');
|
||||
$table->index('token');
|
||||
$table->index(['user_id', 'email_type']);
|
||||
}
|
||||
);
|
@@ -11,8 +11,10 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Mail\Job\SendInformationalEmailJob;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
use Illuminate\Contracts\Queue\Factory;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -22,7 +24,9 @@ class SendTestMailController implements RequestHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected Mailer $mailer,
|
||||
protected TranslatorInterface $translator
|
||||
protected TranslatorInterface $translator,
|
||||
protected SettingsRepositoryInterface $settings,
|
||||
protected Factory $queue
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -31,12 +35,16 @@ class SendTestMailController implements RequestHandlerInterface
|
||||
$actor = RequestUtil::getActor($request);
|
||||
$actor->assertAdmin();
|
||||
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['username' => $actor->username]);
|
||||
|
||||
$this->mailer->raw($body, function (Message $message) use ($actor) {
|
||||
$message->to($actor->email);
|
||||
$message->subject($this->translator->trans('core.email.send_test.subject'));
|
||||
});
|
||||
$this->queue->connection('sync')->push(
|
||||
new SendInformationalEmailJob(
|
||||
email: $actor->email,
|
||||
displayName: $actor->display_name,
|
||||
subject: $this->translator->trans('core.email.send_test.subject'),
|
||||
body: $this->translator->trans('core.email.send_test.body'),
|
||||
forumTitle: $this->settings->get('forum_title'),
|
||||
bodyTitle: $this->translator->trans('core.email.send_test.subject')
|
||||
)
|
||||
);
|
||||
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
@@ -204,4 +204,19 @@ class Formatter
|
||||
return $attributes;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plain text string (with or without Markdown) to it's HTML equivalent.
|
||||
*
|
||||
* @param ?string $content
|
||||
* @return string
|
||||
*/
|
||||
public function convert(?string $content): string
|
||||
{
|
||||
if (! $content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->getRenderer()->render($this->getParser()->parse($content));
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Forum\Controller;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Notification\UnsubscribeToken;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laminas\Diactoros\Response\RedirectResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class UnsubscribeActionController implements RequestHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected Dispatcher $bus,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Request $request): ResponseInterface
|
||||
{
|
||||
$parsedBody = $request->getParsedBody();
|
||||
$token = Arr::get($parsedBody, 'token');
|
||||
$userId = Arr::get($parsedBody, 'userId');
|
||||
|
||||
/** @var UnsubscribeToken|null $unsubscribeRecord */
|
||||
$unsubscribeRecord = UnsubscribeToken::where('user_id', $userId)
|
||||
->where('token', $token)
|
||||
->first();
|
||||
|
||||
if ($unsubscribeRecord && empty($unsubscribeRecord->unsubscribed_at)) {
|
||||
$unsubscribeRecord->unsubscribed_at = Carbon::now();
|
||||
$unsubscribeRecord->save();
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::find($userId);
|
||||
$user->setNotificationPreference($unsubscribeRecord->email_type, 'email', false);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->url->to('forum')->base());
|
||||
}
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Forum\Controller;
|
||||
|
||||
use Flarum\Http\Controller\AbstractHtmlController;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Notification\UnsubscribeToken;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
class UnsubscribeViewController extends AbstractHtmlController
|
||||
{
|
||||
public function __construct(
|
||||
protected Factory $view,
|
||||
protected UrlGenerator $url,
|
||||
protected TranslatorInterface $translator,
|
||||
protected SettingsRepositoryInterface $settings
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(Request $request): View
|
||||
{
|
||||
$userId = Arr::get($request->getQueryParams(), 'userId');
|
||||
$token = Arr::get($request->getQueryParams(), 'token');
|
||||
|
||||
// Fetch the unsubscribe token record
|
||||
/** @var UnsubscribeToken|null $unsubscribeRecord */
|
||||
$unsubscribeRecord = UnsubscribeToken::where('user_id', $userId)
|
||||
->where('token', $token)
|
||||
->first();
|
||||
|
||||
$settingsLink = $this->url->to('forum')->route('settings');
|
||||
$forumTitle = $this->settings->get('forum_title');
|
||||
|
||||
// If record exists and has not been used before
|
||||
if ($unsubscribeRecord && empty($unsubscribeRecord->unsubscribed_at)) {
|
||||
$view = 'flarum.forum::unsubscribe-confirmation';
|
||||
$message = $this->translator->trans('core.views.unsubscribe_email.confirm_message', [
|
||||
'settingsLink' => $settingsLink,
|
||||
'forumTitle' => $forumTitle,
|
||||
'type' => $unsubscribeRecord->email_type
|
||||
]);
|
||||
} else {
|
||||
// If the token doesn't exist or has already been used
|
||||
$view = 'flarum.forum::unsubscribe-error';
|
||||
$message = $this->translator->trans('core.views.unsubscribe_email.invalid_message', [
|
||||
'settingsLink' => $settingsLink,
|
||||
'forumTitle' => $forumTitle
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->view
|
||||
->make($view)
|
||||
->with('message', $message)
|
||||
->with('userId', $userId)
|
||||
->with('token', $token)
|
||||
->with('csrfToken', $request->getAttribute('session')->token());
|
||||
}
|
||||
}
|
@@ -147,7 +147,8 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$view->share([
|
||||
'translator' => $container->make(TranslatorInterface::class),
|
||||
'settings' => $container->make(SettingsRepositoryInterface::class)
|
||||
'settings' => $container->make(SettingsRepositoryInterface::class),
|
||||
'formatter' => $container->make(Formatter::class),
|
||||
]);
|
||||
|
||||
$events->listen(
|
||||
|
@@ -43,6 +43,18 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||
$route->toForum(Content\AssertRegistered::class)
|
||||
);
|
||||
|
||||
$map->get(
|
||||
'/notifications/unsubscribe/{userId}/{token}',
|
||||
'notifications.unsubscribe',
|
||||
$route->toController(Controller\UnsubscribeViewController::class)
|
||||
);
|
||||
|
||||
$map->post(
|
||||
'/notifications/unsubscribe/confirm',
|
||||
'notifications.unsubscribe.confirm',
|
||||
$route->toController(Controller\UnsubscribeActionController::class)
|
||||
);
|
||||
|
||||
$map->get(
|
||||
'/logout',
|
||||
'logout',
|
||||
|
@@ -58,6 +58,7 @@ class WriteSettings implements Step
|
||||
'forum_title' => 'A new Flarum forum',
|
||||
'forum_description' => '',
|
||||
'mail_driver' => 'mail',
|
||||
'mail_format' => 'multipart',
|
||||
'mail_from' => 'noreply@localhost',
|
||||
'slug_driver_Flarum\User\User' => 'default',
|
||||
'theme_colored_header' => '0',
|
||||
|
49
framework/core/src/Mail/Job/SendInformationalEmailJob.php
Normal file
49
framework/core/src/Mail/Job/SendInformationalEmailJob.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mail\Job;
|
||||
|
||||
use Flarum\Queue\AbstractJob;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
|
||||
class SendInformationalEmailJob extends AbstractJob
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $email,
|
||||
private readonly string $displayName,
|
||||
private readonly string $subject,
|
||||
private readonly string $body,
|
||||
private readonly string $forumTitle,
|
||||
private readonly ?string $bodyTitle = null,
|
||||
protected array $views = [
|
||||
'text' => 'flarum.forum::email.plain.information.base',
|
||||
'html' => 'flarum.forum::email.html.information.base'
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Mailer $mailer): void
|
||||
{
|
||||
$forumTitle = $this->forumTitle;
|
||||
$infoContent = $this->body;
|
||||
$userEmail = $this->email;
|
||||
$title = $this->bodyTitle;
|
||||
$username = $this->displayName;
|
||||
|
||||
$mailer->send(
|
||||
$this->views,
|
||||
compact('forumTitle', 'infoContent', 'userEmail', 'title', 'username'),
|
||||
function (Message $message) {
|
||||
$message->to($this->email);
|
||||
$message->subject($this->subject);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mail\Job;
|
||||
|
||||
use Flarum\Queue\AbstractJob;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
|
||||
class SendRawEmailJob extends AbstractJob
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $email,
|
||||
private readonly string $subject,
|
||||
private readonly string $body
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Mailer $mailer): void
|
||||
{
|
||||
$mailer->raw($this->body, function (Message $message) {
|
||||
$message->to($this->email);
|
||||
$message->subject($this->subject);
|
||||
});
|
||||
}
|
||||
}
|
@@ -14,7 +14,6 @@ use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Mail\Mailer as MailerContract;
|
||||
use Illuminate\Contracts\Validation\Factory;
|
||||
use Illuminate\Mail\Mailer;
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||
|
||||
@@ -62,18 +61,20 @@ class MailServiceProvider extends AbstractServiceProvider
|
||||
});
|
||||
|
||||
$this->container->singleton('mailer', function (Container $container): MailerContract {
|
||||
$settings = $container->make(SettingsRepositoryInterface::class);
|
||||
|
||||
$mailer = new Mailer(
|
||||
'flarum',
|
||||
$container['view'],
|
||||
$container['symfony.mailer.transport'],
|
||||
$container['events']
|
||||
$container['events'],
|
||||
$settings,
|
||||
);
|
||||
|
||||
if ($container->bound('queue')) {
|
||||
$mailer->setQueue($container->make('queue'));
|
||||
}
|
||||
|
||||
$settings = $container->make(SettingsRepositoryInterface::class);
|
||||
$mailer->alwaysFrom($settings->get('mail_from'), $settings->get('forum_title'));
|
||||
|
||||
return $mailer;
|
||||
|
46
framework/core/src/Mail/Mailer.php
Normal file
46
framework/core/src/Mail/Mailer.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mail;
|
||||
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Mail\Mailer as SymfonyMailer;
|
||||
use Symfony\Component\Mailer\Transport\TransportInterface;
|
||||
|
||||
class Mailer extends SymfonyMailer
|
||||
{
|
||||
public function __construct(
|
||||
string $name,
|
||||
Factory $views,
|
||||
TransportInterface $transport,
|
||||
Dispatcher $events = null,
|
||||
protected SettingsRepositoryInterface $settings
|
||||
) {
|
||||
parent::__construct($name, $views, $transport, $events);
|
||||
}
|
||||
|
||||
public function send($view, array $data = [], $callback = null)
|
||||
{
|
||||
$emailType = $this->settings->get('mail_format');
|
||||
|
||||
switch ($emailType) {
|
||||
case 'html':
|
||||
unset($view['text']);
|
||||
break;
|
||||
case 'plain':
|
||||
unset($view['html']);
|
||||
break;
|
||||
// case 'multipart' is the default, where Flarum will send both HTML and text versions of emails, so that the recipient's email client can choose which one to display.
|
||||
}
|
||||
|
||||
parent::send($view, $data, $callback);
|
||||
}
|
||||
}
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Notification\Job;
|
||||
|
||||
use Flarum\Notification\Blueprint\BlueprintInterface;
|
||||
use Flarum\Notification\MailableInterface;
|
||||
use Flarum\Notification\NotificationMailer;
|
||||
use Flarum\Queue\AbstractJob;
|
||||
@@ -17,7 +18,7 @@ use Flarum\User\User;
|
||||
class SendEmailNotificationJob extends AbstractJob
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailableInterface $blueprint,
|
||||
private readonly MailableInterface&BlueprintInterface $blueprint,
|
||||
private readonly User $recipient
|
||||
) {
|
||||
}
|
||||
|
@@ -14,9 +14,16 @@ use Flarum\Locale\TranslatorInterface;
|
||||
interface MailableInterface
|
||||
{
|
||||
/**
|
||||
* Get the name of the view to construct a notification email with.
|
||||
* Get the names of the views to construct a notification email with.
|
||||
*
|
||||
* To provide the best experience for the user, Flarum expects both a `text` and `html` view.
|
||||
*
|
||||
* @return array{
|
||||
* text: string,
|
||||
* html: string
|
||||
* }
|
||||
*/
|
||||
public function getEmailView(): string|array;
|
||||
public function getEmailViews(): array;
|
||||
|
||||
/**
|
||||
* Get the subject line for a notification email.
|
||||
|
@@ -9,34 +9,70 @@
|
||||
|
||||
namespace Flarum\Notification;
|
||||
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Notification\Blueprint\BlueprintInterface;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class NotificationMailer
|
||||
{
|
||||
public function __construct(
|
||||
protected Mailer $mailer,
|
||||
protected TranslatorInterface $translator,
|
||||
protected SettingsRepositoryInterface $settings
|
||||
protected SettingsRepositoryInterface $settings,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
||||
public function send(MailableInterface $blueprint, User $user): void
|
||||
public function send(MailableInterface&BlueprintInterface $blueprint, User $user): void
|
||||
{
|
||||
// Ensure that notifications are delivered to the user in their default language, if they've selected one.
|
||||
// If the selected locale is no longer available, the forum default will be used instead.
|
||||
$this->translator->setLocale($user->getPreference('locale') ?? $this->settings->get('default_locale'));
|
||||
|
||||
// Generate and save the unsubscribe token:
|
||||
$unsubscribeRecord = UnsubscribeToken::generate($user->id, $blueprint::getType());
|
||||
$unsubscribeRecord->save();
|
||||
|
||||
$unsubscribeLink = $this->url->to('forum')->route('notifications.unsubscribe', ['userId' => $user->id, 'token' => $unsubscribeRecord->token]);
|
||||
$settingsLink = $this->url->to('forum')->route('settings');
|
||||
$type = $blueprint::getType();
|
||||
$forumTitle = $this->settings->get('forum_title');
|
||||
$username = $user->display_name;
|
||||
$userEmail = $user->email;
|
||||
|
||||
$this->mailer->send(
|
||||
$blueprint->getEmailView(),
|
||||
compact('blueprint', 'user'),
|
||||
$this->getEmailViews($blueprint),
|
||||
compact('blueprint', 'user', 'unsubscribeLink', 'settingsLink', 'type', 'forumTitle', 'username', 'userEmail'),
|
||||
function (Message $message) use ($blueprint, $user) {
|
||||
$message->to($user->email, $user->display_name)
|
||||
->subject($blueprint->getEmailSubject($this->translator));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrives the email views from the blueprint, and enforces that both a
|
||||
* plain text and HTML view are provided.
|
||||
*
|
||||
* @param MailableInterface&BlueprintInterface $blueprint
|
||||
* @return array{
|
||||
* text: string,
|
||||
* html: string
|
||||
* }
|
||||
*/
|
||||
protected function getEmailViews(MailableInterface&BlueprintInterface $blueprint): array
|
||||
{
|
||||
$views = $blueprint->getEmailViews();
|
||||
|
||||
// check that both text and html views are provided
|
||||
if (! Arr::has($views, ['text', 'html'])) {
|
||||
throw new \InvalidArgumentException('Both text and html views must be provided to send an email notification of type'.$blueprint::getType().'.');
|
||||
}
|
||||
|
||||
return $views;
|
||||
}
|
||||
}
|
||||
|
57
framework/core/src/Notification/UnsubscribeToken.php
Normal file
57
framework/core/src/Notification/UnsubscribeToken.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Notification;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Database\AbstractModel;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $email_type
|
||||
* @property string $token
|
||||
* @property \Carbon\Carbon $unsubscribed_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property-read \Flarum\User\User|null $user
|
||||
*/
|
||||
class UnsubscribeToken extends AbstractModel
|
||||
{
|
||||
protected $table = 'unsubscribe_tokens';
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'int',
|
||||
'unsubscribed_at' => 'datetime'
|
||||
];
|
||||
|
||||
protected $fillable = ['user_id', 'email_type', 'token'];
|
||||
|
||||
const TOKEN_LENGTH = 60;
|
||||
|
||||
public static function generate(int $userId, string $emailType): static
|
||||
{
|
||||
$token = new static;
|
||||
|
||||
$token->token = Str::random(self::TOKEN_LENGTH);
|
||||
$token->user_id = $userId;
|
||||
$token->email_type = $emailType;
|
||||
$token->created_at = Carbon::now();
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
@@ -18,7 +18,7 @@ class QueueFactory implements Factory
|
||||
/**
|
||||
* The cached queue instance.
|
||||
*/
|
||||
private ?Queue $queue;
|
||||
private ?Queue $queue = null;
|
||||
|
||||
/**
|
||||
* Expects a callback that will be called to instantiate the queue adapter,
|
||||
|
@@ -24,6 +24,7 @@ class SettingsServiceProvider extends AbstractServiceProvider
|
||||
return new Collection([
|
||||
'theme_primary_color' => '#4D698E',
|
||||
'theme_secondary_color' => '#4D698E',
|
||||
'mail_format' => 'multipart',
|
||||
]);
|
||||
});
|
||||
|
||||
|
@@ -9,7 +9,8 @@
|
||||
|
||||
namespace Flarum\User;
|
||||
|
||||
use Flarum\Mail\Job\SendRawEmailJob;
|
||||
use Flarum\Mail\Job\SendInformationalEmailJob;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
trait AccountActivationMailerTrait
|
||||
{
|
||||
@@ -38,6 +39,12 @@ trait AccountActivationMailerTrait
|
||||
$body = $this->translator->trans('core.email.activate_account.body', $data);
|
||||
$subject = $this->translator->trans('core.email.activate_account.subject');
|
||||
|
||||
$this->queue->push(new SendRawEmailJob($user->email, $subject, $body));
|
||||
$this->queue->push(new SendInformationalEmailJob(
|
||||
email: $user->email,
|
||||
subject: $subject,
|
||||
body: $body,
|
||||
forumTitle: Arr::get($data, 'forum'),
|
||||
displayName: Arr::get($data, 'username')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@@ -11,10 +11,11 @@ namespace Flarum\User;
|
||||
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Mail\Job\SendRawEmailJob;
|
||||
use Flarum\Mail\Job\SendInformationalEmailJob;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\Event\EmailChangeRequested;
|
||||
use Illuminate\Contracts\Queue\Queue;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EmailConfirmationMailer
|
||||
{
|
||||
@@ -34,7 +35,13 @@ class EmailConfirmationMailer
|
||||
$body = $this->translator->trans('core.email.confirm_email.body', $data);
|
||||
$subject = $this->translator->trans('core.email.confirm_email.subject');
|
||||
|
||||
$this->queue->push(new SendRawEmailJob($email, $subject, $body));
|
||||
$this->queue->push(new SendInformationalEmailJob(
|
||||
email: $email,
|
||||
subject:$subject,
|
||||
body: $body,
|
||||
forumTitle: Arr::get($data, 'forum'),
|
||||
displayName: Arr::get($data, 'username')
|
||||
));
|
||||
}
|
||||
|
||||
protected function generateToken(User $user, string $email): EmailToken
|
||||
|
@@ -11,12 +11,13 @@ namespace Flarum\User\Job;
|
||||
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Mail\Job\SendRawEmailJob;
|
||||
use Flarum\Mail\Job\SendInformationalEmailJob;
|
||||
use Flarum\Queue\AbstractJob;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\PasswordToken;
|
||||
use Flarum\User\UserRepository;
|
||||
use Illuminate\Contracts\Queue\Queue;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class RequestPasswordResetJob extends AbstractJob
|
||||
{
|
||||
@@ -50,6 +51,13 @@ class RequestPasswordResetJob extends AbstractJob
|
||||
$body = $translator->trans('core.email.reset_password.body', $data);
|
||||
$subject = $translator->trans('core.email.reset_password.subject');
|
||||
|
||||
$queue->push(new SendRawEmailJob($user->email, $subject, $body));
|
||||
$queue->push(new SendInformationalEmailJob(
|
||||
email: $user->email,
|
||||
displayName: Arr::get($data, 'username'),
|
||||
subject: $subject,
|
||||
body: $body,
|
||||
forumTitle: Arr::get($data, 'forum'),
|
||||
bodyTitle: $subject
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@@ -687,4 +687,14 @@ class User extends AbstractModel
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of a notification preference.
|
||||
*/
|
||||
public function setNotificationPreference(string $type, string $method, bool $value): static
|
||||
{
|
||||
$this->setPreference(static::getNotificationPreferenceKey($type, $method), $value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
84
framework/core/views/email/html/base.blade.php
Normal file
84
framework/core/views/email/html/base.blade.php
Normal file
@@ -0,0 +1,84 @@
|
||||
@inject('url', 'Flarum\Http\UrlGenerator')
|
||||
|
||||
@php
|
||||
$primaryColor = $settings->get('theme_primary_color');
|
||||
$secondaryColor = $settings->get('theme_secondary_color');
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ $translator->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $title ?? 'Flarum Email' }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 5px;
|
||||
background-color: #fff;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
.content-preview {
|
||||
background-color: #f7f7f7; /* Light gray background */
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.signoff {
|
||||
margin-top: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="Header-title">
|
||||
<a href="{{ $url->to('forum')->base() }}" id="home-link">
|
||||
@if ($settings->get('logo_path'))
|
||||
<img src="{{ $url->to('forum')->base() . '/assets/' . $settings->get('logo_path') }}" alt="{{ $settings->get('forum_title') }}" class="Header-logo">
|
||||
@else
|
||||
{{ $settings->get('forum_title') }}
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
@yield('header')
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@if(!isset($greeting) || $greeting !== false)
|
||||
<div class="greeting">
|
||||
<p>{!! $translator->trans('core.email.greeting', ['displayName' => $username]) !!}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div class="main-content">
|
||||
@yield('content')
|
||||
</div>
|
||||
@if(!isset($signoff) || $signoff !== false)
|
||||
<div class="signoff">
|
||||
<p>{!! $translator->trans('core.email.signoff', ['forumTitle' => $settings->get('forum_title')]) !!}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
@yield('footer')
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
22
framework/core/views/email/html/information/base.blade.php
Normal file
22
framework/core/views/email/html/information/base.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
@extends('flarum.forum::email.html.base')
|
||||
|
||||
@section('header')
|
||||
<h2>{{ $title ?? $translator->trans('core.email.informational.default_title') }}</h2>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@if(isset($infoContent))
|
||||
<p>{!! $formatter->convert($infoContent) !!}</p>
|
||||
@else
|
||||
@yield('informationContent')
|
||||
@endif
|
||||
@hasSection('contentPreview')
|
||||
<div class="content-preview">
|
||||
@yield('contentPreview')
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p>{!! $formatter->convert($translator->trans('core.email.informational.footer', ['userEmail' => $userEmail, 'forumUrl' => $url->to('forum')->base(), 'forumTitle' => $settings->get('forum_title')])) !!}</p>
|
||||
@endsection
|
20
framework/core/views/email/html/notification/base.blade.php
Normal file
20
framework/core/views/email/html/notification/base.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@extends('flarum.forum::email.html.base')
|
||||
|
||||
@section('header')
|
||||
<h2>{{ $title ?? $translator->trans('core.email.notification.default_title') }}</h2>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@yield('notificationContent')
|
||||
@hasSection('contentPreview')
|
||||
<div class="content-preview">
|
||||
@yield('contentPreview')
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
<p>{!! $formatter->convert($translator->trans('core.email.notification.footer.main_text', ['email' => $user->email, 'type' => $type, 'forumUrl' => $url->to('forum')->base(), 'forumTitle' => $settings->get('forum_title')])) !!}</p>
|
||||
<p>{!! $formatter->convert($translator->trans('core.email.notification.footer.unsubscribe_text', ['unsubscribeLink' => $unsubscribeLink])) !!}</p>
|
||||
<p>{!! $formatter->convert($translator->trans('core.email.notification.footer.settings_text', ['settingsLink' => $settingsLink])) !!}</p>
|
||||
@endsection
|
14
framework/core/views/email/plain/base.blade.php
Normal file
14
framework/core/views/email/plain/base.blade.php
Normal file
@@ -0,0 +1,14 @@
|
||||
@yield('header')
|
||||
|
||||
@if(!isset($greeting) || $greeting !== false)
|
||||
{{ $translator->trans('core.email.greeting', ['displayName' => $username]) }}
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
|
||||
@if(!isset($signoff) || $signoff !== false)
|
||||
- {{ $translator->trans('core.email.signoff', ['forumTitle' => $settings->get('forum_title')]) }} -
|
||||
@endif
|
||||
|
||||
|
||||
@yield('footer')
|
13
framework/core/views/email/plain/information/base.blade.php
Normal file
13
framework/core/views/email/plain/information/base.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
@extends('flarum.forum::email.plain.base')
|
||||
|
||||
@section('header')
|
||||
{{ $title ?? $translator->trans('core.email.informational.default_title') }}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
{{ $infoContent ?? '' }}
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
{!! $translator->trans('core.email.informational.footer_plain', ['userEmail' => $userEmail, 'forumTitle' => $forumTitle]) !!}
|
||||
@endsection
|
17
framework/core/views/email/plain/notification/base.blade.php
Normal file
17
framework/core/views/email/plain/notification/base.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
@extends('flarum.forum::email.plain.base')
|
||||
|
||||
@section('header')
|
||||
{{ $title ?? $translator->trans('core.email.notification.default_title') }}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@yield('notificationContent')
|
||||
@endsection
|
||||
|
||||
@section('footer')
|
||||
{!! $translator->trans('core.email.notification.footer.main_text_plain', ['email' => $user->email, 'type' => $type, 'forumTitle' => $forumTitle]) !!}
|
||||
|
||||
{!! $translator->trans('core.email.notification.footer.unsubscribe_text_plain', ['unsubscribeLink' => $unsubscribeLink]) !!}
|
||||
|
||||
{!! $translator->trans('core.email.notification.footer.settings_text_plain', ['settingsLink' => $settingsLink]) !!}
|
||||
@endsection
|
22
framework/core/views/unsubscribe-confirmation.blade.php
Normal file
22
framework/core/views/unsubscribe-confirmation.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
@extends('flarum.forum::layouts.basic')
|
||||
|
||||
@section('title', $translator->trans('core.views.unsubscribe_email.title'))
|
||||
|
||||
@section('content')
|
||||
<h2>{{ $translator->trans('core.views.unsubscribe_email.title') }}</h2>
|
||||
<p>{!! $formatter->convert($message) !!}</p>
|
||||
<p>{{ $translator->trans('core.views.unsubscribe_email.immediate_helptext') }}</p>
|
||||
|
||||
<form action="{{ $url->to('forum')->route('notifications.unsubscribe.confirm') }}" method="post">
|
||||
<input type="hidden" name="userId" value="{{ $userId }}">
|
||||
<input type="hidden" name="token" value="{{ $token }}">
|
||||
<input type="hidden" name="csrfToken" value="{{ $csrfToken }}">
|
||||
<button type="submit" class="button Button Button--primary">
|
||||
{{ $translator->trans('core.views.unsubscribe_email.confirm_button') }}
|
||||
</button>
|
||||
</form>
|
||||
<br/>
|
||||
<a href="{{ $url->to('forum')->base() }}">
|
||||
{{ $translator->trans('core.views.unsubscribe_email.return_to_forum', ['forumTitle' => $settings->get('forum_title')]) }}
|
||||
</a>
|
||||
@endsection
|
12
framework/core/views/unsubscribe-error.blade.php
Normal file
12
framework/core/views/unsubscribe-error.blade.php
Normal file
@@ -0,0 +1,12 @@
|
||||
@extends('flarum.forum::layouts.basic')
|
||||
|
||||
@section('title', $translator->trans('core.views.unsubscribe_email_error.title'))
|
||||
|
||||
@section('content')
|
||||
<h2>{{ $translator->trans('core.views.unsubscribe_email_error.title') }}</h2>
|
||||
<p>{!! $message !!}</p>
|
||||
|
||||
<a href="{{ $url->to('forum')->base() }}">
|
||||
{{ $translator->trans('core.views.unsubscribe_email.return_to_forum', ['forumTitle' => $settings->get('forum_title')]) }}
|
||||
</a>
|
||||
@endsection
|
Reference in New Issue
Block a user