Add support for "sendingTimePolicy": "Daytime" also for email (not only SMS).
Solution design
Background
Most of the plumbing is already in place: EmailSendingOptions(Ext).SendingTimePolicy exists, and NotificationOrderChainMapper already maps the policy through to the core model. The gating logic, dedicated persistence, and trigger pipeline are SMS-only today.
Constraints
- Mirror the existing SMS Daytime pattern; no larger refactoring.
- Backwards-compatible API (existing clients keep working unchanged).
- DB migration must be additive and rollback-safe.
- Plan for automatic verification.
Database (new migration v0.80)
01-alter-tables.sql: ALTER TABLE notifications.orders ADD COLUMN emailsendingtimepolicy INTEGER NULL; — additive, no backfill, no constraint. Old code ignores the column.
02-functions-and-procedures.sql (autogenerated from FunctionsAndProcedures/):
- Modify
claim_email_batch: extend WHERE with AND (ord.emailsendingtimepolicy IS NULL OR ord.emailsendingtimepolicy = 1). Existing resourcelimitlog rate-limit logic is preserved.
- Add
claim_daytime_email_batch: mirrors claim_email_batch but with WHERE ord.emailsendingtimepolicy = 2.
The existing notifications.orders.sendingtimepolicy column (used by SMS) is intentionally not renamed to smssendingtimepolicy. Renaming would complicate rollback and is outside this issue's scope. The naming asymmetry is accepted.
API + domain
EmailSendingOptionsExt.SendingTimePolicy becomes SendingTimePolicyExt? — nullable, no [DefaultValue]. A missing/null field stays NULL all the way to the DB. Existing API clients that don't send the field get the same behavior as today.
EmailSendingOptions.SendingTimePolicy becomes SendingTimePolicy?.
EmailSendingOptionsValidator: When(value != null, () => Must(IsDaytimeOrAnytime)) — null allowed; non-null must be Daytime or Anytime.
NotificationOrder gets a new EmailSendingTimePolicy : SendingTimePolicy? property alongside the existing SendingTimePolicy (which remains for SMS). The mapper sets the right property based on template type.
Open for PR review: when an order has both SMS and email recipients with different policies, two separate properties on NotificationOrder may be confusing. A record SendingTimePolicies(SendingTimePolicy? Sms, SendingTimePolicy? Email) wrapper could be cleaner — left to PR review rather than pre-decided.
Sending pipeline
IEmailNotificationRepository.GetNewNotificationsAsync(int, CancellationToken, SendingTimePolicy = Anytime) — switch routes to claim_email_batch or claim_daytime_email_batch.
IEmailNotificationService.SendNotifications(CancellationToken, SendingTimePolicy).
IEmailPublishTaskQueue gets a policy parameter (mirror ISmsPublishTaskQueue).
EmailPublishBackgroundService runs two loops (Anytime + Daytime), mirror SmsPublishBackgroundService.
Scheduling
- New
EmailSendWindowStartHour / EmailSendWindowEndHour keys in NotificationConfig and appsettings.json, mirroring the SMS keys (defaults 09–17). Mirroring rather than reusing keeps the change isolated and explicit.
INotificationScheduleService gets CanSendEmailNow() and GetEmailExpirationDateTime(DateTime) — same logic as the SMS counterparts, applied to the email window.
EmailOrderProcessingService calls GetEmailExpirationDateTime when policy is Daytime (extending expiry across the next business window, matching SMS behavior); otherwise keeps current RequestedSendTime + 48h.
Controller + cron
/trigger/sendemail keeps current semantics: enqueues with SendingTimePolicy.Anytime. Not breaking — the modified claim_email_batch ensures Daytime orders are not picked up by this path. Existing cron job stays unchanged.
- New
/trigger/sendemaildaytime: CanSendEmailNow() gate, then enqueues with SendingTimePolicy.Daytime. Returns 200 OK regardless of whether work was queued.
- A new cron job for
/sendemaildaytime will be added in the infra repo (out of scope for this PR).
Tests (automatic verification)
- New:
Trigger_SendEmailNotificationsTests mirroring Trigger_SendSmsNotificationsTests — outside-window blocks; anytime always processes.
- Updated:
EmailSendingOptionsValidatorTests — null allowed, Daytime allowed, invalid values rejected.
- Updated:
EmailNotificationRepositoryTests — verify policy parameter routes correctly and NULL rows are picked up by the anytime path.
- Updated:
NotificationScheduleServiceTests — cover CanSendEmailNow() and GetEmailExpirationDateTime.
- New: integration test asserting that an email order with Daytime policy is not claimed by
claim_email_batch.
Rollback considerations
| Scenario |
Status |
| Roll back code only (keep DB) |
Safe with one caveat. Old validator rejects Daytime → no new Daytime emails. The modified claim_email_batch continues to pick up Anytime/NULL rows correctly. Caveat: any emailsendingtimepolicy = 2 rows created before rollback will sit unclaimed until they expire. Mitigation: a one-shot UPDATE notifications.orders SET emailsendingtimepolicy = NULL WHERE emailsendingtimepolicy = 2 if a rollback is needed. |
| Roll back both code and DB |
Requires a follow-up migration that drops the column and restores claim_email_batch. Possible but more work; not needed for routine rollback. |
| Roll forward DB, hold back code |
Safe — new column is nullable and ignored by old code; the modified claim_email_batch filters on a column that's NULL on every existing row → matches everything. |
Add support for
"sendingTimePolicy": "Daytime"also for email (not only SMS).Solution design
Background
Most of the plumbing is already in place:
EmailSendingOptions(Ext).SendingTimePolicyexists, andNotificationOrderChainMapperalready maps the policy through to the core model. The gating logic, dedicated persistence, and trigger pipeline are SMS-only today.Constraints
Database (new migration
v0.80)01-alter-tables.sql:ALTER TABLE notifications.orders ADD COLUMN emailsendingtimepolicy INTEGER NULL;— additive, no backfill, no constraint. Old code ignores the column.02-functions-and-procedures.sql(autogenerated fromFunctionsAndProcedures/):claim_email_batch: extendWHEREwithAND (ord.emailsendingtimepolicy IS NULL OR ord.emailsendingtimepolicy = 1). Existingresourcelimitlograte-limit logic is preserved.claim_daytime_email_batch: mirrorsclaim_email_batchbut withWHERE ord.emailsendingtimepolicy = 2.API + domain
EmailSendingOptionsExt.SendingTimePolicybecomesSendingTimePolicyExt?— nullable, no[DefaultValue]. A missing/null field stays NULL all the way to the DB. Existing API clients that don't send the field get the same behavior as today.EmailSendingOptions.SendingTimePolicybecomesSendingTimePolicy?.EmailSendingOptionsValidator:When(value != null, () => Must(IsDaytimeOrAnytime))— null allowed; non-null must beDaytimeorAnytime.NotificationOrdergets a newEmailSendingTimePolicy : SendingTimePolicy?property alongside the existingSendingTimePolicy(which remains for SMS). The mapper sets the right property based on template type.Sending pipeline
IEmailNotificationRepository.GetNewNotificationsAsync(int, CancellationToken, SendingTimePolicy = Anytime)— switch routes toclaim_email_batchorclaim_daytime_email_batch.IEmailNotificationService.SendNotifications(CancellationToken, SendingTimePolicy).IEmailPublishTaskQueuegets a policy parameter (mirrorISmsPublishTaskQueue).EmailPublishBackgroundServiceruns two loops (Anytime + Daytime), mirrorSmsPublishBackgroundService.Scheduling
EmailSendWindowStartHour/EmailSendWindowEndHourkeys inNotificationConfigandappsettings.json, mirroring the SMS keys (defaults 09–17). Mirroring rather than reusing keeps the change isolated and explicit.INotificationScheduleServicegetsCanSendEmailNow()andGetEmailExpirationDateTime(DateTime)— same logic as the SMS counterparts, applied to the email window.EmailOrderProcessingServicecallsGetEmailExpirationDateTimewhen policy isDaytime(extending expiry across the next business window, matching SMS behavior); otherwise keeps currentRequestedSendTime + 48h.Controller + cron
/trigger/sendemailkeeps current semantics: enqueues withSendingTimePolicy.Anytime. Not breaking — the modifiedclaim_email_batchensures Daytime orders are not picked up by this path. Existing cron job stays unchanged./trigger/sendemaildaytime:CanSendEmailNow()gate, then enqueues withSendingTimePolicy.Daytime. Returns 200 OK regardless of whether work was queued./sendemaildaytimewill be added in the infra repo (out of scope for this PR).Tests (automatic verification)
Trigger_SendEmailNotificationsTestsmirroringTrigger_SendSmsNotificationsTests— outside-window blocks; anytime always processes.EmailSendingOptionsValidatorTests— null allowed, Daytime allowed, invalid values rejected.EmailNotificationRepositoryTests— verify policy parameter routes correctly and NULL rows are picked up by the anytime path.NotificationScheduleServiceTests— coverCanSendEmailNow()andGetEmailExpirationDateTime.claim_email_batch.Rollback considerations
claim_email_batchcontinues to pick up Anytime/NULL rows correctly. Caveat: anyemailsendingtimepolicy = 2rows created before rollback will sit unclaimed until they expire. Mitigation: a one-shotUPDATE notifications.orders SET emailsendingtimepolicy = NULL WHERE emailsendingtimepolicy = 2if a rollback is needed.claim_email_batch. Possible but more work; not needed for routine rollback.claim_email_batchfilters on a column that's NULL on every existing row → matches everything.