Skip to content

Support Daytime-policy for emails #1530

Description

@olebhansen

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.

Metadata

Metadata

Assignees

Labels

status/blockedFurther work depending on the completion of some other task/PoC/issue

Type

No type

Projects

  • Status
    🌥 Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions