diff --git a/src/ChurchCRM/Emails/notifications/BirthdayEmail.php b/src/ChurchCRM/Emails/notifications/BirthdayEmail.php new file mode 100644 index 0000000000..ccc956e7c0 --- /dev/null +++ b/src/ChurchCRM/Emails/notifications/BirthdayEmail.php @@ -0,0 +1,84 @@ +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 + { + 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(); + } +} \ No newline at end of file diff --git a/src/ChurchCRM/Service/BirthdayEmailService.php b/src/ChurchCRM/Service/BirthdayEmailService.php new file mode 100644 index 0000000000..615960a76b --- /dev/null +++ b/src/ChurchCRM/Service/BirthdayEmailService.php @@ -0,0 +1,68 @@ +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)"); + } +} diff --git a/src/ChurchCRM/Service/SystemService.php b/src/ChurchCRM/Service/SystemService.php index 70b79d14f5..9f4f9b9674 100644 --- a/src/ChurchCRM/Service/SystemService.php +++ b/src/ChurchCRM/Service/SystemService.php @@ -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 diff --git a/src/ChurchCRM/dto/SystemConfig.php b/src/ChurchCRM/dto/SystemConfig.php index 253d12ad4c..33d93edd95 100644 --- a/src/ChurchCRM/dto/SystemConfig.php +++ b/src/ChurchCRM/dto/SystemConfig.php @@ -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')), @@ -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'], diff --git a/src/SystemSettings.php b/src/SystemSettings.php index 30b8ede214..55785470e2 100644 --- a/src/SystemSettings.php +++ b/src/SystemSettings.php @@ -119,6 +119,7 @@ function categoryId(string $category): string { +
@@ -214,11 +215,19 @@ function categoryId(string $category): string { getType() === 'number' || $setting->getType() === 'date') : ?> - getType() === 'boolean') : ?> + getType() === 'boolean') : ?> + getName() === 'bEnableBirthdayEmails') : ?> +
+ + +
+ getType() === 'json') : ?> diff --git a/src/admin/index.php b/src/admin/index.php index fda62faf17..f6ad177479 100644 --- a/src/admin/index.php +++ b/src/admin/index.php @@ -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'; diff --git a/src/admin/routes/api/birthday-emails.php b/src/admin/routes/api/birthday-emails.php new file mode 100644 index 0000000000..d06419d5fc --- /dev/null +++ b/src/admin/routes/api/birthday-emails.php @@ -0,0 +1,46 @@ +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); + } + }); + +}); \ No newline at end of file diff --git a/src/skin/js/SystemSettings.js b/src/skin/js/SystemSettings.js index 343312f061..ac97bdab85 100644 --- a/src/skin/js/SystemSettings.js +++ b/src/skin/js/SystemSettings.js @@ -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); + }); +});