I'm notifying pending users using a custom controller. The script is called by a cron job from an action URL, that loop through pending users:
$users = User::find()->status('pending')->limit(null)->all();
foreach ($users as $user) Craft::$app->getUsers()->sendActivationEmail($user);
The problem here is that the action link inside the email redirects to the reset password page (defined by setPasswordPath) instead of the success page (defined by activateAccountSuccessPath). Furthermore, it is not served using the language of the created user.
This wrong redirect happens even if verificationCodeDuration and defaultTokenDuration are setup for a very comfortable value, and even if the cron job test is done just after the user account creation.
After exploring the Craft source code, I found how to send the email using the correct action link like this:
foreach ($users as $user) {
$url = Craft::$app->getUsers()->getEmailVerifyUrl($user);
Craft::$app->getMailer()
->composeFromKey('account_activation', ['link' => Template::raw($url)])
->setTo($user)
->send();
}
But from this, the problem is that the link in this email redirect to the website using the default language, not the one defined un the userLanguage setting.
I tried to force the language of this link by adding this just before the $url setting within the loop: Craft::$app->language = $user->userLanguage ?? Craft::$app->language, but this does nothing more.
The problem here is that the action link inside the email redirects to the reset password page (defined by
setPasswordPath) instead of the success page (defined byactivateAccountSuccessPath). Furthermore, it is not served using the language of the created user.
That is expected. setPasswordPath is the path to the set-password page, which they must do in order to activate their account. activateAccountSuccessPath is where they should be redirected _after_ they’ve activated their account (hence the “Success”).
But from this, the problem is that the link in this email redirect to the website using the default language, not the one defined un the
userLanguagesetting.
It’s based on the currently requested site by default.
What is userLanguage – a custom field? Craft doesn’t have any userLanguage properties, or getUserLanguage() getter methods.
I don't think setPasswordPath is necessary in that case, as the password is defined by the user during the user registration process. This links should probably directs straight to activateAccountSuccessPath.
Yes, userLanguage is a custom field to define the user's preferred language (saved during the registration).
Is there any way to define the site version of the activation link, in order to match the user preference in that case?
Temporary changing the value of Craft::$app->language to match the correct requested site doesn't works.
Hm… if the password is set then you should be getting a URL based on the verifyEmailPath config setting, not setPasswordPath.
Why in this case of account activation using sendActivationEmail() the verifyEmailPath is used instead of the activateAccountSuccessPath?
As a test within this loop:
foreach ($users as $user) Craft::$app->getUsers()->sendActivationEmail($user);
I tried to print the $user->password value, to check if it's set:
So maybe it's the non-expected reason why the getPasswordResetUrl is sent instead of the getEmailVerifyUrl?
I also tried to print the custom $user->userLanguage, and native $user->language and $user->locale:
$user->userLanguage has the correct value, based on the hidden field during account registration;$user->language and $user->locale have the wrong value: they got the locale of the default site version, whereas the registration was made from the alternative site version (i.e. with another locale);So this can explain why the link in the notification email sent targets the wrong site version, but this still looks like a bug?
As a reminder, the email is always sent in the correct language. That's the email's link which targets the wrong alternative site version when the send activation email is sent using a script.
- Even if the pending user hasn't a password empty row in the DB, this value is empty when printed;
Ah, this is happening because user queries don’t actually select the password value by default (as it’s generally not needed).
Change this line:
$users = User::find()->status('pending')->limit(null)->all();
to:
$users = User::find()
->addSelect(['users.password'])
->status('pending')
->all();
That should solve this for you.
Thank you @brandonkelly! Now using addSelect(['users.password']) the Craft::$app->getUsers()->sendActivationEmail($user) function redirects to the correct activation link (activateAccountSuccessPath instead of setPasswordPath). First problem solved ✔️
Unfortunately, this activation link still fails to redirect to the correct site version (defined here by the userLanguage custom field).
This happens even if the language is forced within the loop:
foreach ($users as $user) {
Craft::$app->language = $user->userLanguage;
Craft::$app->getUsers()->sendActivationEmail($user);
}
I tried to update the $user->language & $user->locale values to match the correct locale within this loop, but they are in read-only mode only. And the locale can't be force as a parameter in sendActivationEmail().
For reference, these are the setup of the registration form:
<input type="hidden" name="action" value="users/save-user">
<input type="hidden" name="preferredLanguage" value="{{ craft.app.language }}">
<input type="hidden" name="preferredLocale" value="{{ craft.app.language }}">
<input type="hidden" name="fields[userLanguage]" value="{{ craft.app.language }}">
The preferredLanguage & preferredLocale hidden fields are set in order to force the locale upon registration, but it seems they have no effect.
So how to deal with the user locale in that case where there isn't a currently requested site by default (as the controller is called from a cron), where we can't update the user preferred language, and where can't force the notification language?
First problem solved ✔️
Great 🎉
Unfortunately, this activation link still fails to redirect to the correct site version (defined here by the
userLanguagecustom field).
I’m guessing the issue here is that current application language is only used when the user doesn’t have a preferred language selected in their control panel preferences, and only if the message is being sent from the front end:
We just released Craft 3.6.5 with a new EVENT_BEFORE_PREP event to Craft’s mailer, which you can hook into from your module in order to override the message language based on the user’s userLanguage custom field:
use craft\mail\Mailer;
use craft\mail\Message;
use yii\base\Event;
use yii\mail\MailEvent;
Event::on(
Mailer::class,
Mailer::EVENT_BEFORE_PREP,
function (MailEvent $event) {
// Set the message to the userLanguage if there is one
if (
$event->message instanceof Message &&
isset($event->message->variables['user']) &&
$event->message->variables['user']->userLanguage
) {
$event->message->language = $event->message->variables['user']->userLanguage;
}
}
);
You can also safely delete this line:
Craft::$app->language = $user->userLanguage;
Thank you @brandonkelly. But unfortunately, the notification email is still sent using the default site language.
The EVENT_BEFORE_PREP event was added at the bottom of all events in the main _Module.php_ file.
If I add an exit; just after $event->message->language = $event->message->variables['user']->userLanguage;, the script stops. So the language is correctly caught. I can also print $user->userLanguage value just before Craft::$app->getUsers()->sendActivationEmail($user);.
I thought the problem could be from my mailer setting in config/app.php:
'components' => [
'mailer' => function() {
// Get the stored email settings
$settings = craft\helpers\App::mailSettings();
// Override the transport adapter class
$fromEmail = Craft::t('app', TEXT_FROM_EMAIL);
$settings->fromEmail = (empty($fromEmail) || (!empty($fromEmail) && empty(filter_var(str_replace('+', '', $fromEmail), FILTER_VALIDATE_EMAIL)))) ? getenv('SYSTEM_EMAIL_ADDRESS') : $fromEmail;
$settings->fromName = getenv('SENDER_NAME');
$settings->template = getenv('HTML_EMAIL_TEMPLATE');
$settings->transportType = craft\mandrill\MandrillAdapter::class;
$settings->transportSettings = [
'apiKey' => getenv('MANDRILL_API_KEY'),
'subaccount' => getenv('MANDRILL_SUBACCOUNT')
];
// Create a Mailer component config with these settings
$config = craft\helpers\App::mailerConfig($settings);
// Instantiate and return it
return Craft::createObject($config);
},
],
But even if I comment all of this, nothing change and the notification link target is still not localized.
But even if I comment all of this, nothing change and the notification link target is still not localized.
What exactly are you getting, and what are you expecting? The selected language is only going to determine which translated version of the message should be sent (see the language dropdown when editing a system message from Utilities → System Messages). It won’t affect what the URLs get set to.
The mailer setting in config/app.php were an old setup that I ended up deleting everything excepts fromEmail after new tests. Its only purpose is to get a custom fromEmail for system messages, because the sender email address should not be the same for each site versions.
Here is a more detailed scenario, and where it goes wrong:
account_activation system message: content is in NL, _{{ link }} targets NL site version_;sendActivationEmail();account_activation system message: content is in NL, but {{ link }} targets FR site version;Within this controller, when I print the data:
user->userLanguage returns 'nl' and user->preferredLanguage returns 'nl', which are both correct;Craft::$app->getUsers()->getEmailVerifyUrl($user) returns /fr/verifyemail?code={code}&id={code), with wrong site version path (/fr instead of /nl);That is probably why the {{ link }} sent from sendActivationEmail() is not using the correct site version.
Even if I hard-force the language using the new event:
Event::on(
Mailer::class,
Mailer::EVENT_BEFORE_PREP,
function (MailEvent $event) {
$event->message->language = 'nl';
}
);
The system message content is in NL, but {{ link }} still targets FR primary site version.
This is maybe an issue with getVerifyEmailPath() which is not getting the $siteHandle value in that case.
So how to localize the {{ link }}'s variable of account_activation system message in case it's not sent natively from Craft?
Yeah OK, the URL is based on the _site_, not the language.
What you’ll need to do is change the current site, before calling sendActivationEmail(), based on the user’s preferred language.
use yii\helpers\ArrayHelper;
// index all sites by their language
// NOTE: if two 2+ sites have the same language, the last one will be used here
$sitesByLanguage = ArrayHelper::index(Craft::$app->sites->getAllSites(), 'language');
$primarySite = Craft::$app->sites->getPrimarySite();
foreach ($users as $user) {
// do we know the user's preferred language, and do we have a site that is set to it?
if ($user->userLanguage && isset($sitesByLanguage[$user->userLanguage])) {
Craft::$app->sites->setCurrentSite($sitesByLanguage[$user->userLanguage]);
} else {
Craft::$app->sites->setCurrentSite($primarySite);
}
Craft::$app->getUsers()->sendActivationEmail($user);
}
Thank you @brandonkelly, this solution works perfectly in my controller! Problem solved ✔️
But I still have an issue with the forgot_password native system message using the new EVENT_BEFORE_PREP event.
When the email is sent from another locale on front-end and using the native action (users/send-password-reset-email), the link still targets the default primary site language instead of the forced language.
Example of reduced case:
Event::on(
Mailer::class,
Mailer::EVENT_BEFORE_PREP,
function (MailEvent $event) {
if ($event->message instanceof Message) {
Craft::$app->sites->setCurrentSite('de');
$event->message->language = 'de';
}
}
);
Here, while the email content is sent in Deutsch (DE) — the user language, the email {link} targets the Français (FR) primary site version. Any idea why?
Well, again, this is because the URL is going to be based on the current _site_, not the language.
Is there only one “Forgot Password” page, on the primary site? Or does each site have its own? The URL should be based on whichever site was requested by the form.
This page is set from the setPasswordPath config. It redirects to a Single page which is setup with a custom URI for each site (so each language).
Maybe it's a weird or useless case, but I was looking to always force the {{ link }} value based on the user preferred language, instead of the site where it is requested. In that case, the EVENT_BEFORE_PREP strangely doesn't work.
In 90% of our projects, we only have one site group where versions are just translated version of the primary site.
setPasswordPath is just a URI path though. It will be appended to whatever the current site’s base URL is. So again, where is your “Forgot Password” page? Is there just one, or do you have one for each site?
I'm sorry if I misunderstood or misexplained something.
If setPasswordPath is set like this:
'setPasswordPath' => 'actions/users/set-password'
It targets the actions/users/set-password.twig template (which in my case extends the login page template). This is correct.
I also tried like this:
'setPasswordPath' => function() {
return Entry::find()->section('myAccount')->one()->uri ?? null;
}
Where myAccount is a login page: it is a Single available in each site version, so for each language. Its content is localized.
But both are failing to load the expected site version, as the link in the email always targets the current site in use (I can see the /fr prefix in the URI, instead of /de when sent to a user set with DE preference or when EVENT_BEFORE_PREP language is forced set to de).
So the URI for setPasswordPath may be the correct one, but the wrong version is served.
If I disable the setPasswordPath setting, I'm always forwarded to /fr/setpassword for a DE user if the password reset is asked from the /fr site version.
Despite this, the text of this email use the correct language version (i.e. in DE). So the issue only affects {{ link }}.
I still need to know where the “Forgot Password” page actually lives though…
Sorry if I'm still not clear :
The “Forgot Password” lives within the login page (Entry::find()->section('myAccount')->one()) which is a Single.
This page shows the reset password form in a modal when it detects the code & id query parameters.
This is the only page, and it is setup and enabled for each (translated) site version (FR, DE & EN).
And I take it that each site has its own Login page?
Yes, each site has its own Login page. The Login page is the myAccount Single, which is setup and enabled for each site version.
How is the modal submitting the password reset form?
Using an AJAX submitted form: $.post({ url: '/' + $('[name="language"]', $form).val(), …, and these tags within the form: <input type="hidden" name="action" value="users/send-password-reset-email"> + <input type="hidden" name="language" value="{{ craft.app.language }}">.
Despite the forced language param, the email content is sent using the correct user language, but the {{ link }} variable matches language instead of the user preferred language (which is use for the email content).
If I remove the language param, the email content + {{ link }} are sent using the current language, not the user language.
Are the sites’ base URLs set identically to their selected languages? (For example, is the Dutch site’s base URL set to /nl, just as its language code is nl?)
Yes they are.
Hm… and you’ve verified that the Ajax request is going to the expected URLs?
I can see in the Chrome DevTools that the _Request URL_ is going to https://www.example.com/siteHandle (where siteHandle is something like en or nl) with an action to users/send-password-reset-email.