Add recurring scheduled messages (daily, weekly, monthly)

Extend scheduled messages with a recurrence field. After sending a
recurring message, the scheduler automatically creates the next pending
occurrence. Frontend adds repeat toggle (Once/Daily/Weekly/Monthly) and
displays recurrence in the messages table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:30:30 -04:00
parent f5203e0466
commit 467e752629
5 changed files with 96 additions and 3 deletions
+19 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from quart import Blueprint, request, jsonify from quart import Blueprint, request, jsonify
from blueprints.users.decorators import admin_required from blueprints.users.decorators import admin_required
from .models import ScheduledMessage, MessageChannel, MessageStatus from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
scheduled_messages_blueprint = Blueprint( scheduled_messages_blueprint = Blueprint(
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages" "scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
@@ -22,6 +22,7 @@ def _serialize(msg: ScheduledMessage) -> dict:
"subject": msg.subject, "subject": msg.subject,
"scheduled_at": msg.scheduled_at.isoformat(), "scheduled_at": msg.scheduled_at.isoformat(),
"status": msg.status.value, "status": msg.status.value,
"recurrence": msg.recurrence.value,
"error_message": msg.error_message, "error_message": msg.error_message,
"created_at": msg.created_at.isoformat(), "created_at": msg.created_at.isoformat(),
"updated_at": msg.updated_at.isoformat(), "updated_at": msg.updated_at.isoformat(),
@@ -48,6 +49,8 @@ async def create_message():
subject = (data.get("subject") or "").strip() or None subject = (data.get("subject") or "").strip() or None
scheduled_at_str = data.get("scheduled_at") scheduled_at_str = data.get("scheduled_at")
recurrence_str = data.get("recurrence", "none")
if not recipient or not channel or not content or not scheduled_at_str: if not recipient or not channel or not content or not scheduled_at_str:
return jsonify( return jsonify(
{"error": "recipient, channel, content, and scheduled_at are required"} {"error": "recipient, channel, content, and scheduled_at are required"}
@@ -60,6 +63,15 @@ async def create_message():
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"} {"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
), 400 ), 400
try:
recurrence_enum = Recurrence(recurrence_str)
except ValueError:
return jsonify(
{
"error": f"Invalid recurrence: {recurrence_str}. Must be 'none', 'daily', 'weekly', or 'monthly'"
}
), 400
if channel_enum == MessageChannel.EMAIL and not subject: if channel_enum == MessageChannel.EMAIL and not subject:
return jsonify({"error": "subject is required for email messages"}), 400 return jsonify({"error": "subject is required for email messages"}), 400
@@ -83,6 +95,7 @@ async def create_message():
content=content, content=content,
subject=subject, subject=subject,
scheduled_at=scheduled_at, scheduled_at=scheduled_at,
recurrence=recurrence_enum,
created_by_id=user_id, created_by_id=user_id,
) )
return jsonify(_serialize(msg)), 201 return jsonify(_serialize(msg)), 201
@@ -113,6 +126,11 @@ async def update_message(msg_id: str):
msg.content = data["content"].strip() msg.content = data["content"].strip()
if "subject" in data: if "subject" in data:
msg.subject = data["subject"].strip() or None msg.subject = data["subject"].strip() or None
if "recurrence" in data:
try:
msg.recurrence = Recurrence(data["recurrence"])
except ValueError:
return jsonify({"error": f"Invalid recurrence: {data['recurrence']}"}), 400
if "scheduled_at" in data: if "scheduled_at" in data:
try: try:
scheduled_at = datetime.fromisoformat(data["scheduled_at"]) scheduled_at = datetime.fromisoformat(data["scheduled_at"])
+10
View File
@@ -16,6 +16,13 @@ class MessageStatus(enum.Enum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
class Recurrence(enum.Enum):
NONE = "none"
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
class ScheduledMessage(Model): class ScheduledMessage(Model):
id = fields.UUIDField(primary_key=True) id = fields.UUIDField(primary_key=True)
recipient = fields.CharField(max_length=255) recipient = fields.CharField(max_length=255)
@@ -26,6 +33,9 @@ class ScheduledMessage(Model):
status = fields.CharEnumField( status = fields.CharEnumField(
enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING enum_type=MessageStatus, max_length=10, default=MessageStatus.PENDING
) )
recurrence = fields.CharEnumField(
enum_type=Recurrence, max_length=10, default=Recurrence.NONE
)
error_message = fields.TextField(null=True) error_message = fields.TextField(null=True)
created_by = fields.ForeignKeyField( created_by = fields.ForeignKeyField(
"models.User", related_name="scheduled_messages" "models.User", related_name="scheduled_messages"
+38 -1
View File
@@ -1,13 +1,46 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from .models import ScheduledMessage, MessageChannel, MessageStatus from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
POLL_INTERVAL = 15 POLL_INTERVAL = 15
RECURRENCE_DELTAS = {
Recurrence.DAILY: relativedelta(days=1),
Recurrence.WEEKLY: relativedelta(weeks=1),
Recurrence.MONTHLY: relativedelta(months=1),
}
async def _schedule_next_occurrence(msg: ScheduledMessage):
"""Create the next pending occurrence for a recurring message."""
delta = RECURRENCE_DELTAS.get(msg.recurrence)
if not delta:
return
next_at = msg.scheduled_at + delta
# If we missed several intervals, advance until we're in the future
now = datetime.now(timezone.utc)
while next_at <= now:
next_at += delta
await ScheduledMessage.create(
recipient=msg.recipient,
channel=msg.channel,
content=msg.content,
subject=msg.subject,
scheduled_at=next_at,
recurrence=msg.recurrence,
created_by_id=msg.created_by_id,
)
logger.info(
f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}"
)
async def scheduled_messages_loop(): async def scheduled_messages_loop():
"""Background loop that polls for and sends due scheduled messages.""" """Background loop that polls for and sends due scheduled messages."""
@@ -45,6 +78,10 @@ async def scheduled_messages_loop():
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}" f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
) )
# Schedule next occurrence for recurring messages
if msg.recurrence != Recurrence.NONE:
await _schedule_next_occurrence(msg)
except Exception as e: except Exception as e:
msg.status = MessageStatus.FAILED msg.status = MessageStatus.FAILED
msg.error_message = str(e) msg.error_message = str(e)
@@ -8,6 +8,7 @@ export interface ScheduledMessage {
subject: string | null; subject: string | null;
scheduled_at: string; scheduled_at: string;
status: "pending" | "sent" | "failed" | "cancelled"; status: "pending" | "sent" | "failed" | "cancelled";
recurrence: "none" | "daily" | "weekly" | "monthly";
error_message: string | null; error_message: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -19,6 +20,7 @@ export interface CreateScheduledMessage {
content: string; content: string;
subject?: string; subject?: string;
scheduled_at: string; scheduled_at: string;
recurrence?: "none" | "daily" | "weekly" | "monthly";
} }
class ScheduledMessageService { class ScheduledMessageService {
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { X, Clock, Send, Trash2, XCircle, RotateCcw } from "lucide-react"; import { X, Clock, Send, Trash2, XCircle, RotateCcw, Repeat } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -36,6 +36,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [scheduledAt, setScheduledAt] = useState(""); const [scheduledAt, setScheduledAt] = useState("");
const [recurrence, setRecurrence] = useState<"none" | "daily" | "weekly" | "monthly">("none");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -57,6 +58,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
channel, channel,
content, content,
scheduled_at: new Date(scheduledAt).toISOString(), scheduled_at: new Date(scheduledAt).toISOString(),
recurrence,
}; };
if (channel === "email") data.subject = subject; if (channel === "email") data.subject = subject;
await scheduledMessageService.create(data); await scheduledMessageService.create(data);
@@ -64,6 +66,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
setSubject(""); setSubject("");
setContent(""); setContent("");
setScheduledAt(""); setScheduledAt("");
setRecurrence("none");
refresh(); refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to schedule message"); setError(err instanceof Error ? err.message : "Failed to schedule message");
@@ -167,6 +170,25 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
/> />
</div> </div>
<div className="flex items-center gap-2">
<Repeat size={12} className="text-warm-gray" />
<span className="text-xs text-warm-gray">Repeat:</span>
{(["none", "daily", "weekly", "monthly"] as const).map((r) => (
<button
key={r}
onClick={() => setRecurrence(r)}
className={cn(
"px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer",
recurrence === r
? "bg-amber-pale text-amber-glow"
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
)}
>
{r === "none" ? "Once" : r.charAt(0).toUpperCase() + r.slice(1)}
</button>
))}
</div>
{channel === "email" && ( {channel === "email" && (
<Input <Input
value={subject} value={subject}
@@ -213,6 +235,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
<TableHead>Recipient</TableHead> <TableHead>Recipient</TableHead>
<TableHead>Content</TableHead> <TableHead>Content</TableHead>
<TableHead>Scheduled</TableHead> <TableHead>Scheduled</TableHead>
<TableHead>Repeat</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="w-28">Actions</TableHead> <TableHead className="w-28">Actions</TableHead>
</TableRow> </TableRow>
@@ -234,6 +257,9 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
<TableCell className="text-xs text-warm-gray"> <TableCell className="text-xs text-warm-gray">
{new Date(msg.scheduled_at).toLocaleString()} {new Date(msg.scheduled_at).toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-xs text-warm-gray capitalize">
{msg.recurrence === "none" ? "—" : msg.recurrence}
</TableCell>
<TableCell> <TableCell>
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge> <Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
</TableCell> </TableCell>