Skip to content

Commit

Permalink
Merge pull request #384 from silinternational/feature/IDP-1266-part-1…
Browse files Browse the repository at this point in the history
…-tracking-emails-to-non-users

[IDP-1266, part 1] Track emails to non-users
  • Loading branch information
forevermatt authored Nov 12, 2024
2 parents e674e3f + 2652b0d commit c46958c
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 54 deletions.
88 changes: 76 additions & 12 deletions application/common/components/Emailer.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ protected function assertConfigIsValid()
/**
* Use the email service to send an email.
*
* WARNING:
* You probably shouldn't be calling this directly. Instead, call the
* `sendMessageTo()` method so that the sending of this email will be
* logged.
*
* @param string $toAddress The recipient's email address.
* @param string $subject The subject.
* @param string $htmlBody The email body (as HTML).
Expand Down Expand Up @@ -261,6 +266,13 @@ protected function getSubjectForMessage(string $messageType, array $data): strin
{
$subject = $this->subjects[$messageType] ?? '';

if (empty($subject)) {
\Yii::error(sprintf(
'No subject known for %s email messages.',
$messageType
));
}

foreach ($data as $key => $value) {
if (is_scalar($value)) {
$subject = str_replace('{' . $key . '}', $value, $subject);
Expand Down Expand Up @@ -313,6 +325,7 @@ public function init()
EmailLog::MESSAGE_TYPE_MFA_RATE_LIMIT => $this->subjectForMfaRateLimit,
EmailLog::MESSAGE_TYPE_PASSWORD_CHANGED => $this->subjectForPasswordChanged,
EmailLog::MESSAGE_TYPE_WELCOME => $this->subjectForWelcome,
EmailLog::MESSAGE_TYPE_ABANDONED_USERS => $this->subjectForAbandonedUsers,
EmailLog::MESSAGE_TYPE_EXT_GROUP_SYNC_ERRORS => $this->subjectForExtGroupSyncErrors,
EmailLog::MESSAGE_TYPE_GET_BACKUP_CODES => $this->subjectForGetBackupCodes,
EmailLog::MESSAGE_TYPE_REFRESH_BACKUP_CODES => $this->subjectForRefreshBackupCodes,
Expand Down Expand Up @@ -341,7 +354,7 @@ public function init()
}

/**
* Send the specified type of message to the given User.
* Send the specified type of message to the given User (or non-User address).
*
* @param string $messageType The message type. Must be one of the
* EmailLog::MESSAGE_TYPE_* values.
Expand Down Expand Up @@ -385,7 +398,9 @@ public function sendMessageTo(
$this->email($toAddress, $subject, $htmlBody, strip_tags($htmlBody), $ccAddress, $bccAddress, $delaySeconds);

if ($user !== null) {
EmailLog::logMessage($messageType, $user->id);
EmailLog::logMessageToUser($messageType, $user->id);
} else {
EmailLog::logMessageToNonUser($messageType, $toAddress);
}
}

Expand All @@ -410,14 +425,13 @@ public function sendDelayedMfaRelatedEmails()
}

/**
*
* Whether the user has already been sent this type of email in the last X days
*
* @param int $userId
* @param string $messageType
* @return bool
*/
public function hasReceivedMessageRecently($userId, string $messageType)
public function hasUserReceivedMessageRecently(int $userId, string $messageType): bool
{
$latestEmail = EmailLog::find()->where(['user_id' => $userId, 'message_type' => $messageType])
->orderBy('sent_utc DESC')->one();
Expand All @@ -428,6 +442,49 @@ public function hasReceivedMessageRecently($userId, string $messageType)
return MySqlDateTime::dateIsRecent($latestEmail->sent_utc, $this->emailRepeatDelayDays);
}

/**
* Whether the non-user address has already been sent this type of email in the last X days
*
* @param string $emailAddress
* @param string $messageType
* @return bool
*/
public function hasNonUserReceivedMessageRecently(string $emailAddress, string $messageType): bool
{
$latestEmail = EmailLog::find()->where([
'message_type' => $messageType,
'non_user_address' => $emailAddress,
'user_id' => null,
])->orderBy(
'sent_utc DESC'
)->one();
if (empty($latestEmail)) {
return false;
}

return MySqlDateTime::dateIsRecent($latestEmail->sent_utc, $this->emailRepeatDelayDays);
}

/**
* Whether we should send an abandoned-users message to HR.
*
* @return bool
*/
public function shouldSendAbandonedUsersMessage(): bool
{
if (empty($this->hrNotificationsEmail)) {
return false;
}

$haveSentAbandonedUsersEmailRecently = $this->hasNonUserReceivedMessageRecently(
$this->hrNotificationsEmail,
EmailLog::MESSAGE_TYPE_ABANDONED_USERS
);

return !$haveSentAbandonedUsersEmailRecently;
}


/**
* Whether we should send an invite message to the given User.
*
Expand Down Expand Up @@ -486,7 +543,7 @@ public function shouldSendGetBackupCodesMessageTo($user)
return $this->sendGetBackupCodesEmails
&& $user->getVerifiedMfaOptionsCount() === 1
&& !$user->hasMfaBackupCodes()
&& !$this->hasReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_GET_BACKUP_CODES);
&& !$this->hasUserReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_GET_BACKUP_CODES);
}

/**
Expand All @@ -513,7 +570,7 @@ public function shouldSendLostSecurityKeyMessageTo($user)
return false;
}

if ($this->hasReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_LOST_SECURITY_KEY)) {
if ($this->hasUserReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_LOST_SECURITY_KEY)) {
return false;
}

Expand Down Expand Up @@ -652,7 +709,7 @@ public function sendMethodReminderEmails()
foreach ($methods as $method) {
$user = $method->user;
if (!MySqlDateTime::dateIsRecent($method->created, 3) &&
!$this->hasReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_METHOD_REMINDER)
!$this->hasUserReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_METHOD_REMINDER)
) {
$this->sendMessageTo(
EmailLog::MESSAGE_TYPE_METHOD_REMINDER,
Expand Down Expand Up @@ -698,7 +755,7 @@ public function sendPasswordExpiringEmails()
$passwordExpiry = strtotime($userPassword->getExpiresOn());
if ($passwordExpiry < strtotime(self::PASSWORD_EXPIRING_CUTOFF)
&& !($passwordExpiry < time())
&& !$this->hasReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRING)
&& !$this->hasUserReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRING)
) {
$this->sendMessageTo(EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRING, $user);
$numEmailsSent++;
Expand Down Expand Up @@ -740,7 +797,7 @@ public function sendPasswordExpiredEmails()
$passwordExpiry = strtotime($userPassword->getExpiresOn());
if ($passwordExpiry < time()
&& $passwordExpiry > strtotime(self::PASSWORD_EXPIRED_CUTOFF)
&& !$this->hasReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRED)
&& !$this->hasUserReceivedMessageRecently($user->id, EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRED)
) {
$this->sendMessageTo(EmailLog::MESSAGE_TYPE_PASSWORD_EXPIRED, $user);
$numEmailsSent++;
Expand Down Expand Up @@ -801,9 +858,16 @@ public function sendAbandonedUsersEmail()
$dataForEmail['users'] = User::getAbandonedUsers();

if (!empty($dataForEmail['users'])) {
$htmlBody = \Yii::$app->view->render('@common/mail/abandoned-users.html.php', $dataForEmail);

$this->email($this->hrNotificationsEmail, $this->subjectForAbandonedUsers, $htmlBody, strip_tags($htmlBody));
if ($this->shouldSendAbandonedUsersMessage()) {
$this->sendMessageTo(
EmailLog::MESSAGE_TYPE_ABANDONED_USERS,
null,
ArrayHelper::merge(
$dataForEmail,
['toAddress' => $this->hrNotificationsEmail]
)
);
}
}
}
}
Expand Down
55 changes: 32 additions & 23 deletions application/common/models/EmailLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace common\models;

use common\helpers\MySqlDateTime;
use ReflectionClass;
use Yii;
use yii\helpers\ArrayHelper;

Expand All @@ -17,6 +18,7 @@ class EmailLog extends EmailLogBase
public const MESSAGE_TYPE_MFA_RATE_LIMIT = 'mfa-rate-limit';
public const MESSAGE_TYPE_PASSWORD_CHANGED = 'password-changed';
public const MESSAGE_TYPE_WELCOME = 'welcome';
public const MESSAGE_TYPE_ABANDONED_USERS = 'abandoned-users';
public const MESSAGE_TYPE_EXT_GROUP_SYNC_ERRORS = 'ext-group-sync-errors';
public const MESSAGE_TYPE_GET_BACKUP_CODES = 'get-backup-codes';
public const MESSAGE_TYPE_REFRESH_BACKUP_CODES = 'refresh-backup-codes';
Expand All @@ -41,35 +43,23 @@ public function attributeLabels()
{
return ArrayHelper::merge(parent::attributeLabels(), [
'sent_utc' => Yii::t('app', 'Sent (UTC)'),
'non_user_address' => Yii::t('app', 'Non-User Address'),
]);
}

public static function getMessageTypes()
public static function getMessageTypes(): array
{
return [
self::MESSAGE_TYPE_INVITE,
self::MESSAGE_TYPE_MFA_RATE_LIMIT,
self::MESSAGE_TYPE_PASSWORD_CHANGED,
self::MESSAGE_TYPE_WELCOME,
self::MESSAGE_TYPE_GET_BACKUP_CODES,
self::MESSAGE_TYPE_REFRESH_BACKUP_CODES,
self::MESSAGE_TYPE_LOST_SECURITY_KEY,
self::MESSAGE_TYPE_MFA_OPTION_ADDED,
self::MESSAGE_TYPE_MFA_OPTION_REMOVED,
self::MESSAGE_TYPE_MFA_ENABLED,
self::MESSAGE_TYPE_MFA_DISABLED,
self::MESSAGE_TYPE_METHOD_VERIFY,
self::MESSAGE_TYPE_METHOD_REMINDER,
self::MESSAGE_TYPE_METHOD_PURGED,
self::MESSAGE_TYPE_MFA_MANAGER,
self::MESSAGE_TYPE_MFA_MANAGER_HELP,
self::MESSAGE_TYPE_PASSWORD_EXPIRING,
self::MESSAGE_TYPE_PASSWORD_EXPIRED,
self::MESSAGE_TYPE_PASSWORD_PWNED,
];
$reflectionClass = new ReflectionClass(__CLASS__);
$messageTypes = [];
foreach ($reflectionClass->getConstants() as $name => $value) {
if (str_starts_with($name, 'MESSAGE_TYPE_')) {
$messageTypes[] = $value;
}
}
return $messageTypes;
}

public static function logMessage(string $messageType, $userId)
public static function logMessageToUser(string $messageType, $userId)
{
$emailLog = new EmailLog([
'user_id' => $userId,
Expand All @@ -88,6 +78,25 @@ public static function logMessage(string $messageType, $userId)
}
}

public static function logMessageToNonUser(string $messageType, string $emailAddress)
{
$emailLog = new EmailLog([
'non_user_address' => $emailAddress,
'message_type' => $messageType,
]);

if (!$emailLog->save()) {
$errorMessage = sprintf(
'Failed to log %s email to %s: %s',
var_export($messageType, true),
var_export($emailAddress, true),
\json_encode($emailLog->getFirstErrors(), JSON_PRETTY_PRINT)
);
\Yii::warning($errorMessage);
throw new \Exception($errorMessage, 1730909932);
}
}

/**
* {@inheritdoc}
*/
Expand Down
7 changes: 5 additions & 2 deletions application/common/models/EmailLogBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
* This is the model class for table "email_log".
*
* @property int $id
* @property int $user_id
* @property int|null $user_id
* @property string|null $message_type
* @property string $sent_utc
* @property string|null $non_user_address
*
* @property User $user
*/
Expand All @@ -30,10 +31,11 @@ public static function tableName()
public function rules()
{
return [
[['user_id', 'sent_utc'], 'required'],
[['user_id'], 'integer'],
[['message_type'], 'string'],
[['sent_utc'], 'required'],
[['sent_utc'], 'safe'],
[['non_user_address'], 'string', 'max' => 255],
[['user_id'], 'exist', 'skipOnError' => true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']],
];
}
Expand All @@ -48,6 +50,7 @@ public function attributeLabels()
'user_id' => Yii::t('app', 'User ID'),
'message_type' => Yii::t('app', 'Message Type'),
'sent_utc' => Yii::t('app', 'Sent Utc'),
'non_user_address' => Yii::t('app', 'Non User Address'),
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

use yii\db\Migration;

/**
* Class m241029_200547_add_email_log_message_types
*/
class m241029_200547_add_email_log_message_types extends Migration
{
public function safeUp()
{
$this->alterColumn(
'{{email_log}}',
'message_type',
"enum('invite','welcome','mfa-rate-limit','password-changed','get-backup-codes','refresh-backup-codes','lost-security-key','mfa-option-added','mfa-option-removed','mfa-enabled','mfa-disabled','method-verify','mfa-manager','mfa-manager-help','method-reminder','method-purged','password-expiring','password-expired','password-pwned','ext-group-sync-errors','abandoned-users') null"
);
}

public function safeDown()
{
$this->alterColumn(
'{{email_log}}',
'message_type',
"enum('invite','welcome','mfa-rate-limit','password-changed','get-backup-codes','refresh-backup-codes','lost-security-key','mfa-option-added','mfa-option-removed','mfa-enabled','mfa-disabled','method-verify','mfa-manager','mfa-manager-help','method-reminder','method-purged','password-expiring','password-expired','password-pwned') null"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

use yii\db\Migration;

/**
* Class m241106_155829_enable_logging_non_user_emails
*/
class m241106_155829_enable_logging_non_user_emails extends Migration
{
public function safeUp()
{
$this->addColumn(
'{{email_log}}',
'non_user_address',
$this->string()->null()
);
$this->alterColumn(
'{{email_log}}',
'user_id',
$this->integer()->null()
);
}

public function safeDown()
{
$this->delete(
'{{email_log}}',
[
'user_id' => null,
]
);
$this->alterColumn(
'{{email_log}}',
'user_id',
$this->integer()->notNull()
);
$this->dropColumn(
'{{email_log}}',
'non_user_address'
);
}
}
Loading

0 comments on commit c46958c

Please sign in to comment.