diff --git a/notify/shoutrrr/constants.go b/notify/shoutrrr/constants.go index c2e261d49..940a114e3 100644 --- a/notify/shoutrrr/constants.go +++ b/notify/shoutrrr/constants.go @@ -15,12 +15,16 @@ // Package shoutrrr provides the shoutrrr notification service to services. package shoutrrr +// smtpAuthUnknown is the smtp 'auth' value meaning "no method given". +// Shoutrrr resolves it to Plain/None while parsing the URL, so it must not be sent as a param. +const smtpAuthUnknown = "Unknown" + var ( barkNtfyParamScheme = []string{"http", "https"} barkParamSound = []string{"alarm", "anticipate", "bell", "birdsong", "bloom", "calypso", "chime", "choo", "descent", "electronic", "fanfare", "glass", "gotosleep", "healthnotification", "horn", "ladder", "mailsent", "minuet", "multiwaynotification", "newmail", "newsflash", "noir", "paymentsuccess", "shake", "sherwoodforest", "silence", "spell", "suspense", "telegraph", "tiptoes", "typewriters", "update"} genericParamRequestmethod = []string{"CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE"} ntfyParamPriority = []string{"min", "low", "default", "high", "max"} - smtpParamAuth = []string{"None", "Unknown", "Plain", "CramMD5", "OAuth2"} + smtpParamAuth = []string{"None", smtpAuthUnknown, "Plain", "CRAMMD5", "OAuth2"} smtpParamEncryption = []string{"Auto", "ExplicitTLS", "ImplicitTLS", "None"} telegramParamParsemode = []string{"None", "HTML", "Markdown"} zulipParamType = []string{"channel", "direct"} diff --git a/notify/shoutrrr/shoutrrr.go b/notify/shoutrrr/shoutrrr.go index f1902d02c..5b9af5bf2 100644 --- a/notify/shoutrrr/shoutrrr.go +++ b/notify/shoutrrr/shoutrrr.go @@ -63,17 +63,27 @@ func (s *Shoutrrr) BuildURL() (url string) { query, ) case "smtp": - // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X] + // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X][&timeout=Z][&encryption=E][&skiptlsverify=S] login := s.GetURLField("password") login = s.GetURLField("username") + util.ValueUnlessZero(login, ":"+login) port := s.GetURLField("port") fromAddress := s.GetParam("fromaddress") fromName := s.GetParam("fromname") toAddresses := s.GetParam("toaddresses") + // These must travel in the URL rather than the params. + // timeout - Shoutrrr doesn't parse params as durations. + // encryption/skiptlsverify - Shoutrrr dials with the config it parsed from + // the URL, applying the params only to a clone used afterwards. + timeout := s.GetParam("timeout") + encryption := s.GetParam("encryption") + skipTLSVerify := s.GetParam("skiptlsverify") query := buildQuery( queryParam("fromaddress", fromAddress), queryParam("fromname", fromName), queryParam("toaddresses", toAddresses), + queryParam("timeout", timeout), + queryParam("encryption", encryption), + queryParam("skiptlsverify", skipTLSVerify), ) url = fmt.Sprintf( @@ -354,6 +364,16 @@ func (s *Shoutrrr) BuildParams(info serviceinfo.ServiceInfo) *types.Params { params[key] = util.TemplateString(value, info) } + if s.GetType() == "smtp" { + // timeout is carried in the URL by BuildURL. + delete(params, "timeout") + // 'Unknown' auth means "no method given" and Shoutrrr + // resolves that to Plain/None when it parses the URL. + if strings.EqualFold(params["auth"], smtpAuthUnknown) { + delete(params, "auth") + } + } + return ¶ms } diff --git a/notify/shoutrrr/shoutrrr_test.go b/notify/shoutrrr/shoutrrr_test.go index ff7e96f84..f13e44213 100644 --- a/notify/shoutrrr/shoutrrr_test.go +++ b/notify/shoutrrr/shoutrrr_test.go @@ -19,8 +19,14 @@ package shoutrrr import ( "errors" "fmt" + "strings" "testing" + goshoutrrr "github.com/nicholas-fedor/shoutrrr" + goshoutrrrFormat "github.com/nicholas-fedor/shoutrrr/pkg/format" + goshoutrrrSMTP "github.com/nicholas-fedor/shoutrrr/pkg/services/email/smtp" + goshoutrrrTypes "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/release-argus/Argus/config/decode" "github.com/release-argus/Argus/internal/logx" "github.com/release-argus/Argus/internal/test" @@ -490,6 +496,39 @@ func TestShoutrrr_BuildURL(t *testing.T) { "toaddresses": "TO_ADDRESS1,TO_ADDRESS2", }, }, + { + name: "smtp/base + login + port + timeout", + sType: "smtp", + want: "smtp://USERNAME:PASSWORD@HOST:587/?fromaddress=FROMADDRESS&toaddresses=TO_ADDRESS1%2CTO_ADDRESS2&timeout=0h0m10s", + urlFields: map[string]string{ + "host": "HOST", + "username": "USERNAME", + "password": "PASSWORD", + "port": "587", + }, + params: map[string]string{ + "fromaddress": "FROMADDRESS", + "toaddresses": "TO_ADDRESS1,TO_ADDRESS2", + "timeout": "0h0m10s", + }, + }, + { + name: "smtp/base + login + port + encryption + skiptlsverify", + sType: "smtp", + want: "smtp://USERNAME:PASSWORD@HOST:587/?fromaddress=FROMADDRESS&toaddresses=TO_ADDRESS1%2CTO_ADDRESS2&encryption=ImplicitTLS&skiptlsverify=yes", + urlFields: map[string]string{ + "host": "HOST", + "username": "USERNAME", + "password": "PASSWORD", + "port": "587", + }, + params: map[string]string{ + "fromaddress": "FROMADDRESS", + "toaddresses": "TO_ADDRESS1,TO_ADDRESS2", + "encryption": "ImplicitTLS", + "skiptlsverify": "yes", + }, + }, { name: "teams/base", sType: "teams", @@ -786,6 +825,330 @@ func TestShoutrrr_BuildParams(t *testing.T) { } } +func TestShoutrrr_BuildParams__SMTPAuth(t *testing.T) { + // GIVEN: Shoutrrrs with an 'auth' Param, of an SMTP and a non-SMTP type. + svcInfo := serviceinfo.ServiceInfo{ID: "service_id"} + tests := []struct { + name string + sType string + auth string + wantKept bool + }{ + { + name: "smtp/'Unknown' is dropped so Shoutrrr keeps its resolved method", + sType: "smtp", + auth: "Unknown", + }, + { + name: "smtp/'unknown' is dropped case-insensitively", + sType: "smtp", + auth: "uNkNoWn", + }, + { + name: "smtp/an explicit method is kept", + sType: "smtp", + auth: "Plain", + wantKept: true, + }, + { + name: "non-smtp/'Unknown' is left alone", + sType: "ntfy", + auth: "Unknown", + wantKept: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + shoutrrr := testShoutrrr(false, false) + shoutrrr.Type = tc.sType + shoutrrr.Main.Type = tc.sType + shoutrrr.Params["fromaddress"] = "FROM" + shoutrrr.Params["toaddresses"] = "TO" + shoutrrr.Params["auth"] = tc.auth + + // WHEN: BuildParams is called. + params := *shoutrrr.BuildParams(svcInfo) + + // THEN: 'Unknown' only survives for non-SMTP types. + if _, kept := params["auth"]; kept != tc.wantKept { + t.Errorf( + "%s\nShoutrrr.BuildParams() 'auth' presence mismatch\ngot: %t\nwant: %t", + packageName, kept, tc.wantKept, + ) + } + + if tc.sType != "smtp" { + return + } + + // AND: applying those Params the way Shoutrrr does at send time + // leaves an auth method it can act on. + sender, err := goshoutrrr.CreateSenderWithOptions(goshoutrrrTypes.SenderOptions{}) + if err != nil { + t.Fatalf( + "%s\nfailed to create the sender\n%v", + packageName, err, + ) + } + service, err := sender.Locate(shoutrrr.BuildURL()) + if err != nil { + t.Fatalf( + "%s\nShoutrrr rejected the built URL\n%v", + packageName, err, + ) + } + smtpService, ok := service.(*goshoutrrrSMTP.Service) + if !ok { + t.Fatalf( + "%s\nwant a *smtp.Service\ngot: %T", + packageName, service, + ) + } + + config := smtpService.Config.Clone() + resolver := goshoutrrrFormat.NewPropKeyResolver(&config) + if err := resolver.UpdateConfigFromParams(&config, ¶ms); err != nil { + t.Fatalf( + "%s\nShoutrrr rejected the Params\n%v", + packageName, err, + ) + } + if got := config.Auth.String(); got == smtpAuthUnknown { + t.Errorf( + "%s\nauth left unresolved after applying the Params\ngot: %q\nwant: anything but %q", + packageName, got, smtpAuthUnknown, + ) + } + }) + } +} + +// TestShoutrrr_SMTPURLCarriedParams covers the SMTP Params that Argus has to carry +// in the URL because Shoutrrr will not honour them from the send-params: +// - 'timeout' reflects onto a time.Duration field via strconv.ParseInt, so a +// duration string errors on the Params path. +// - 'encryption'/'skiptlsverify' are read by getClientConnection from the Config +// parsed out of the URL, not from the clone the Params are applied to. +// +// Drop this test (and the queryParam calls in BuildURL), once Shoutrrr handles +// both upstream. +func TestShoutrrr_SMTPURLCarriedParams(t *testing.T) { + // GIVEN: Shoutrrrs with a Param that has to reach Shoutrrr through the URL. + svcInfo := serviceinfo.ServiceInfo{ID: "service_id"} + tests := []struct { + name string + sType string + layer string // "" (the Shoutrrr itself), "main", "defaults" or "hardDefaults". + param string + value string + wantKept bool // Param survives BuildParams. + wantInURL bool // Param is carried in the URL. + want string // Value Shoutrrr parses out of the URL for this param. + }{ + { + name: "smtp/timeout, moves to the URL", + sType: "smtp", + param: "timeout", + value: "0h0m10s", + wantInURL: true, + want: "10s", + }, + { + name: "smtp/timeout, from Main", + sType: "smtp", + layer: "main", + param: "timeout", + value: "0h0m10s", + wantInURL: true, + want: "10s", + }, + { + name: "smtp/timeout, from Defaults", + sType: "smtp", + layer: "defaults", + param: "timeout", + value: "0m10s", + wantInURL: true, + want: "10s", + }, + { + name: "smtp/timeout, from HardDefaults", + sType: "smtp", + layer: "hardDefaults", + param: "timeout", + value: "10s", + wantInURL: true, + want: "10s", + }, + { + name: "smtp/timeout, empty defers to Shoutrrr", + sType: "smtp", + param: "timeout", + value: "", + want: "10s", + }, + { + name: "non-smtp/timeout, left in the Params", + sType: "ntfy", + param: "timeout", + value: "0h0m10s", + wantKept: true, + }, + { + name: "non-smtp/timeout, from HardDefaults, left in the Params", + sType: "ntfy", + layer: "hardDefaults", + param: "timeout", + value: "10s", + wantKept: true, + }, + { + name: "smtp/encryption, implicit TLS", + sType: "smtp", + param: "encryption", + value: "ImplicitTLS", + wantKept: true, + wantInURL: true, + want: "ImplicitTLS", + }, + { + name: "smtp/encryption, explicit TLS", + sType: "smtp", + param: "encryption", + value: "ExplicitTLS", + wantKept: true, + wantInURL: true, + want: "ExplicitTLS", + }, + { + name: "smtp/encryption, empty defers to Shoutrrr", + sType: "smtp", + param: "encryption", + value: "", + wantKept: true, + want: "Auto", + }, + { + name: "smtp/skiptlsverify, enabled", + sType: "smtp", + param: "skiptlsverify", + value: "yes", + wantKept: true, + wantInURL: true, + want: "true", + }, + { + name: "smtp/skiptlsverify, disabled", + sType: "smtp", + param: "skiptlsverify", + value: "no", + wantKept: true, + wantInURL: true, + want: "false", + }, + { + name: "smtp/skiptlsverify, empty defers to Shoutrrr", + sType: "smtp", + param: "skiptlsverify", + value: "", + wantKept: true, + want: "false", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + shoutrrr := testShoutrrr(false, false) + shoutrrr.Type = tc.sType + shoutrrr.Main.Type = tc.sType + shoutrrr.Params["fromaddress"] = "FROM" + shoutrrr.Params["toaddresses"] = "TO" + switch tc.layer { + case "main": + shoutrrr.Main.Params[tc.param] = tc.value + case "defaults": + shoutrrr.Defaults.Params[tc.param] = tc.value + case "hardDefaults": + shoutrrr.HardDefaults.Params[tc.param] = tc.value + default: + shoutrrr.Params[tc.param] = tc.value + } + + // WHEN: BuildParams and BuildURL are called. + params := *shoutrrr.BuildParams(svcInfo) + url := shoutrrr.BuildURL() + + // THEN: only an SMTP 'timeout' is dropped from the Params, + // as that is the one Shoutrrr errors on rather than ignores. + if _, kept := params[tc.param]; kept != tc.wantKept { + t.Errorf( + "%s\nShoutrrr.BuildParams() %q presence mismatch\ngot: %t\nwant: %t", + packageName, tc.param, kept, tc.wantKept, + ) + } + + if tc.sType != "smtp" { + return + } + + // AND: it is carried in the URL when set, and absent when not. + if inURL := strings.Contains(url, tc.param+"="); inURL != tc.wantInURL { + t.Errorf( + "%s\nShoutrrr.BuildURL() %q presence mismatch\ngot: %t\nwant: %t\nurl: %q", + packageName, tc.param, inURL, tc.wantInURL, url, + ) + } + + // AND: Shoutrrr parses it into the Config that it dials with. + sender, err := goshoutrrr.CreateSenderWithOptions(goshoutrrrTypes.SenderOptions{}) + if err != nil { + t.Fatalf("%s\nfailed to create the sender\n%v", + packageName, err) + } + service, err := sender.Locate(url) + if err != nil { + t.Fatalf("%s\nShoutrrr rejected the built URL %q\n%v", + packageName, url, err) + } + smtpService, ok := service.(*goshoutrrrSMTP.Service) + if !ok { + t.Fatalf("%s\nwant a *smtp.Service\ngot: %T", + packageName, service) + } + + if got := smtpParamValue(t, smtpService.Config, tc.param); got != tc.want { + t.Errorf( + "%s\nShoutrrr parsed %q as\ngot: %q\nwant: %q\nurl: %q", + packageName, tc.param, got, tc.want, url, + ) + } + }) + } +} + +// smtpParamValue returns the value Shoutrrr parsed for param out of the URL. +func smtpParamValue(t *testing.T, config *goshoutrrrSMTP.Config, param string) string { + t.Helper() + + switch param { + case "encryption": + return config.Encryption.String() + case "skiptlsverify": + return fmt.Sprint(config.SkipTLSVerify) + case "timeout": + return config.Timeout.String() + } + + t.Fatalf("%s\nsmtpParamValue: unhandled param %q", + packageName, param) + return "" +} + func TestShoutrrr_GetSender(t *testing.T) { type wants struct { err bool diff --git a/notify/shoutrrr/verify.go b/notify/shoutrrr/verify.go index d487b3105..74110f521 100644 --- a/notify/shoutrrr/verify.go +++ b/notify/shoutrrr/verify.go @@ -388,6 +388,13 @@ func (b *Base) correctSelf(shoutrrrType string) (changed bool) { b.setParam("skiptlsverification", "") changed = true } + // Timeout, treat integers as seconds by default. + if timeout := b.GetParam("timeout"); timeout != "" { + if _, err := strconv.Atoi(timeout); err == nil { + b.setParam("timeout", timeout+"s") + changed = true + } + } case "zulip": // BotMail, replace the @ with a %40 - https://containrrr.dev/shoutrrr/v0.5/services/zulip/ if botMail := b.getURLField("botmail"); strings.Contains(botMail, "@") { @@ -587,7 +594,7 @@ func (s *Shoutrrr) checkValuesURLFields() error { ) } case "smtp": - // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X] + // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X][&timeout=Z][&encryption=E][&skiptlsverify=S] if s.GetURLField("host") == "" { errs = append( errs, @@ -671,7 +678,7 @@ func (s *Shoutrrr) checkValuesURLFields() error { ) } case "mattermost": - // mattermost://[username@]host[:port][/path]/token[/channel] + // mattermost://[username@]host[:port]/token[/channel] if s.GetURLField("host") == "" { errs = append( errs, @@ -763,7 +770,7 @@ func (s *Shoutrrr) checkValuesURLFields() error { ) } case "rocketchat": - // rocketchat://[username@]host[:port][/path]/tokenA/tokenB/channel + // rocketchat://[username@]host[:port]/tokenA/tokenB/channel if s.GetURLField("host") == "" { errs = append( errs, @@ -940,7 +947,7 @@ func (s *Shoutrrr) checkValuesParams() error { ) } case "smtp": - // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X] + // smtp://username:password@host[:port]/?fromaddress=X&toaddresses=Y[&fromname=X][&timeout=Z][&encryption=E][&skiptlsverify=S] if s.GetParam("fromaddress") == "" { errs = append( errs, @@ -1000,6 +1007,24 @@ func (b *Base) checkValuesParams(itemType string) error { errs = append(errs, e) } + switch itemType { + case "smtp": + // Timeout, must be a positive duration. + // Zero/negative gives Shoutrrr an already-expired context, failing every send. + if timeout := b.GetParam("timeout"); timeout != "" { + if d, err := time.ParseDuration(timeout); err != nil || d <= 0 { + errs = append( + errs, + &decode.ErrField{ + Key: "timeout", + Value: timeout, + Description: "use a positive 'AhBmCs' duration, e.g. '10s'", + }, + ) + } + } + } + // Params.* for key, value := range b.Params { if !util.CheckTemplate(value) { diff --git a/notify/shoutrrr/verify_test.go b/notify/shoutrrr/verify_test.go index 8dfbe6020..d8a79b526 100644 --- a/notify/shoutrrr/verify_test.go +++ b/notify/shoutrrr/verify_test.go @@ -1443,6 +1443,39 @@ func TestBase_CorrectSelf(t *testing.T) { }, renamedVar: true, }, + { + name: "smtp/timeout, valid duration", + sType: "smtp", + mapTarget: "params", + startAs: map[string]string{ + "timeout": "0h0m10s", + }, + want: map[string]string{ + "timeout": "0h0m10s", + }, + }, + { + name: "smtp/timeout, integer treated as seconds", + sType: "smtp", + mapTarget: "params", + startAs: map[string]string{ + "timeout": "10", + }, + want: map[string]string{ + "timeout": "10s", + }, + }, + { + name: "smtp/timeout, invalid left for CheckValues", + sType: "smtp", + mapTarget: "params", + startAs: map[string]string{ + "timeout": "abc", + }, + want: map[string]string{ + "timeout": "abc", + }, + }, { name: "zulip/botmail, not urlEncoded", sType: "zulip", @@ -2795,6 +2828,46 @@ func TestShoutrrr_CheckValuesParams(t *testing.T) { }, errRegex: `^toaddresses: .*$`, }, + { + name: "smtp/valid timeout", + sType: "smtp", + params: map[string]string{ + "fromaddress": "bash", + "toaddresses": "bosh", + "timeout": "0h0m10s", + }, + errRegex: `^$`, + }, + { + name: "smtp/invalid timeout", + sType: "smtp", + params: map[string]string{ + "fromaddress": "bash", + "toaddresses": "bosh", + "timeout": "abc", + }, + errRegex: `^timeout: "abc" .*positive.*duration.*$`, + }, + { + name: "smtp/zero timeout", + sType: "smtp", + params: map[string]string{ + "fromaddress": "bash", + "toaddresses": "bosh", + "timeout": "0s", + }, + errRegex: `^timeout: "0s" .*positive.*duration.*$`, + }, + { + name: "smtp/negative timeout", + sType: "smtp", + params: map[string]string{ + "fromaddress": "bash", + "toaddresses": "bosh", + "timeout": "-5s", + }, + errRegex: `^timeout: "-5s" .*positive.*duration.*$`, + }, { name: "smtp/valid", sType: "smtp", @@ -2995,6 +3068,45 @@ func TestBase_CheckValuesParams(t *testing.T) { }, errRegex: `^auth: "-" .*OAuth2.*$`, }, + { + name: "smtp/valid timeout", + itemType: "smtp", + params: map[string]string{ + "timeout": "0h0m10s", + }, + errRegex: `^$`, + }, + { + name: "smtp/invalid timeout", + itemType: "smtp", + params: map[string]string{ + "timeout": "abc", + }, + errRegex: `^timeout: "abc" .*positive.*duration.*$`, + }, + { + name: "smtp/zero timeout", + itemType: "smtp", + params: map[string]string{ + "timeout": "0s", + }, + errRegex: `^timeout: "0s" .*positive.*duration.*$`, + }, + { + name: "smtp/negative timeout", + itemType: "smtp", + params: map[string]string{ + "timeout": "-5s", + }, + errRegex: `^timeout: "-5s" .*positive.*duration.*$`, + }, + { + name: "non-smtp/timeout not checked", + params: map[string]string{ + "timeout": "abc", + }, + errRegex: `^$`, + }, } for _, tc := range tests { @@ -3267,8 +3379,8 @@ func TestBase_ValidateParamSelect(t *testing.T) { { name: "invalid value returns error and leaves unchanged", value: "nope", - allowed: []string{"None", "Unknown", "Plain", "CramMD5", "OAuth2"}, - errRegex: "^" + key + `: "nope" .*'CramMD5'.*$`, + allowed: []string{"None", "Unknown", "Plain", "CRAMMD5", "OAuth2"}, + errRegex: "^" + key + `: "nope" .*'CRAMMD5'.*$`, want: "nope", }, } diff --git a/web/ui/react-app/src/components/modals/service-edit/notify-types/smtp.tsx b/web/ui/react-app/src/components/modals/service-edit/notify-types/smtp.tsx index 53eeca3af..df1a5cc2c 100644 --- a/web/ui/react-app/src/components/modals/service-edit/notify-types/smtp.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/notify-types/smtp.tsx @@ -121,9 +121,8 @@ const SMTP = ({ name, main }: { name: string; main?: NotifySMTPSchema }) => { defaultVal={defaults?.params?.timeout} label="Timeout" name={`${name}.params.timeout`} - required tooltip={{ - content: 'Timeout for send operations', + content: 'Timeout for send operations, e.g. 10s', type: 'string', }} /> diff --git a/web/ui/react-app/src/utils/api/types/config/notify/index.ts b/web/ui/react-app/src/utils/api/types/config/notify/index.ts index 4bbb11f55..c462b7c0c 100644 --- a/web/ui/react-app/src/utils/api/types/config/notify/index.ts +++ b/web/ui/react-app/src/utils/api/types/config/notify/index.ts @@ -24,6 +24,7 @@ import type { smtpEncryptionOptions, } from '@/utils/api/types/config/notify/smtp'; import type { TelegramParsemode } from '@/utils/api/types/config/notify/telegram'; +import type { ZulipType } from '@/utils/api/types/config/notify/zulip'; import type { EmptyObject, Headers } from '@/utils/api/types/config/shared'; export type NotifyTypesMap = { @@ -150,8 +151,11 @@ export type NotifyGotify = NotifyBase & { params?: { date?: string; disabletls?: string; + extras?: string; + insecureskipverify?: string; priority?: string; title?: string; + useheader?: string; }; }; @@ -214,7 +218,9 @@ export type NotifyMatterMost = NotifyBase & { username?: string; }; params?: { + disabletls?: string; icon?: string; + title?: string; }; }; @@ -249,6 +255,7 @@ export type NotifyNtfy = NotifyBase & { cache?: string; click?: string; delay?: string; + disabletlsverification?: string; email?: string; filename?: string; firebase?: string; @@ -357,8 +364,10 @@ export type NotifySMTP = NotifyBase & { encryption?: (typeof smtpEncryptionOptions)[number]['value']; fromaddress?: string; fromname?: string; + requirestarttls?: string; skiptlsverify?: string; subject?: string; + timeout?: string; toaddresses?: string; usehtml?: string; usestarttls?: string; @@ -403,10 +412,15 @@ export type NotifyZulip = NotifyBase & { botkey?: string; botmail?: string; host?: string; + port?: string; }; params?: { + read_by_sender?: string; stream?: string; + title?: string; + to?: string; topic?: string; + type?: ZulipType; }; };