Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
84 changes: 84 additions & 0 deletions src/ChurchCRM/Emails/notifications/BirthdayEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace ChurchCRM\Emails\notifications;

use ChurchCRM\dto\SystemConfig;
use ChurchCRM\Emails\BaseEmail;
use ChurchCRM\model\ChurchCRM\Person;

class BirthdayEmail extends BaseEmail
{
private Person $person;

/**
* @param string[] $toAddresses
*/
public function __construct(array $toAddresses, Person $person)
{
$this->person = $person;
parent::__construct($toAddresses);
$this->mail->Subject = SystemConfig::getValue('sChurchName') . ': ' . $this->getSubSubject();
$this->mail->isHTML(true);
$this->mail->msgHTML($this->buildMessage());
}

protected function getSubSubject(): string
{
return gettext('Happy Birthday') . ', ' . $this->person->getFullName() . '!';
}

public function getTokens(): array
{
$ageString = $this->person->getAge();
$age = ($ageString !== null && ctype_digit($ageString)) ? (int) $ageString : null;

$body = gettext('Happy Birthday') . ', ' . $this->person->getFullName() . '!';
$body .= "\n\n";
if ($age !== null) {
$body .= sprintf(gettext('Wishing you a wonderful %d%s birthday!'), $age, $this->getOrdinalSuffix($age));
} else {
$body .= gettext('Wishing you a wonderful birthday!');
}
$body .= "\n\n" . SystemConfig::getValue('sChurchName') . ' ' . gettext('is thinking of you today.');

$myTokens = [
'toName' => $this->person->getFullName(),
'body' => $body,
];

return array_merge($this->getCommonTokens(), $myTokens);
}

private function getOrdinalSuffix(int $number): string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Automated PR Reviewthis comment was posted by the pr-reviewer bot, not by a human or the implementer agent

[LOW] Ordinal suffixes are hardcoded English — cannot be translated for non-English locales

getOrdinalSuffix() always returns one of 'st', 'nd', 'rd', 'th'. This value is substituted as the %s argument in sprintf(gettext('Wishing you a wonderful %d%s birthday!'), $age, ...). While the surrounding format string is wrapped in gettext() and can be translated, the %s slot is always filled by the hardcoded English suffix — translators cannot supply a locale-appropriate ordinal indicator.

Churches using French ("ème"), German (trailing period), Spanish (gender-dependent), or other non-English locales will receive grammatically wrong or meaningless birthday text.

Suggested fix: remove the ordinal from the translatable string and pass the full age as a plain integer, for example:

// Before (broken for i18n):
$body .= sprintf(gettext('Wishing you a wonderful %d%s birthday!'), $age, $this->getOrdinalSuffix($age));

// After (translators control the full sentence):
$body .= sprintf(gettext('Wishing you a wonderful %d year birthday!'), $age);

Or simply omit the age from this sentence entirely and let gettext cover the whole thing without a numeric placeholder.

{
if ($number % 100 >= 11 && $number % 100 <= 13) {
return 'th';
}

switch ($number % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}

protected function getFullURL(): string
{
return '';
}

protected function getButtonText(): string
{
return '';
}

protected function getPreheader(): string
{
return $this->getSubSubject();
}
}
68 changes: 68 additions & 0 deletions src/ChurchCRM/Service/BirthdayEmailService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace ChurchCRM\Service;

use ChurchCRM\dto\SystemConfig;
use ChurchCRM\Emails\notifications\BirthdayEmail;
use ChurchCRM\model\ChurchCRM\PersonQuery;
use ChurchCRM\Utils\DateTimeUtils;
use ChurchCRM\Utils\LoggerUtils;

class BirthdayEmailService
{
/**
* Sends birthday greeting emails to everyone whose birthday is today,
* if the feature is enabled and it has not already run today.
*
* Safe to call multiple times per day (idempotent) and safe to call
* even when the feature is disabled (no-ops immediately).
*/
public static function run(): void
{
if (!SystemConfig::getBooleanValue('bEnableBirthdayEmails')) {
return;
}

$tz = DateTimeUtils::getConfiguredTimezone();
$today = new \DateTime('now', $tz);
$todayString = $today->format('Y-m-d');

if (SystemConfig::getValue('sLastBirthdayEmailRunDate') === $todayString) {
// Already ran today; avoid duplicate sends.
return;
}

// Persist before sending so a crash cannot result in duplicate emails.
SystemConfig::setValue('sLastBirthdayEmailRunDate', $todayString);

$logger = LoggerUtils::getAppLogger();
$sentCount = 0;
$skippedCount = 0;

$people = PersonQuery::create()
->filterByBirthMonth((int) $today->format('n'))
->filterByBirthDay((int) $today->format('j'))
->find();

foreach ($people as $person) {
$email = $person->getEmail();
if (empty($email)) {
$skippedCount++;
continue;
}

try {
$birthdayEmail = new BirthdayEmail([$email], $person);
if ($birthdayEmail->send()) {
$sentCount++;
} else {
$logger?->warning('BirthdayEmailService: failed to send to person ID ' . $person->getId() . ': ' . $birthdayEmail->getError());
}
} catch (\Exception $e) {
$logger?->warning('BirthdayEmailService: exception sending to person ID ' . $person->getId() . ': ' . $e->getMessage());
}
}

$logger?->info("BirthdayEmailService: sent {$sentCount} birthday email(s), skipped {$skippedCount} (no email on file)");
}
}
2 changes: 2 additions & 0 deletions src/ChurchCRM/Service/SystemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ public static function runTimerJobs(): void
{
LoggerUtils::getAppLogger()->debug('Starting background job processing');

BirthdayEmailService::run();

// Fire the CRON_RUN hook so plugins can register scheduled tasks.
// Each active plugin registers a handler on Hooks::CRON_RUN in boot().
// HookManager catches and logs any per-plugin errors so one failing
Expand Down
4 changes: 3 additions & 1 deletion src/ChurchCRM/dto/SystemConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ private static function buildConfigs(): array
'bEnabledEvents' => new ConfigItem('bEnabledEvents', 'boolean', '1', gettext('Show or hide the Events section in the main navigation menu')),
'bEnabledFundraiser' => new ConfigItem('bEnabledFundraiser', 'boolean', '1', gettext('Enable Fundraiser menu.')),
'bEnabledEmail' => new ConfigItem('bEnabledEmail', 'boolean', '1', gettext('Enable email sending from ChurchCRM. Required for password reset, notifications, and email links.')),
'bEnableBirthdayEmails' => new ConfigItem('bEnableBirthdayEmails', 'boolean', '0', gettext('Automatically send a birthday greeting email to people on their birthday')),
'sLastBirthdayEmailRunDate' => new ConfigItem('sLastBirthdayEmailRunDate', 'text', '', gettext('Internal: last date birthday emails were sent (YYYY-MM-DD). Do not edit manually.')),
'sEmailPreheader' => new ConfigItem('sEmailPreheader', 'text', '', gettext('Optional short summary shown as inbox preview text beside the subject line. Leave blank to let the email client auto-generate from the body. Per-email types (password reset, new member, verification) set their own preheader; this is a fallback.')),
'sGreeterCustomMsg1' => new ConfigItem('sGreeterCustomMsg1', 'text', '', gettext('Custom message for church greeter email 1, max 255 characters')),
'sGreeterCustomMsg2' => new ConfigItem('sGreeterCustomMsg2', 'text', '', gettext('Custom message for church greeter email 2, max 255 characters')),
Expand All @@ -283,7 +285,7 @@ private static function buildConfigs(): array
private static function buildCategories(): array
{
return [
gettext('New Members & Greeting') => ['sNewPersonNotificationRecipientIDs', 'IncludeDataInNewPersonNotifications', 'sGreeterCustomMsg1', 'sGreeterCustomMsg2'],
gettext('New Members & Greeting') => ['sNewPersonNotificationRecipientIDs', 'IncludeDataInNewPersonNotifications', 'sGreeterCustomMsg1', 'sGreeterCustomMsg2', 'bEnableBirthdayEmails'],
gettext('People') => ['sDirClassifications', 'iPersonNameStyle', 'iPersonInitialStyle', 'bHidePersonAddress', 'bHideFriendDate', 'bHideWeddingDate', 'bForceUppercaseZip', 'sInactiveClassification'],
gettext('Families') => ['sDirRoleHead', 'sDirRoleSpouse', 'sDirRoleChild', 'sDefaultCity', 'sDefaultState', 'sDefaultZip', 'sDefaultCountry', 'bHideFamilyNewsletter'],
gettext('Financial Settings') => ['bEnabledFinance', 'bEnabledFundraiser', 'sDepositSlipType', 'iChecksPerDepositForm', 'bDisplayBillCounts', 'bUseScannedChecks', 'bEnableNonDeductible', 'iFYMonth', 'bUseDonationEnvelopes', 'aFinanceQueries', 'sCurrencySymbol', 'sCurrencyPosition', 'sThousandsSeparator', 'sDecimalSeparator'],
Expand Down
11 changes: 10 additions & 1 deletion src/SystemSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function categoryId(string $category): string {
</div>
</div>


<form name="SystemSettingsForm" method="post" action="SystemSettings.php">
<div class="row g-4">

Expand Down Expand Up @@ -214,11 +215,19 @@ function categoryId(string $category): string {
<?php elseif ($setting->getType() === 'number' || $setting->getType() === 'date') : ?>
<input type="text" maxlength="15" class="form-control" name="new_value[<?= $setting->getName() ?>]" value="<?= InputUtils::escapeAttribute($setting->getValue()) ?>">

<?php elseif ($setting->getType() === 'boolean') : ?>
<?php elseif ($setting->getType() === 'boolean') : ?>
<select name="new_value[<?= $setting->getName() ?>]" class="form-select choiceSelectBox">
<option value="0" <?= !$setting->getValue() ? 'selected' : '' ?>><?= gettext('False') ?></option>
<option value="1" <?= $setting->getValue() ? 'selected' : '' ?>><?= gettext('True') ?></option>
</select>
<?php if ($setting->getName() === 'bEnableBirthdayEmails') : ?>
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="sendTestBirthdayEmail">
<i class="fa-solid fa-paper-plane me-1"></i><?= gettext('Send Test Birthday Email to Myself') ?>
</button>
<span id="birthdayEmailTestResult" class="ms-2 small"></span>
</div>
<?php endif; ?>

<?php elseif ($setting->getType() === 'json') : ?>
<input type="hidden" name="new_value[<?= $setting->getName() ?>]" value="<?= InputUtils::escapeAttribute($setting->getValue()) ?>">
Expand Down
1 change: 1 addition & 0 deletions src/admin/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require __DIR__ . '/routes/get-started.php';
require __DIR__ . '/routes/api/demo.php';
require __DIR__ . '/routes/api/database.php';
require __DIR__ . '/routes/api/birthday-emails.php';
require __DIR__ . '/routes/api/orphaned-files.php';
require __DIR__ . '/routes/api/options.php';
require __DIR__ . '/routes/api/system/system-config.php';
Expand Down
46 changes: 46 additions & 0 deletions src/admin/routes/api/birthday-emails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use ChurchCRM\Authentication\AuthenticationManager;
use ChurchCRM\Emails\notifications\BirthdayEmail;
use ChurchCRM\Slim\SlimUtils;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;

$app->group('/api/admin/birthday-emails', function (RouteCollectorProxy $group): void {

/**
* @OA\Post(
* path="/api/admin/birthday-emails/test",
* summary="Send a test birthday email to the current admin (Admin role required)",
* tags={"Admin"},
* security={{"ApiKeyAuth":{}}},
* @OA\Response(response=200, description="Test email sent"),
* @OA\Response(response=400, description="Current admin has no email on file"),
* @OA\Response(response=500, description="Test email failed to send")
* )
*/
$group->post('/test', function (Request $request, Response $response, array $args): Response {
$person = AuthenticationManager::getCurrentUser()->getPerson();
$email = $person->getEmail();

if (empty($email)) {
return SlimUtils::renderErrorJSON($response, gettext('Your account has no email address on file. Add one to your profile to send a test.'), [], 400);
}

try {
$birthdayEmail = new BirthdayEmail([$email], $person);
if ($birthdayEmail->send()) {
return SlimUtils::renderJSON($response, [
'success' => true,
'message' => gettext('Test email sent to') . ' ' . $email,
]);
}

return SlimUtils::renderErrorJSON($response, gettext('Test email failed to send') . ': ' . $birthdayEmail->getError(), [], 500);
} catch (\Throwable $e) {
return SlimUtils::renderErrorJSON($response, gettext('Test email failed to send'), [], 500, $e, $request);
}
});

});
25 changes: 25 additions & 0 deletions src/skin/js/SystemSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,28 @@ $(".setting-tip").click(function () {
className: "setting-tip-box",
});
});

$("#sendTestBirthdayEmail").on("click", function (event) {
event.preventDefault();
var $btn = $(this);
var $result = $("#birthdayEmailTestResult");

$btn.prop("disabled", true);
$result.removeClass("text-success text-danger").text("Sending...");

$.ajax({
method: "POST",
url: window.CRM.root + "/admin/api/admin/birthday-emails/test",
dataType: "json",
})
.done(function (data) {
$result.addClass("text-success").text(data.message);
})
.fail(function (jqXHR) {
var msg = (jqXHR.responseJSON && jqXHR.responseJSON.message) || "Failed to send test email.";
$result.addClass("text-danger").text(msg);
})
.always(function () {
$btn.prop("disabled", false);
});
});