Notification Configuration Capabilities#
Overview#
Zerobyte provides a comprehensive notification system for backup events, built on Shoutrrr as the underlying delivery mechanism. The system supports 9 notification channels including Email (SMTP), Slack, Discord, Gotify, Ntfy, Pushover, Telegram, Generic Webhook, and Custom Shoutrrr URLs.
Notifications can be triggered for four backup lifecycle events: start, success, warning, and failure. All sensitive credentials are encrypted at rest using cryptographic utilities, ensuring secure storage of passwords, tokens, and webhook URLs.
Telegram Thread ID Support#
Telegram Forum groups (also known as Topics or Supergroups with topics enabled) allow organizing conversations into separate threads. Zerobyte supports this feature through an optional thread ID configuration, enabling notifications to be sent to specific topics within a group chat.
Configuration#
The Telegram notification schema defines the following fields:
{
type: "telegram",
botToken: string, // Required: Telegram bot authentication token
chatId: string, // Required: Target chat or group ID
threadId?: string // Optional: Specific topic/thread ID within the group
}
The threadId field enables sending messages to specific topics within Telegram Forum groups, allowing backup notifications to be organized by topic—for example, separate threads for different backup schedules or environments.
User Interface#
The form field implementation provides a clear, user-friendly interface:
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="3" />
</FormControl>
<FormDescription>
Telegram Group Topic ID to send notifications to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
The field accepts any string value with no validation constraints, as thread IDs are simple numeric identifiers assigned by Telegram.
Technical Implementation#
When a thread ID is provided, the Telegram Shoutrrr URL builder constructs a specialized URL format:
export const buildTelegramShoutrrrUrl = (
config: Extract<NotificationConfig, { type: "telegram" }>
) => {
let shoutrrrUrl = `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
if (config.threadId) {
shoutrrrUrl += `:${config.threadId}`;
}
return shoutrrrUrl;
};
The thread ID is appended to the channels parameter with a colon separator via string concatenation, producing a URL like:
telegram://123456:ABCdef@telegram?channels=-1234567890:3
Where 123456:ABCdef is the bot token, -1234567890 is the chat ID, and 3 is the thread ID. The colon separator is included literally (unencoded) in the URL. This format follows Shoutrrr's convention for targeting specific threads within Telegram groups.
Comparison with Discord#
Discord also supports thread IDs for organizing notifications, but uses a different URL construction approach. Discord employs a query parameter format (&thread_id={threadId}) rather than Telegram's colon-separated channels approach, reflecting the underlying differences in how each platform's API handles threaded messages.
Discord Message Formatting#
Discord notifications are configured to deliver multiline messages as single unified messages. The Discord Shoutrrr URL builder sets splitLines=false to prevent each line from being sent as a separate message. This ensures that notification content—including backup statistics with multiple data points—appears as a cohesive message rather than fragmented across multiple sequential posts.
Email From Name Customization#
Email notifications sent via SMTP can include a customized display name that appears alongside the sender's email address in the recipient's email client. This feature enhances sender recognition and allows for branded notification messages.
Configuration#
The Email notification schema includes both required and optional fields:
{
type: "email",
smtpHost: string, // Required
smtpPort: number, // Required: 1-65535
from: string, // Required: Sender email address
to: string[], // Required: Recipient email addresses
useTLS: boolean, // Required: Enable STARTTLS
username?: string, // Optional: SMTP authentication username
password?: string, // Optional: SMTP authentication password
fromName?: string // Optional: Display name for sender
}
The fromName field is optional and allows customizing the display name shown in email clients, transforming a plain email address like noreply@example.com into a more recognizable sender such as "Zerobyte Backup noreply@example.com".
User Interface#
The email form implementation presents the field with helpful guidance:
<FormField
control={form.control}
name="fromName"
render={({ field }) => (
<FormItem>
<FormLabel>From Name (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte Backup" />
</FormControl>
<FormDescription>
The display name shown in the email client.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
The field has no length restrictions or pattern validation, accepting any string value. The placeholder text "Zerobyte Backup" suggests an appropriate value, though no default is set in form initialization—the field remains undefined until the user provides a value.
Technical Implementation#
The email Shoutrrr URL builder conditionally includes the fromName parameter:
export const buildEmailShoutrrrUrl = (
config: Extract<NotificationConfig, { type: "email" }>
) => {
const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
: "";
const host = `${config.smtpHost}:${config.smtpPort}`;
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
const useStartTLS = config.useTLS ? "yes" : "no";
const fromNameParam = config.fromName
? `&fromname=${encodeURIComponent(config.fromName)}`
: "";
return `smtp://${auth}${host}/?from=${encodeURIComponent(config.from)}${fromNameParam}&to=${toRecipients}&starttls=${useStartTLS}`;
};
The conditional parameter construction on line 11 ensures the fromname parameter is only added when a value is provided. The function uses encodeURIComponent() to properly handle special characters in the display name, such as spaces, commas, or international characters.
When the fromName is omitted, the email is sent without a display name parameter, causing most email clients to show only the email address from the from field.
Dynamic Notification Message Content#
Zerobyte automatically generates contextual notification messages based on backup events, incorporating dynamic information such as schedule names, volume names, repository names, and detailed backup statistics.
Message Construction#
The buildNotificationMessage() function constructs messages dynamically based on event type and context:
function buildNotificationMessage(
event: NotificationEvent,
context: {
volumeName: string;
repositoryName: string;
scheduleName?: string;
error?: string;
summary?: ResticBackupRunSummaryDto;
},
)
Schedule Name Integration#
Schedule names are prominently featured in notifications. The function uses the schedule name (or falls back to "backup") as the primary identifier in notification titles:
const backupName = context.scheduleName ?? "backup";
When a schedule name is provided, it appears in:
- Title:
"Zerobyte {scheduleName} started"(or "completed successfully", "completed with warnings", "failed") - Body:
"Schedule: {scheduleName}"as a separate line item
This personalization helps administrators quickly identify which backup schedule triggered the notification, especially useful in environments with multiple concurrent backup schedules.
Event-Specific Message Formats#
Start Event#
Start event notifications provide basic context when a backup begins:
case "start":
return {
title: `Zerobyte ${backupName} started`,
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
]
.filter(Boolean)
.join("\n"),
};
Example output:
Title: Zerobyte daily-backup started
Body:
Volume: /data/documents
Repository: s3://backup-bucket/repo
Schedule: daily-backup
Success Event#
Success event notifications include comprehensive backup statistics:
case "success": {
const bodyLines = [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
...notificationLines, // Detailed backup statistics
];
return {
title: `Zerobyte ${backupName} completed successfully`,
body: bodyLines.filter(Boolean).join("\n"),
};
}
Warning Event#
Warning event notifications include error information alongside statistics:
case "warning": {
const bodyLines = [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
context.error ? `Warning: ${context.error}` : null,
...notificationLines,
];
return {
title: `Zerobyte ${backupName} completed with warnings`,
body: bodyLines.filter(Boolean).join("\n"),
};
}
When backup operations complete with exit code 3 (warning/partial success with read errors), the error handling system separates high-level error summaries from detailed diagnostic information. Notifications use the detailed diagnostic information extracted from restic's stderr output when available, providing specific troubleshooting details about what issues occurred during the backup operation. For example, instead of a generic error summary, administrators receive specific file access errors like "error: open /mnt/data/private.db: permission denied". The diagnostic details are stored in the lastBackupError field and included in the notification context.error field, providing administrators with actionable information to diagnose and resolve backup issues.
Failure Event#
Failure event notifications focus on error details:
case "failure":
return {
title: `Zerobyte ${backupName} failed`,
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
context.error ? `Error: ${context.error}` : null,
]
.filter(Boolean)
.join("\n"),
};
Like warning events, failure notifications prioritize detailed diagnostic information from restic's stderr output over generic error summaries. When restic operations fail, the notification system uses the toErrorDetails() helper function to extract specific diagnostic details—such as SSH permission errors like "Permissions 0755 for '/tmp/zerobyte-ssh-key' are too open. This private key will be ignored."—rather than generic messages like "ssh command exited". This ensures failure notifications provide actionable troubleshooting information to help administrators quickly identify and resolve issues.
Backup Statistics#
The buildBackupNotificationLines() helper function formats detailed backup statistics from Restic's backup summary. The function provides two formatting modes depending on data availability.
Simple Mode#
When detailed statistics are unavailable, the function provides basic metrics:
- Duration: Total backup time in seconds
- Files: Total number of files processed
- Size: Total bytes processed (formatted)
- Snapshot: Snapshot ID
Example output:
Duration: 127s
Files: 1,523
Size: 2.4 GB
Snapshot: a3f7c921
Detailed Mode#
When comprehensive statistics are available, the function provides granular metrics:
const lines = [
"Overview:",
`- Data added: ${safeBytesText(summary.data_added)}`,
summary.data_added_packed !== undefined
? `- Data stored: ${safeBytesText(summary.data_added_packed)}`
: null,
`- Total files processed: ${safeCountText(summary.total_files_processed)}`,
`- Total bytes processed: ${safeBytesText(summary.total_bytes_processed)}`,
"Backup Statistics:",
`- Files new: ${safeCountText(summary.files_new)}`,
`- Files changed: ${safeCountText(summary.files_changed)}`,
`- Files unmodified: ${safeCountText(summary.files_unmodified)}`,
`- Dirs new: ${safeCountText(summary.dirs_new)}`,
`- Dirs changed: ${safeCountText(summary.dirs_changed)}`,
`- Dirs unmodified: ${safeCountText(summary.dirs_unmodified)}`,
`- Data blobs: ${safeCountText(summary.data_blobs)}`,
`- Tree blobs: ${safeCountText(summary.tree_blobs)}`,
`- Total duration: ${safeDurationText(summary.total_duration)}`,
`- Snapshot: ${snapshotText}`,
];
Example output:
Overview:
- Data added: 234.5 MB
- Data stored: 187.2 MB
- Total files processed: 1,523
- Total bytes processed: 2.4 GB
Backup Statistics:
- Files new: 45
- Files changed: 12
- Files unmodified: 1,466
- Dirs new: 3
- Dirs changed: 8
- Dirs unmodified: 142
- Data blobs: 234
- Tree blobs: 153
- Total duration: 2m 7s
- Snapshot: a3f7c921
Customization Limitations#
Notification messages are not user-customizable or templated. The message format is hardcoded in the buildNotificationMessage() function. However, users retain control over:
- Which events trigger notifications (start, success, warning, failure) per schedule assignment
- Which notification destinations receive messages for each schedule
- The content through schedule naming since schedule names appear prominently in titles and bodies
Form Behaviors and Validation#
Zerobyte uses Zod for schema validation, integrated with React Hook Form through @hookform/resolvers/zod. A single form component handles both create and update operations, adapting its behavior based on the current mode.
Field-Level Validation Rules#
Notification Name#
The notification name has strict length requirements. The form schema enforces a constraint of 2-32 characters using Zod's validation methods:
{
name: z.string().min(2).max(32)
}
The server-side validation adds additional checks, trimming whitespace and ensuring the name is not empty:
if (updates.name !== undefined) {
const trimmedName = updates.name.trim();
if (trimmedName.length === 0) {
throw new BadRequestError("Name cannot be empty");
}
updateData.name = trimmedName;
}
Type-Specific Validation#
Each notification type has specific validation rules enforced through Zod schemas:
Email Configuration (schema):
smtpPort:z.number().int().min(1).max(65535)(valid TCP port range)to:z.array(z.string())(required array of recipient addresses)username,password,fromName: Optional (z.string().optional())
Gotify Priority (schema):
priority:z.number().min(0).max(10)(Gotify's priority range)
Pushover Priority (schema):
priority:z.union([z.literal(-1), z.literal(0), z.literal(1)])(literal value union)
Ntfy Priority (schema):
priority:z.enum(["max", "high", "default", "low", "min"])(string literal enumeration)
Generic Webhook Method (schema):
method:z.enum(["GET", "POST"])(HTTP method restriction)
Dynamic Form Behaviors#
Type Selection and Form Reset#
When the notification type changes, the form dynamically resets with type-specific default values:
<Select
onValueChange={(value) => {
field.onChange(value);
if (!initialValues) {
form.reset({
name: form.getValues().name || "",
...defaultValuesForType[value as keyof typeof defaultValuesForType],
});
}
}}
value={field.value}
disabled={mode === "update"}
This behavior preserves the notification name while populating type-specific fields with appropriate defaults. Importantly, the type selector is disabled in update mode to prevent changing the notification type after creation, ensuring configuration consistency.
Array Field Handling#
Array inputs are handled through string transformations, providing user-friendly interfaces for complex data types:
Email Recipients - Comma-separated transformation:
<Input
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) =>
field.onChange(
e.target.value
.split(",")
.map((email) => email.trim())
.filter(Boolean),
)
}
/>
Users enter emails as a comma-separated list, which is automatically split, trimmed, and filtered to produce a clean array.
Generic Webhook Headers - Newline-separated transformation:
<Textarea
placeholder="Authorization: Bearer token X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
/>
Headers are entered one per line, automatically converted to an array format.
Conditional Field Rendering#
The Generic webhook form conditionally shows fields based on the useJson toggle:
{form.watch("useJson") && (
<>
<FormField name="titleKey" ... />
<FormField name="messageKey" ... />
</>
)}
When JSON mode is enabled, additional fields appear for specifying custom keys for the title and message in the JSON payload.
Live Request Preview#
The generic webhook form includes a dynamic request preview that updates in real-time as users type:
const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> }) => {
// Builds preview showing HTTP method, URL, headers, and body
return <CodeBlock code={previewCode} filename="HTTP Request" />
}
This preview helps users verify their webhook configuration before testing or deploying it.
Update and Edit Flow#
Loading Existing Configurations#
The notification details page fetches existing notification data and passes it as initial values:
const { data } = useSuspenseQuery({
...getNotificationDestinationOptions({ path: { id: notificationId } }),
});
<CreateNotificationForm
mode="update"
formId={formId}
onSubmit={handleSubmit}
initialValues={{
...data.config,
name: data.name,
}}
/>
Submitting Updates#
The update handler submits only the name and config fields:
const handleSubmit = (values: NotificationFormValues) => {
updateDestination.mutate({
path: { id: String(data.id) },
body: {
name: values.name,
config: values,
},
});
};
The enabled status is managed separately from the main form, preventing accidental disabling during configuration updates.
Server-Side Update Processing#
The update destination service performs several operations:
- Partial Updates: All fields are optional in the update DTO, supporting partial updates
- Re-validation: Config is re-validated against the schema to ensure integrity
- Re-encryption: Sensitive fields are re-encrypted if config changes
const newConfigResult = notificationConfigSchema.safeParse(updates.config || existing.config);
if (!newConfigResult.success) {
throw new BadRequestError("Invalid notification configuration");
}
const newConfig = newConfigResult.data;
Testing Notifications#
Notification destinations can be tested regardless of their enabled status, allowing users to validate configurations before activation. This enables administrators to verify connection settings, authentication credentials, and message delivery without first enabling the destination for production use.
UI Constraints#
The test button is available during configuration:
<Button
onClick={handleTest}
disabled={testDestination.isPending || !data.enabled}
variant="outline"
loading={testDestination.isPending}
<TestTube2 className="h-4 w-4 mr-2" />
Test
</Button>
Note: The UI currently enforces the !data.enabled constraint, though the server no longer requires destinations to be enabled for testing.
Server-Side Test Processing#
The test destination service processes test requests by decrypting credentials and sending a fixed test message:
const testDestination = async (id: number) => {
const destination = await getDestination(id);
const decryptedConfig = await decryptSensitiveFields(destination.config);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
// Send test notification
};
When testing succeeds, a fixed message is sent: "This is a test notification from Zerobyte".
API Layer Validation#
Create Destination DTO#
The create schema requires both name and config:
export const createDestinationBody = z.object({
name: z.string(),
config: notificationConfigSchema,
});
Update Destination DTO#
The update schema makes all fields optional for flexible partial updates:
export const updateDestinationBody = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
config: notificationConfigSchema.optional(),
});
This design allows updating individual fields without requiring the complete configuration object.
Security Features#
Automatic Credential Encryption#
Sensitive fields are automatically encrypted before storage:
// Encryption on create/update
const sealedConfig = await sealNotificationConfig(config);
// Decryption when needed
const resolvedConfig = await resolveNotificationConfig(destination.config);
The encryption is transparent to the frontend—sensitive fields like passwords, API tokens, and webhook URLs are automatically protected using cryptoUtils.sealSecret() and decrypted using cryptoUtils.resolveSecret() when needed for sending notifications. This ensures credentials are never stored in plain text in the database.