Add timezone support for delayed notifications

This commit is contained in:
Bee mines committed 2026-09-21 14:27:20 +08:00
1 parent 10cb6506f8
commit 7961282490
7 files changed
+148 -1

No files matched your search

+15
View File
@@ -2595,6 +2595,19 @@ Usage is pretty straight forward. You can set the delivery time using the `X-Del
`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`,
[and more](https://github.com/olebedev/when)).
To interpret a natural language time in a specific timezone, set the `X-Timezone` header
(alias: `Timezone`), the `timezone` query parameter, or the `timezone` JSON field to an
IANA timezone name such as `Asia/Tokyo` or `America/New_York`:
```
curl -H "At: tomorrow, 10am" -H "Timezone: Asia/Tokyo" -d "Good morning" ntfy.sh/hello
```
Without a timezone, the server's local timezone is used as before. The web app sends your
browser's timezone automatically when scheduling a message. Relative durations such as
`1h` and Unix timestamps still refer to the same instant regardless of timezone. Invalid
timezone names return HTTP 400 when a delay is supplied; without a delay, the timezone is ignored.
As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured
with the `message-delay-limit` option.
@@ -3719,6 +3732,7 @@ all the supported fields:
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `timezone` | - | *string* | `Asia/Tokyo` | Timezone for natural language delayed delivery times |
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
@@ -4957,6 +4971,7 @@ table in their canonical form.
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
| `X-Timezone` | `Timezone` | IANA timezone for [delayed delivery](#scheduled-delivery) |
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
+1
View File
@@ -151,6 +151,7 @@ var (
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPBadRequestTimezoneInvalid = &errHTTP{40059, http.StatusBadRequest, "invalid request: invalid timezone", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
+12 -1
View File
@@ -1235,7 +1235,15 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
if call != "" {
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
now := time.Now()
if timezone := readParam(r, "x-timezone", "timezone"); timezone != "" {
location, err := time.LoadLocation(timezone)
if err != nil {
return false, false, "", "", "", false, "", errHTTPBadRequestTimezoneInvalid
}
now = now.In(location)
}
delay, err := util.ParseFutureTime(delayStr, now)
if err != nil {
return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
@@ -2096,6 +2104,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Delay != "" {
r.Header.Set("X-Delay", m.Delay)
}
if m.Timezone != "" {
r.Header.Set("X-Timezone", m.Timezone)
}
if m.Call != "" {
r.Header.Set("X-Call", m.Call)
}
+85
View File
@@ -0,0 +1,85 @@
package server
import (
"fmt"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestServer_PublishTimezone(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
for _, zone := range []string{"Asia/Tokyo", "America/New_York", "UTC"} {
for _, transport := range []string{"header", "x-header", "query", "json"} {
t.Run(zone+"/"+transport, func(t *testing.T) {
s := newTestServer(t, newTestConfig(t, databaseURL))
location, err := time.LoadLocation(zone)
require.NoError(t, err)
tomorrow := func() int64 {
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), now.Day()+1, 10, 0, 0, 0, location).Unix()
}
path, body := "/mytopic", "a message"
headers := map[string]string{"Delay": "tomorrow 10am"}
switch transport {
case "header":
headers["Timezone"] = zone
case "x-header":
headers["X-Timezone"] = zone
case "query":
path += "?timezone=" + url.QueryEscape(zone)
case "json":
path = "/"
body = fmt.Sprintf(`{"topic":"mytopic","message":"a message","delay":"tomorrow 10am","timezone":%q}`, zone)
headers = nil
}
before := tomorrow()
response := request(t, s, "POST", path, body, headers)
require.Equal(t, 200, response.Code, response.Body.String())
message := toMessage(t, response.Body.String())
require.Contains(t, []int64{before, tomorrow()}, message.Time)
})
}
}
})
}
func TestServer_PublishTimezoneRelativeAndUnix(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, delay := range []string{"1h", fmt.Sprint(time.Now().Add(time.Hour).Unix())} {
before := time.Now().Add(time.Hour).Unix()
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": delay, "Timezone": "Asia/Tokyo",
})
require.Equal(t, 200, response.Code, response.Body.String())
require.InDelta(t, before, toMessage(t, response.Body.String()).Time, 2)
}
})
}
func TestServer_PublishTimezoneInvalid(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
for _, zone := range []string{"Unknown/Timezone", "../etc/passwd", "+09:00"} {
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Delay": "1h", "Timezone": zone,
})
require.Equal(t, 400, response.Code)
require.Equal(t, errHTTPBadRequestTimezoneInvalid, toHTTPError(t, response.Body.String()))
}
})
}
func TestServer_PublishTimezoneWithoutDelay(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"Timezone": "Unknown/Timezone",
})
require.Equal(t, 200, response.Code)
require.InDelta(t, time.Now().Unix(), toMessage(t, response.Body.String()).Time, 2)
})
}
+1
View File
@@ -27,6 +27,7 @@ type publishMessage struct {
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
Timezone string `json:"timezone"`
}
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
+30
View File
@@ -0,0 +1,30 @@
package util
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestParseFutureTime_TimezoneDST(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
for _, test := range []struct {
name string
now time.Time
want time.Time
}{
{"spring", time.Date(2026, 3, 7, 10, 0, 0, 0, location), time.Date(2026, 3, 8, 14, 0, 0, 0, time.UTC)},
{"fall", time.Date(2026, 10, 31, 10, 0, 0, 0, location), time.Date(2026, 11, 1, 15, 0, 0, 0, time.UTC)},
} {
t.Run(test.name, func(t *testing.T) {
got, err := ParseFutureTime("tomorrow 10am", test.now)
require.NoError(t, err)
require.True(t, test.want.Equal(got), "want %s, got %s", test.want, got)
got, err = ParseFutureTime("24h", test.now)
require.NoError(t, err)
require.Equal(t, 24*time.Hour, got.Sub(test.now))
})
}
}
+4
View File
@@ -149,6 +149,10 @@ const PublishDialog = (props) => {
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (timezone) {
url.searchParams.append("timezone", timezone);
}
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());