notification icons
This commit is contained in:
		
							parent
							
								
									cbcd0e3f0d
								
							
						
					
					
						commit
						d519fd999b
					
				
					 14 changed files with 197 additions and 13 deletions
				
			
		| 
						 | 
				
			
			@ -52,6 +52,7 @@ var (
 | 
			
		|||
	errHTTPBadRequestActionsInvalid                  = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
 | 
			
		||||
	errHTTPBadRequestMatrixMessageInvalid            = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
 | 
			
		||||
	errHTTPBadRequestMatrixPushkeyBaseURLMismatch    = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
 | 
			
		||||
	errHTTPBadRequestIconURLInvalid                  = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
 | 
			
		||||
	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
 | 
			
		||||
	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
 | 
			
		||||
	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,44 +38,47 @@ const (
 | 
			
		|||
			attachment_url TEXT NOT NULL,
 | 
			
		||||
			sender TEXT NOT NULL,
 | 
			
		||||
			encoding TEXT NOT NULL,
 | 
			
		||||
			published INT NOT NULL
 | 
			
		||||
			published INT NOT NULL,
 | 
			
		||||
			icon_url TEXT NOT NULL,
 | 
			
		||||
			icon_type TEXT NOT NULL,
 | 
			
		||||
			icon_size INT NOT NULL
 | 
			
		||||
		);
 | 
			
		||||
		CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
 | 
			
		||||
		CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
 | 
			
		||||
		COMMIT;
 | 
			
		||||
	`
 | 
			
		||||
	insertMessageQuery = `
 | 
			
		||||
		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) 
 | 
			
		||||
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published, icon_url, icon_type, icon_size)
 | 
			
		||||
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
	`
 | 
			
		||||
	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1`
 | 
			
		||||
	selectRowIDFromMessageID     = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
 | 
			
		||||
	selectMessagesSinceTimeQuery = `
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size
 | 
			
		||||
		FROM messages 
 | 
			
		||||
		WHERE topic = ? AND time >= ? AND published = 1
 | 
			
		||||
		ORDER BY time, id
 | 
			
		||||
	`
 | 
			
		||||
	selectMessagesSinceTimeIncludeScheduledQuery = `
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size
 | 
			
		||||
		FROM messages 
 | 
			
		||||
		WHERE topic = ? AND time >= ?
 | 
			
		||||
		ORDER BY time, id
 | 
			
		||||
	`
 | 
			
		||||
	selectMessagesSinceIDQuery = `
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size
 | 
			
		||||
		FROM messages 
 | 
			
		||||
		WHERE topic = ? AND id > ? AND published = 1 
 | 
			
		||||
		ORDER BY time, id
 | 
			
		||||
	`
 | 
			
		||||
	selectMessagesSinceIDIncludeScheduledQuery = `
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size
 | 
			
		||||
		FROM messages 
 | 
			
		||||
		WHERE topic = ? AND (id > ? OR published = 0)
 | 
			
		||||
		ORDER BY time, id
 | 
			
		||||
	`
 | 
			
		||||
	selectMessagesDueQuery = `
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 | 
			
		||||
		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, icon_url, icon_type, icon_size
 | 
			
		||||
		FROM messages 
 | 
			
		||||
		WHERE time <= ? AND published = 0
 | 
			
		||||
		ORDER BY time, id
 | 
			
		||||
| 
						 | 
				
			
			@ -89,7 +92,7 @@ const (
 | 
			
		|||
 | 
			
		||||
// Schema management queries
 | 
			
		||||
const (
 | 
			
		||||
	currentSchemaVersion          = 7
 | 
			
		||||
	currentSchemaVersion          = 8
 | 
			
		||||
	createSchemaVersionTableQuery = `
 | 
			
		||||
		CREATE TABLE IF NOT EXISTS schemaVersion (
 | 
			
		||||
			id INT PRIMARY KEY,
 | 
			
		||||
| 
						 | 
				
			
			@ -177,6 +180,13 @@ const (
 | 
			
		|||
	migrate6To7AlterMessagesTableQuery = `
 | 
			
		||||
		ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
 | 
			
		||||
	`
 | 
			
		||||
 | 
			
		||||
	// 7 -> 8
 | 
			
		||||
	migrate7To8AlterMessagesTableQuery = `
 | 
			
		||||
		ALTER TABLE messages ADD COLUMN icon_url TEXT NOT NULL DEFAULT('');
 | 
			
		||||
		ALTER TABLE messages ADD COLUMN icon_type TEXT NOT NULL DEFAULT('');
 | 
			
		||||
		ALTER TABLE messages ADD COLUMN icon_size INT NOT NULL DEFAULT('0');
 | 
			
		||||
	`
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type messageCache struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -248,6 +258,13 @@ func (c *messageCache) addMessages(ms []*message) error {
 | 
			
		|||
			attachmentExpires = m.Attachment.Expires
 | 
			
		||||
			attachmentURL = m.Attachment.URL
 | 
			
		||||
		}
 | 
			
		||||
		var iconURL, iconType string
 | 
			
		||||
		var iconSize int64
 | 
			
		||||
		if m.Icon != nil {
 | 
			
		||||
			iconURL = m.Icon.URL
 | 
			
		||||
			iconType = m.Icon.Type
 | 
			
		||||
			iconSize = m.Icon.Size
 | 
			
		||||
		}
 | 
			
		||||
		var actionsStr string
 | 
			
		||||
		if len(m.Actions) > 0 {
 | 
			
		||||
			actionsBytes, err := json.Marshal(m.Actions)
 | 
			
		||||
| 
						 | 
				
			
			@ -275,6 +292,9 @@ func (c *messageCache) addMessages(ms []*message) error {
 | 
			
		|||
			m.Sender,
 | 
			
		||||
			m.Encoding,
 | 
			
		||||
			published,
 | 
			
		||||
			iconURL,
 | 
			
		||||
			iconType,
 | 
			
		||||
			iconSize,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
| 
						 | 
				
			
			@ -412,9 +432,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 | 
			
		|||
	defer rows.Close()
 | 
			
		||||
	messages := make([]*message, 0)
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		var timestamp, attachmentSize, attachmentExpires int64
 | 
			
		||||
		var timestamp, attachmentSize, attachmentExpires, iconSize int64
 | 
			
		||||
		var priority int
 | 
			
		||||
		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
 | 
			
		||||
		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding, iconURL, iconType string
 | 
			
		||||
		err := rows.Scan(
 | 
			
		||||
			&id,
 | 
			
		||||
			×tamp,
 | 
			
		||||
| 
						 | 
				
			
			@ -432,6 +452,9 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 | 
			
		|||
			&attachmentURL,
 | 
			
		||||
			&sender,
 | 
			
		||||
			&encoding,
 | 
			
		||||
			&iconURL,
 | 
			
		||||
			&iconType,
 | 
			
		||||
			&iconSize,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
| 
						 | 
				
			
			@ -456,6 +479,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 | 
			
		|||
				URL:     attachmentURL,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		var ico *icon
 | 
			
		||||
		if iconURL != "" {
 | 
			
		||||
			ico = &icon{
 | 
			
		||||
				URL:  iconURL,
 | 
			
		||||
				Type: iconType,
 | 
			
		||||
				Size: iconSize,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		messages = append(messages, &message{
 | 
			
		||||
			ID:         id,
 | 
			
		||||
			Time:       timestamp,
 | 
			
		||||
| 
						 | 
				
			
			@ -466,6 +497,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 | 
			
		|||
			Priority:   priority,
 | 
			
		||||
			Tags:       tags,
 | 
			
		||||
			Click:      click,
 | 
			
		||||
			Icon:       ico,
 | 
			
		||||
			Actions:    actions,
 | 
			
		||||
			Attachment: att,
 | 
			
		||||
			Sender:     sender,
 | 
			
		||||
| 
						 | 
				
			
			@ -524,6 +556,8 @@ func setupCacheDB(db *sql.DB, startupQueries string) error {
 | 
			
		|||
		return migrateFrom5(db)
 | 
			
		||||
	} else if schemaVersion == 6 {
 | 
			
		||||
		return migrateFrom6(db)
 | 
			
		||||
	} else if schemaVersion == 7 {
 | 
			
		||||
		return migrateFrom7(db)
 | 
			
		||||
	}
 | 
			
		||||
	return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -618,5 +652,16 @@ func migrateFrom6(db *sql.DB) error {
 | 
			
		|||
	if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return migrateFrom7(db)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func migrateFrom7(db *sql.DB) error {
 | 
			
		||||
	log.Info("Migrating cache database schema: from 7 to 8")
 | 
			
		||||
	if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil // Update this when a new version is added
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,6 +75,7 @@ var (
 | 
			
		|||
	fileRegex        = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
 | 
			
		||||
	disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
 | 
			
		||||
	attachURLRegex   = regexp.MustCompile(`^https?://`)
 | 
			
		||||
	iconURLRegex     = regexp.MustCompile(`^https?://`)
 | 
			
		||||
 | 
			
		||||
	//go:embed site
 | 
			
		||||
	webFs        embed.FS
 | 
			
		||||
| 
						 | 
				
			
			@ -568,6 +569,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 | 
			
		|||
	firebase = readBoolParam(r, true, "x-firebase", "firebase")
 | 
			
		||||
	m.Title = readParam(r, "x-title", "title", "t")
 | 
			
		||||
	m.Click = readParam(r, "x-click", "click")
 | 
			
		||||
	ico := readParam(r, "x-icon", "icon")
 | 
			
		||||
	filename := readParam(r, "x-filename", "filename", "file", "f")
 | 
			
		||||
	attach := readParam(r, "x-attach", "attach", "a")
 | 
			
		||||
	if attach != "" || filename != "" {
 | 
			
		||||
| 
						 | 
				
			
			@ -594,6 +596,13 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 | 
			
		|||
			m.Attachment.Name = "attachment"
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if ico != "" {
 | 
			
		||||
		m.Icon = &icon{}
 | 
			
		||||
		if !iconURLRegex.MatchString(ico) {
 | 
			
		||||
			return false, false, "", false, errHTTPBadRequestIconURLInvalid
 | 
			
		||||
		}
 | 
			
		||||
		m.Icon.URL = ico
 | 
			
		||||
	}
 | 
			
		||||
	email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
 | 
			
		||||
	if email != "" {
 | 
			
		||||
		if err := v.EmailAllowed(); err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -1336,6 +1345,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 | 
			
		|||
		if m.Click != "" {
 | 
			
		||||
			r.Header.Set("X-Click", m.Click)
 | 
			
		||||
		}
 | 
			
		||||
		if m.Icon != "" {
 | 
			
		||||
			r.Header.Set("X-Icon", m.Icon)
 | 
			
		||||
		}
 | 
			
		||||
		if len(m.Actions) > 0 {
 | 
			
		||||
			actionsStr, err := json.Marshal(m.Actions)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -166,6 +166,11 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
 | 
			
		|||
				data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
 | 
			
		||||
				data["attachment_url"] = m.Attachment.URL
 | 
			
		||||
			}
 | 
			
		||||
			if m.Icon != nil {
 | 
			
		||||
				data["icon_url"] = m.Icon.URL
 | 
			
		||||
				data["icon_type"] = m.Icon.Type
 | 
			
		||||
				data["icon_size"] = fmt.Sprintf("%d", m.Icon.Size)
 | 
			
		||||
			}
 | 
			
		||||
			apnsConfig = createAPNSAlertConfig(m, data)
 | 
			
		||||
		} else {
 | 
			
		||||
			// If anonymous read for a topic is not allowed, we cannot send the message along
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -123,6 +123,11 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 | 
			
		|||
	m.Priority = 4
 | 
			
		||||
	m.Tags = []string{"tag 1", "tag2"}
 | 
			
		||||
	m.Click = "https://google.com"
 | 
			
		||||
	m.Icon = &icon{
 | 
			
		||||
		URL:  "https://ntfy.sh/static/img/ntfy.png",
 | 
			
		||||
		Type: "image/jpeg",
 | 
			
		||||
		Size: 4567,
 | 
			
		||||
	}
 | 
			
		||||
	m.Title = "some title"
 | 
			
		||||
	m.Actions = []*action{
 | 
			
		||||
		{
 | 
			
		||||
| 
						 | 
				
			
			@ -173,6 +178,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 | 
			
		|||
				"priority":           "4",
 | 
			
		||||
				"tags":               strings.Join(m.Tags, ","),
 | 
			
		||||
				"click":              "https://google.com",
 | 
			
		||||
				"icon_url":           "https://ntfy.sh/static/img/ntfy.png",
 | 
			
		||||
				"icon_type":          "image/jpeg",
 | 
			
		||||
				"icon_size":          "4567",
 | 
			
		||||
				"title":              "some title",
 | 
			
		||||
				"message":            "this is a message",
 | 
			
		||||
				"actions":            `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
 | 
			
		||||
| 
						 | 
				
			
			@ -193,6 +201,9 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 | 
			
		|||
		"priority":           "4",
 | 
			
		||||
		"tags":               strings.Join(m.Tags, ","),
 | 
			
		||||
		"click":              "https://google.com",
 | 
			
		||||
		"icon_url":           "https://ntfy.sh/static/img/ntfy.png",
 | 
			
		||||
		"icon_type":          "image/jpeg",
 | 
			
		||||
		"icon_size":          "4567",
 | 
			
		||||
		"title":              "some title",
 | 
			
		||||
		"message":            "this is a message",
 | 
			
		||||
		"actions":            `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1046,7 +1046,7 @@ 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"],` +
 | 
			
		||||
		`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
 | 
			
		||||
		`"delay":"30min"}`
 | 
			
		||||
		`"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}`
 | 
			
		||||
	response := request(t, s, "PUT", "/", body, nil)
 | 
			
		||||
	require.Equal(t, 200, response.Code)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1058,6 +1058,8 @@ func TestServer_PublishAsJSON(t *testing.T) {
 | 
			
		|||
	require.Equal(t, "http://google.com", m.Attachment.URL)
 | 
			
		||||
	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.URL)
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ type message struct {
 | 
			
		|||
	Click      string      `json:"click,omitempty"`
 | 
			
		||||
	Actions    []*action   `json:"actions,omitempty"`
 | 
			
		||||
	Attachment *attachment `json:"attachment,omitempty"`
 | 
			
		||||
	Icon       *icon       `json:"icon,omitempty"`
 | 
			
		||||
	PollID     string      `json:"poll_id,omitempty"`
 | 
			
		||||
	Sender     string      `json:"-"`                  // IP address of uploader, used for rate limiting
 | 
			
		||||
	Encoding   string      `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
 | 
			
		||||
| 
						 | 
				
			
			@ -44,6 +45,12 @@ type attachment struct {
 | 
			
		|||
	URL     string `json:"url"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type icon struct {
 | 
			
		||||
	URL  string `json:"url"`
 | 
			
		||||
	Type string `json:"type,omitempty"`
 | 
			
		||||
	Size int64  `json:"size,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type action struct {
 | 
			
		||||
	ID      string            `json:"id"`
 | 
			
		||||
	Action  string            `json:"action"`            // "view", "broadcast", or "http"
 | 
			
		||||
| 
						 | 
				
			
			@ -74,6 +81,7 @@ type publishMessage struct {
 | 
			
		|||
	Click    string   `json:"click"`
 | 
			
		||||
	Actions  []action `json:"actions"`
 | 
			
		||||
	Attach   string   `json:"attach"`
 | 
			
		||||
	Icon     string   `json:"icon"`
 | 
			
		||||
	Filename string   `json:"filename"`
 | 
			
		||||
	Email    string   `json:"email"`
 | 
			
		||||
	Delay    string   `json:"delay"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue