mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
Add timezone support for delayed notifications
This commit is contained in:
1 parent
10cb6506f8
commit
7961282490
7 files changed
+148
-1
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user