diff --git a/backend/internal/hub/models/notifications.go b/backend/internal/hub/models/notifications.go
index b09c693..875edaa 100644
--- a/backend/internal/hub/models/notifications.go
+++ b/backend/internal/hub/models/notifications.go
@@ -16,6 +16,7 @@ const (
NotificationTypeDiscord NotificationType = "discord"
NotificationTypeGotify NotificationType = "gotify"
NotificationTypeSlack NotificationType = "slack"
+ NotificationTypeTeams NotificationType = "teams"
NotificationTypeWebhook NotificationType = "webhook"
NotificationTypeCustom NotificationType = "custom"
)
diff --git a/backend/internal/hub/notifications/provider/provider.go b/backend/internal/hub/notifications/provider/provider.go
index 5b40cb0..e1d8b22 100644
--- a/backend/internal/hub/notifications/provider/provider.go
+++ b/backend/internal/hub/notifications/provider/provider.go
@@ -16,6 +16,7 @@ func init() {
Register(models.NotificationTypeDiscord, DiscordProvider{})
Register(models.NotificationTypeGotify, GotifyProvider{})
Register(models.NotificationTypeSlack, SlackProvider{})
+ Register(models.NotificationTypeTeams, TeamsProvider{})
Register(models.NotificationTypeWebhook, WebhookProvider{})
Register(models.NotificationTypeCustom, CustomProvider{})
}
diff --git a/backend/internal/hub/notifications/provider/teams.go b/backend/internal/hub/notifications/provider/teams.go
new file mode 100644
index 0000000..ade5162
--- /dev/null
+++ b/backend/internal/hub/notifications/provider/teams.go
@@ -0,0 +1,58 @@
+package provider
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/nicholas-fedor/shoutrrr/pkg/services/chat/teams"
+)
+
+type TeamsProvider struct{}
+
+type teamsConfig struct {
+ Host string `json:"host"`
+ Title string `json:"title"`
+}
+
+func (TeamsProvider) BuildShoutrrrUrls(rawConfig string) ([]string, error) {
+ trimmed, err := normalizeRawConfig(rawConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg, err := decodeConfigJSON[teamsConfig](trimmed, "invalid JSON teams config")
+ if err != nil {
+ return nil, err
+ }
+
+ host := strings.TrimSpace(cfg.Host)
+ if host == "" {
+ return nil, errors.New("teams config requires host")
+ }
+
+ webhookURL, err := url.Parse(host)
+ if err != nil {
+ return nil, fmt.Errorf("invalid teams host: %w", err)
+ }
+ if webhookURL.Scheme != "https" {
+ return nil, errors.New("teams host must be a valid HTTPS Teams webhook URL")
+ }
+
+ serviceConfig, err := teams.ConfigFromWebhookURL(webhookURL)
+ if err != nil {
+ return nil, fmt.Errorf("invalid teams host: %w", err)
+ }
+
+ if title := strings.TrimSpace(cfg.Title); title != "" {
+ serviceConfig.Title = title
+ }
+
+ serviceURL := serviceConfig.GetURL()
+ if serviceURL == nil {
+ return nil, errors.New("invalid teams host")
+ }
+
+ return []string{serviceURL.String()}, nil
+}
diff --git a/backend/internal/hub/notifications/provider/teams_test.go b/backend/internal/hub/notifications/provider/teams_test.go
new file mode 100644
index 0000000..245462f
--- /dev/null
+++ b/backend/internal/hub/notifications/provider/teams_test.go
@@ -0,0 +1,146 @@
+package provider
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/nicholas-fedor/shoutrrr"
+)
+
+const validTeamsWebhookURL = "https://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"
+
+func TestTeamsProviderBuildShoutrrrUrls(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ wantErr string
+ }{
+ {
+ name: "empty config",
+ raw: " \n ",
+ wantErr: "notification config is empty",
+ },
+ {
+ name: "invalid json object",
+ raw: "{",
+ wantErr: "invalid JSON teams config",
+ },
+ {
+ name: "missing host",
+ raw: `{"title":"Deploy done"}`,
+ wantErr: "teams config requires host",
+ },
+ {
+ name: "non-https scheme must be rejected",
+ raw: `{"host":"http://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`,
+ wantErr: "teams host must be a valid HTTPS Teams webhook URL",
+ },
+ {
+ name: "invalid webhook domain",
+ raw: `{"host":"https://example.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`,
+ wantErr: "invalid teams host",
+ },
+ { //nolint:gosec
+ name: "direct target string instead of json",
+ raw: "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=contoso.webhook.office.com",
+ wantErr: "invalid JSON teams config",
+ },
+ { //nolint:gosec
+ name: "json array instead of object",
+ raw: `[{"host":"` + validTeamsWebhookURL + `"}]`,
+ wantErr: "invalid JSON teams config",
+ },
+ }
+
+ provider := TeamsProvider{}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := provider.BuildShoutrrrUrls(tt.raw)
+ if tt.wantErr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q", tt.wantErr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("BuildShoutrrrUrls() error = %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected one URL, got %v", got)
+ }
+ if !strings.HasPrefix(got[0], "teams://") {
+ t.Fatalf("expected teams URL, got %q", got[0])
+ }
+ })
+ }
+}
+
+func TestTeamsProviderBuildsStructuredURL(t *testing.T) {
+ provider := TeamsProvider{}
+
+ urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `","title":"Deploy done"}`)
+ if err != nil {
+ t.Fatalf("BuildShoutrrrUrls() error = %v", err)
+ }
+ if len(urls) != 1 {
+ t.Fatalf("expected 1 URL, got %d", len(urls))
+ }
+
+ parsed, err := url.Parse(urls[0])
+ if err != nil {
+ t.Fatalf("failed to parse URL %q: %v", urls[0], err)
+ }
+
+ if parsed.Scheme != "teams" {
+ t.Fatalf("expected scheme teams, got %q", parsed.Scheme)
+ }
+ if parsed.User == nil || parsed.User.Username() != "11111111-4444-4444-8444-cccccccccccc" {
+ t.Fatalf("expected group in URL user, got %v", parsed.User)
+ }
+ if parsed.Host != "22222222-4444-4444-8444-cccccccccccc" {
+ t.Fatalf("expected tenant in URL host, got %q", parsed.Host)
+ }
+ if parsed.Path != "/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05" {
+ t.Fatalf("expected webhook path components, got %q", parsed.Path)
+ }
+
+ query := parsed.Query()
+ if query.Get("host") != "contoso.webhook.office.com" {
+ t.Fatalf("expected host query parameter, got %q", query.Get("host"))
+ }
+ if query.Get("title") != "Deploy done" {
+ t.Fatalf("expected title query parameter, got %q", query.Get("title"))
+ }
+
+ if _, err := shoutrrr.CreateSender(urls...); err != nil {
+ t.Fatalf("expected Shoutrrr to accept Teams URL: %v", err)
+ }
+}
+
+func TestTeamsProviderMinimalConfig(t *testing.T) {
+ provider := TeamsProvider{}
+
+ urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `"}`)
+ if err != nil {
+ t.Fatalf("BuildShoutrrrUrls() error = %v", err)
+ }
+ if len(urls) != 1 {
+ t.Fatalf("expected 1 URL, got %d", len(urls))
+ }
+
+ parsed, err := url.Parse(urls[0])
+ if err != nil {
+ t.Fatalf("failed to parse URL %q: %v", urls[0], err)
+ }
+
+ query := parsed.Query()
+ if query.Get("title") != "" {
+ t.Fatalf("expected no title, got %q", query.Get("title"))
+ }
+}
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 7ec3020..8d40bf0 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -314,9 +314,14 @@
"notificationSlackAppSettingsNote": "Bot-Name und Symbol können in den Slack-App-Einstellungen unter api.slack.com konfiguriert werden.",
"validationNotificationSlackWebhookUrlRequired": "Webhook-URL ist erforderlich",
"validationNotificationSlackWebhookUrlInvalid": "Webhook-URL muss eine gültige Slack-Webhook-URL sein",
+ "title": "Titel",
+ "notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...",
+ "notificationTeamsTitlePlaceholder": "z. B. \"Deploy status\"",
+ "validationNotificationTeamsHostRequired": "Webhook-URL ist erforderlich",
+ "validationNotificationTeamsHostInvalid": "Webhook-URL muss eine gültige eingehende Microsoft-Teams-Webhook-URL sein",
"notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook",
"notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment",
- "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done",
+ "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status",
"shoutrrrUrl": "Shoutrrr-URL",
"shoutrrrDocs": "Shoutrrr-Doku",
"httpMethod": "HTTP-Methode",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 495aae7..6bd60a6 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -314,9 +314,14 @@
"notificationSlackAppSettingsNote": "The bot name and icon can be configured in your Slack app settings at api.slack.com.",
"validationNotificationSlackWebhookUrlRequired": "Webhook URL is required",
"validationNotificationSlackWebhookUrlInvalid": "Webhook URL must be a valid Slack webhook URL",
+ "title": "Title",
+ "notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...",
+ "notificationTeamsTitlePlaceholder": "e.g. \"Deploy status\"",
+ "validationNotificationTeamsHostRequired": "Webhook URL is required",
+ "validationNotificationTeamsHostInvalid": "Webhook URL must be a valid Microsoft Teams incoming webhook URL",
"notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook",
"notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment",
- "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done",
+ "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status",
"shoutrrrUrl": "Shoutrrr URL",
"shoutrrrDocs": "Shoutrrr docs",
"httpMethod": "HTTP Method",
diff --git a/frontend/src/components/dialogs/create-notification.tsx b/frontend/src/components/dialogs/create-notification.tsx
index f119e9d..8481b73 100644
--- a/frontend/src/components/dialogs/create-notification.tsx
+++ b/frontend/src/components/dialogs/create-notification.tsx
@@ -58,6 +58,12 @@ import {
isValidSlackWebhookUrl,
SlackWebhookUrlField,
} from "./slack-notification-builder";
+import {
+ buildTeamsNotificationConfig,
+ isValidTeamsHost,
+ TeamsHostField,
+ TeamsTitleField,
+} from "./teams-notification-builder";
import {
buildGotifyNotificationConfig,
GotifyAppTokenField,
@@ -98,6 +104,8 @@ const notificationBaseSchema = z.object({
gotifyPriority: z.string().trim(),
gotifyCustomPath: z.string().trim(),
slackWebhookUrl: z.string().trim(),
+ teamsHost: z.string().trim(),
+ teamsTitle: z.string().trim(),
webhookUrl: z.string().trim(),
webhookHeaders: z.string(),
customShoutrrrUrl: z.string().trim(),
@@ -193,6 +201,22 @@ const notificationSchema = notificationBaseSchema.superRefine((value, ctx) => {
}
}
+ if (value.type === "teams") {
+ if (value.teamsHost === "") {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamsHost"],
+ message: m.validationNotificationTeamsHostRequired(),
+ });
+ } else if (!isValidTeamsHost(value.teamsHost)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["teamsHost"],
+ message: m.validationNotificationTeamsHostInvalid(),
+ });
+ }
+ }
+
if (value.type === "webhook") {
if (value.webhookUrl === "") {
ctx.addIssue({
@@ -262,6 +286,18 @@ function buildNotificationConfig(value: NotificationFormValues): string {
return config;
}
+ if (value.type === "teams") {
+ const config = buildTeamsNotificationConfig({
+ teamsHost: value.teamsHost,
+ teamsTitle: value.teamsTitle,
+ });
+ if (!config) {
+ throw new Error(m.validationNotificationTeamsHostInvalid());
+ }
+
+ return config;
+ }
+
if (value.type === "gotify") {
const config = buildGotifyNotificationConfig({
gotifyServerUrl: value.gotifyServerUrl,
@@ -317,6 +353,8 @@ function useNotificationForm() {
gotifyPriority: "5",
gotifyCustomPath: "",
slackWebhookUrl: "",
+ teamsHost: "",
+ teamsTitle: "",
webhookUrl: "",
webhookHeaders: "",
customShoutrrrUrl: "",
@@ -558,6 +596,19 @@ function NotificationProviderStepContent({ form }: { form: NotificationFormApi }
);
}
+ if (type === "teams") {
+ return (
+
+ } />
+
+ }
+ />
+
+ );
+ }
+
if (type === "webhook") {
return (
@@ -660,6 +711,8 @@ export default function CreateNotificationDialog() {
gotifyPriority: "5",
gotifyCustomPath: "",
slackWebhookUrl: "",
+ teamsHost: "",
+ teamsTitle: "",
webhookUrl: "",
webhookHeaders: "",
customShoutrrrUrl: "",
diff --git a/frontend/src/components/dialogs/teams-notification-builder.tsx b/frontend/src/components/dialogs/teams-notification-builder.tsx
new file mode 100644
index 0000000..818ea54
--- /dev/null
+++ b/frontend/src/components/dialogs/teams-notification-builder.tsx
@@ -0,0 +1,107 @@
+import { Field, FieldError } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { m } from "@/lib/paraglide/messages";
+
+type TeamsBuilderFieldBinding = {
+ name: string;
+ state: {
+ value: string;
+ meta: {
+ isTouched: boolean;
+ isValid: boolean;
+ errors: unknown[];
+ };
+ };
+ handleBlur: () => void;
+ handleChange: (value: string) => void;
+};
+
+type FieldErrorList = Array<{ message?: string } | undefined>;
+
+export function TeamsHostField({ field }: { field: TeamsBuilderFieldBinding }) {
+ const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
+
+ return (
+
+
+ field.handleChange(event.target.value)}
+ placeholder={m.notificationTeamsHostPlaceholder()}
+ />
+ {isInvalid && }
+
+ );
+}
+
+export function TeamsTitleField({ field }: { field: TeamsBuilderFieldBinding }) {
+ const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
+
+ return (
+
+
+ field.handleChange(event.target.value)}
+ placeholder={m.notificationTeamsTitlePlaceholder()}
+ />
+ {isInvalid && }
+
+ );
+}
+
+export type TeamsNotificationConfig = {
+ host: string;
+ title?: string;
+};
+
+export type TeamsNotificationBuilderValues = {
+ teamsHost: string;
+ teamsTitle: string;
+};
+
+export function isValidTeamsHost(rawUrl: string): boolean {
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl.trim());
+ } catch {
+ return false;
+ }
+
+ if (parsed.protocol !== "https:") {
+ return false;
+ }
+
+ if (!/^[a-z0-9.-]+\.webhook\.office\.com$/i.test(parsed.hostname)) {
+ return false;
+ }
+
+ const pattern =
+ /^\/webhookb2\/[0-9a-f-]{36}@[0-9a-f-]{36}\/IncomingWebhook\/[0-9a-f]{32}\/[0-9a-f-]{36}\/[A-Za-z0-9_-]+\/?$/i;
+ return pattern.test(parsed.pathname);
+}
+
+export function buildTeamsNotificationConfig(
+ values: TeamsNotificationBuilderValues,
+): string | null {
+ const host = values.teamsHost.trim();
+ if (!isValidTeamsHost(host)) {
+ return null;
+ }
+
+ const config: TeamsNotificationConfig = { host };
+ const title = values.teamsTitle.trim();
+ if (title !== "") {
+ config.title = title;
+ }
+
+ return JSON.stringify(config);
+}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index 757d1ab..3cf07c4 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -1,7 +1,14 @@
import { fetcher } from "./api";
export type NotificationStatus = "unknown" | "success" | "error" | "healthy" | "unhealthy";
-export const notificationTypes = ["discord", "gotify", "slack", "webhook", "custom"] as const;
+export const notificationTypes = [
+ "discord",
+ "gotify",
+ "slack",
+ "teams",
+ "webhook",
+ "custom",
+] as const;
export type NotificationType = (typeof notificationTypes)[number];
export interface Notification {