diff --git a/.env.example b/.env.example
index 54a6911..800354d 100644
--- a/.env.example
+++ b/.env.example
@@ -6,3 +6,4 @@ BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api
NODE_ENV=development
ADMIN_EMAILS=corey@blaishome.online
+RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 9b81c56..c7cbb00 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -219,6 +219,7 @@ const smtpUser = process.env.SMTP_USER?.trim() ?? '';
const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
+const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const adminEmails = new Set(
(process.env.ADMIN_EMAILS ?? '')
.split(',')
@@ -487,6 +488,64 @@ const sendMagicLink = async ({
};
};
+const escapeHtml = (value: string) =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+const sendRescueStatusNotification = async ({
+ workspace,
+ ownerEmail,
+ event,
+}: {
+ workspace: WorkspaceRow;
+ ownerEmail: string | null;
+ event: 'created' | 'converted' | 'status_changed';
+}) => {
+ const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
+ const eventLabel = event === 'created' ? 'created' : event === 'converted' ? 'converted to rescue' : 'status updated';
+ const subject = `FlockPal rescue status: ${workspace.name} ${eventLabel}`;
+ const escapedWorkspaceName = escapeHtml(workspace.name);
+ const escapedStatusLabel = escapeHtml(statusLabel);
+ const escapedOwnerEmail = escapeHtml(ownerEmail ?? 'unknown');
+ const escapedBillingEmail = escapeHtml(workspace.billing_email ?? 'not set');
+ const lines = [
+ `Rescue flock: ${workspace.name}`,
+ `Event: ${eventLabel}`,
+ `Verification status: ${statusLabel}`,
+ `Owner email: ${ownerEmail ?? 'unknown'}`,
+ `Billing email: ${workspace.billing_email ?? 'not set'}`,
+ `Flock ID: ${workspace.id}`,
+ ];
+
+ if (!mailTransport) {
+ console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
+ return { delivered: false };
+ }
+
+ await mailTransport.sendMail({
+ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
+ to: rescueStatusNotificationEmail,
+ subject,
+ text: lines.join('\n'),
+ html: `
+
A rescue flock was ${eventLabel}.
+
+ - Rescue flock: ${escapedWorkspaceName}
+ - Verification status: ${escapedStatusLabel}
+ - Owner email: ${escapedOwnerEmail}
+ - Billing email: ${escapedBillingEmail}
+ - Flock ID: ${workspace.id}
+
+ `,
+ });
+
+ return { delivered: true };
+};
+
const issueMagicLinkInvite = async ({
email,
name,
@@ -1006,6 +1065,12 @@ app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessi
return;
}
+ await sendRescueStatusNotification({
+ workspace,
+ ownerEmail: null,
+ event: 'status_changed',
+ });
+
res.json({ workspace: normalizeWorkspace(workspace) });
} catch (error) {
next(error);
@@ -1098,6 +1163,14 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
owner: req.auth!.user,
});
+ if (workspace?.workspace_type === 'rescue') {
+ await sendRescueStatusNotification({
+ workspace,
+ ownerEmail: req.auth!.user.email,
+ event: 'created',
+ });
+ }
+
res.status(201).json({ workspace: normalizeWorkspace(workspace!) });
} catch (error) {
next(error);
@@ -1126,6 +1199,14 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(
billingPlan,
});
+ if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') {
+ await sendRescueStatusNotification({
+ workspace,
+ ownerEmail: req.auth!.user.email,
+ event: 'converted',
+ });
+ }
+
res.json({ workspace: normalizeWorkspace(workspace!) });
} catch (error) {
next(error);
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 0c88dd5..93357b0 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -31,6 +31,7 @@ services:
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
+ RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
diff --git a/docker-compose.yml b/docker-compose.yml
index afda506..5ddc573 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,6 +30,7 @@ services:
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
+ RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}