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 { +