diff --git a/docs/publish.md b/docs/publish.md index 11e33e61..905508fe 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -623,6 +623,35 @@ them with a comma, e.g. `tag1,tag2,tag3`. as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). +## Markdown +_Supported on:_ :material-firefox: + +You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/). 🤩 + +By default, messages sent to ntfy are rendered as plain text. To enable Markdown, set the `X-Markdown` header (or any of +its aliases: `Markdown`, or `md`) to `true` (or `1` or `yes`), or set the `Content-Type` header to `text/markdown`. + +Supported Markdown features: + +- **bold** (`**bold**`) +- *italic* (`*italic*`) +- [links](https://www.markdownguide.org/basic-syntax/#links) (`[links](https://www.markdownguide.org/basic-syntax/#links)`) +- [images](https://www.markdownguide.org/basic-syntax/#images) (`![images](https://www.markdownguide.org/basic-syntax/#images)`) +- [code blocks](https://www.markdownguide.org/basic-syntax/#code-blocks) (`` `code blocks` ``) +- [inline code](https://www.markdownguide.org/basic-syntax/#inline-code) (`` `inline code` ``) +- [headings](https://www.markdownguide.org/basic-syntax/#headings) (`# headings`) +- [lists](https://www.markdownguide.org/basic-syntax/#lists) (`- lists`) +- [blockquotes](https://www.markdownguide.org/basic-syntax/#blockquotes) (`> blockquotes`) +- [horizontal rules](https://www.markdownguide.org/basic-syntax/#horizontal-rules) (`---`) + +XXXXXXXXXXXXXXXXXXXXXx +- examples +- supported only on Web for now + +XXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx + + + ## Scheduled delivery _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -1004,6 +1033,7 @@ all the supported fields: | `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | | `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) | +| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | | `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 | diff --git a/server/server.go b/server/server.go index 60a2fb30..0ab36524 100644 --- a/server/server.go +++ b/server/server.go @@ -1010,7 +1010,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } - contentType, markdown := readParam(r, "content-type"), readBoolParam(r, false, "x-markdown", "markdown", "md") + contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } @@ -1789,6 +1789,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Icon != "" { r.Header.Set("X-Icon", m.Icon) } + if m.Markdown { + r.Header.Set("X-Markdown", "yes") + } if len(m.Actions) > 0 { actionsStr, err := json.Marshal(m.Actions) if err != nil { diff --git a/server/server_test.go b/server/server_test.go index e9ff6fcb..46751acd 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1518,6 +1518,39 @@ func TestServer_PublishActions_AndPoll(t *testing.T) { require.Equal(t, "target_temp_f=65", m.Actions[1].Body) } +func TestServer_PublishMarkdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "_underline this_", map[string]string{ + "Content-Type": "text/markdown", + }) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "_underline this_", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + +func TestServer_PublishMarkdown_QueryParam(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?md=1", "_underline this_", nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "_underline this_", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + +func TestServer_PublishMarkdown_NotMarkdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "_underline this_", map[string]string{ + "Content-Type": "not-markdown", + }) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "", m.ContentType) +} + func TestServer_PublishAsJSON(t *testing.T) { s := newTestServer(t, newTestConfig(t)) body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` + @@ -1535,12 +1568,25 @@ func TestServer_PublishAsJSON(t *testing.T) { require.Equal(t, "google.pdf", m.Attachment.Name) require.Equal(t, "http://ntfy.sh", m.Click) require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon) + require.Equal(t, "", m.ContentType) require.Equal(t, 4, m.Priority) require.True(t, m.Time > time.Now().Unix()+29*60) require.True(t, m.Time < time.Now().Unix()+31*60) } +func TestServer_PublishAsJSON_Markdown(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + body := `{"topic":"mytopic","message":"**This is bold**","markdown":true}` + response := request(t, s, "PUT", "/", body, nil) + require.Equal(t, 200, response.Code) + + m := toMessage(t, response.Body.String()) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "**This is bold**", m.Message) + require.Equal(t, "text/markdown", m.ContentType) +} + func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) { // Publishing as JSON follows a different path. This ensures that rate // limiting works for this endpoint as well diff --git a/server/types.go b/server/types.go index 279f4ce8..eeb566fc 100644 --- a/server/types.go +++ b/server/types.go @@ -101,6 +101,7 @@ type publishMessage struct { Icon string `json:"icon"` Actions []action `json:"actions"` Attach string `json:"attach"` + Markdown bool `json:"markdown"` Filename string `json:"filename"` Email string `json:"email"` Call string `json:"call"` diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index ccd2deb9..bd319dc5 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -192,7 +192,7 @@ const MarkdownContainer = styled("div")` ol { padding-inline: 1rem; } - + img { max-width: 100%; }