Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 74 additions & 42 deletions app/helper/notification.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,23 @@ public function issue_comment(int $issue_id, int $comment_id): void

// Get recipient list and remove current user
$recipients = $this->_issue_watchers($issue_id);
$recipients = array_diff($recipients, [$comment->user_email]);
$recipients = array_filter($recipients, fn($r) => $r['email'] !== $comment->user_email);

// Render message body
// Set template variables
$f3->set("issue", $issue);
$f3->set("comment", $comment);
$f3->set("previewText", $comment->text);
$text = $this->_render("notification/comment.txt");
$body = $this->_render("notification/comment.html");

$subject = "[#{$issue->id}] - New comment on {$issue->name}";

// Send to recipients
// Send to recipients in their preferred language
foreach ($recipients as $recipient) {
$this->utf8mail($recipient, $subject, $body, $text);
$log->write("Sent comment notification to: " . $recipient);
$lang = $this->_set_language($recipient['language']);
$text = $this->_render("notification/comment.txt");
$body = $this->_render("notification/comment.html");
$f3->set("LANGUAGE", $lang);
Comment thread
Alanaktion marked this conversation as resolved.
Comment thread
Alanaktion marked this conversation as resolved.
$this->utf8mail($recipient['email'], $subject, $body, $text);
$log->write("Sent comment notification to: " . $recipient['email']);
}
}
}
Expand Down Expand Up @@ -188,13 +190,11 @@ public function issue_update(int $issue_id, int $update_id): ?bool

// Get recipient list and remove update user
$recipients = $this->_issue_watchers($issue_id);
$recipients = array_diff($recipients, [$update->user_email]);
$recipients = array_filter($recipients, fn($r) => $r['email'] !== $update->user_email);

// Render message body
// Set template variables
$f3->set("issue", $issue);
$f3->set("update", $update);
$text = $this->_render("notification/update.txt");
$body = $this->_render("notification/update.html");

$changes->load(["issue_update_id = ? AND `field` = 'closed_date' AND old_value = '' and new_value != ''", $update->id]);
if ($changes && $changes->id) {
Expand All @@ -203,10 +203,14 @@ public function issue_update(int $issue_id, int $update_id): ?bool
$subject = "[#{$issue->id}] - {$issue->name} updated";
}

// Send to recipients
// Send to recipients in their preferred language
foreach ($recipients as $recipient) {
$this->utf8mail($recipient, $subject, $body, $text);
$log->write("Sent update notification to: " . $recipient);
$lang = $this->_set_language($recipient['language']);
$text = $this->_render("notification/update.txt");
$body = $this->_render("notification/update.html");
$f3->set("LANGUAGE", $lang);
$this->utf8mail($recipient['email'], $subject, $body, $text);
$log->write("Sent update notification to: " . $recipient['email']);
}
}

Expand Down Expand Up @@ -240,21 +244,22 @@ public function issue_create(int $issue_id): void
$user = new \Model\User();
$user->load($issue->author_id);
if ($user->option('disable_self_notifications')) {
$recipients = array_diff($recipients, [$user->email]);
$recipients = array_filter($recipients, fn($r) => $r['email'] !== $user->email);
}

// Render message body
// Set template variables
$f3->set("issue", $issue);

$text = $this->_render("notification/new.txt");
$body = $this->_render("notification/new.html");

$subject = "[#{$issue->id}] - {$issue->name} created by {$issue->author_name}";

// Send to recipients
// Send to recipients in their preferred language
foreach ($recipients as $recipient) {
$this->utf8mail($recipient, $subject, $body, $text);
$log->write("Sent create notification to: " . $recipient);
$lang = $this->_set_language($recipient['language']);
$text = $this->_render("notification/new.txt");
$body = $this->_render("notification/new.html");
$f3->set("LANGUAGE", $lang);
$this->utf8mail($recipient['email'], $subject, $body, $text);
$log->write("Sent create notification to: " . $recipient['email']);
}
}
}
Expand Down Expand Up @@ -288,21 +293,23 @@ public function issue_file(int $issue_id, int $file_id): void

// Get recipient list and remove current user
$recipients = $this->_issue_watchers($issue_id);
$recipients = array_diff($recipients, [$file->user_email]);
$recipients = array_filter($recipients, fn($r) => $r['email'] !== $file->user_email);

// Render message body
// Set template variables
$f3->set("issue", $issue);
$f3->set("file", $file);
$f3->set("previewText", $file->filename);
$text = $this->_render("notification/file.txt");
$body = $this->_render("notification/file.html");

$subject = "[#{$issue->id}] - {$file->user_name} attached a file to {$issue->name}";

// Send to recipients
// Send to recipients in their preferred language
foreach ($recipients as $recipient) {
$this->utf8mail($recipient, $subject, $body, $text);
$log->write("Sent file notification to: " . $recipient);
$lang = $this->_set_language($recipient['language']);
$text = $this->_render("notification/file.txt");
$body = $this->_render("notification/file.html");
$f3->set("LANGUAGE", $lang);
$this->utf8mail($recipient['email'], $subject, $body, $text);
$log->write("Sent file notification to: " . $recipient['email']);
}
}
}
Expand All @@ -321,10 +328,12 @@ public function user_reset(int $user_id, string $token): void
throw new \Exception("User does not exist.");
}

// Render message body
// Render message body in the user's preferred language
$lang = $this->_set_language($user->language);
$f3->set("token", $token);
$text = $this->_render("notification/user_reset.txt");
$body = $this->_render("notification/user_reset.html");
$f3->set("LANGUAGE", $lang);

// Send email to user
$subject = "Reset your password - " . $f3->get("site.name");
Expand All @@ -348,53 +357,76 @@ public function user_due_issues(\Model\User $user, array $due, array $overdue):

$f3->set("previewText", $preview);
$subject = "Due Today - " . $f3->get("site.name");
$lang = $this->_set_language($user->language);
$text = $this->_render("notification/user_due_issues.txt");
$body = $this->_render("notification/user_due_issues.html");
$f3->set("LANGUAGE", $lang);
return $this->utf8mail($user->email, $subject, $body, $text);
}

return false;
}

/**
* Get array of email addresses of all watchers on an issue
* Get array of watchers (email and language) for an issue
*/
protected function _issue_watchers(int $issue_id): array
{
$db = \Base::instance()->get("db.instance");
$recipients = [];

// Add issue author and owner
$result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.author_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id);
$result = $db->exec("SELECT u.email, u.language FROM issue i INNER JOIN `user` u on i.author_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id);
if (!empty($result[0]["email"])) {
$recipients[] = $result[0]["email"];
$recipients[] = ['email' => $result[0]["email"], 'language' => $result[0]["language"]];
}


$result = $db->exec("SELECT u.email FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id);
$result = $db->exec("SELECT u.email, u.language FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id);
if (!empty($result[0]["email"])) {
$recipients[] = $result[0]["email"];
$recipients[] = ['email' => $result[0]["email"], 'language' => $result[0]["language"]];
}

// Add whole group
$result = $db->exec("SELECT u.role, u.id FROM issue i INNER JOIN `user` u on i.owner_id = u.id WHERE u.deleted_date IS NULL AND i.id = ?", $issue_id);
if ($result && $result[0]["role"] == 'group') {
$group_users = $db->exec("SELECT g.user_email FROM user_group_user g WHERE g.deleted_date IS NULL AND g.group_id = ?", $result[0]["id"]);
$group_users = $db->exec("SELECT u.email, u.language FROM user_group g INNER JOIN `user` u ON g.user_id = u.id WHERE u.deleted_date IS NULL AND g.group_id = ?", $result[0]["id"]);
foreach ($group_users as $group_user) {
if (!empty($group_user["user_email"])) {
$recipients[] = $group_user["user_email"];
if (!empty($group_user["email"])) {
$recipients[] = ['email' => $group_user["email"], 'language' => $group_user["language"]];
}
}
}

// Add watchers
$watchers = $db->exec("SELECT u.email FROM issue_watcher w INNER JOIN `user` u ON w.user_id = u.id WHERE u.deleted_date IS NULL AND issue_id = ?", $issue_id);
$watchers = $db->exec("SELECT u.email, u.language FROM issue_watcher w INNER JOIN `user` u ON w.user_id = u.id WHERE u.deleted_date IS NULL AND issue_id = ?", $issue_id);
foreach ($watchers as $watcher) {
$recipients[] = $watcher["email"];
$recipients[] = ['email' => $watcher["email"], 'language' => $watcher["language"]];
}

// Remove duplicate users
return array_unique($recipients);
// Remove duplicate users, keeping first occurrence
$seen = [];
$unique = [];
foreach ($recipients as $recipient) {
if (!in_array($recipient['email'], $seen)) {
$seen[] = $recipient['email'];
Comment thread
Alanaktion marked this conversation as resolved.
Outdated
$unique[] = $recipient;
}
}
return $unique;
}

/**
* Temporarily set the app language, returning the previous value for restoration
*/
protected function _set_language(?string $language): string
{
$f3 = \Base::instance();
$original = (string) $f3->get("LANGUAGE");
if ($language) {
$f3->set("LANGUAGE", $language);
}
Comment thread
Alanaktion marked this conversation as resolved.
Outdated
return $original;

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_set_language() always casts the previous LANGUAGE to a string and callers always restore via set("LANGUAGE", $previous). If LANGUAGE was previously unset/null, this forces it to "" on restore, which can change how locale fallback behaves. Consider tracking whether LANGUAGE originally existed and using clear("LANGUAGE") (or not restoring) when it didn’t.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

If the original value was null, it should be null when changing back. Don't skip restoring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 348ff36. Changed the return type of _set_language() from string to ?string and removed the (string) cast when reading the original value, so a null LANGUAGE is preserved and restored as null rather than "".

}

/**
Expand Down