Merge branch 'main' into zhzy0077-patch-1

This commit is contained in:
binwiederhier 2024-03-07 10:38:19 -05:00
commit 17709f2fb7
127 changed files with 7025 additions and 2272 deletions

View file

@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
"regexp"
"strings"
"unicode/utf8"

View file

@ -5,7 +5,7 @@ import (
"net/netip"
"time"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/user"
)
// Defines default config settings (excluding limits, see below)

View file

@ -2,7 +2,7 @@ package server_test
import (
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/server"
"heckel.io/ntfy/v2/server"
"testing"
)

View file

@ -3,7 +3,7 @@ package server
import (
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/v2/log"
"net/http"
)

View file

@ -3,8 +3,8 @@ package server
import (
"errors"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"io"
"os"
"path/filepath"

View file

@ -4,7 +4,7 @@ import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
"os"
"strings"
"testing"

View file

@ -4,8 +4,8 @@ import (
"fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"net/http"
"strings"
"unicode/utf8"

View file

@ -10,8 +10,8 @@ import (
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
var (

View file

@ -30,9 +30,9 @@ import (
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
// Server is the main server, providing the UI and API for ntfy
@ -743,8 +743,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
return nil, e.With(t)
}
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting.
// The 5xx response is because some app servers (in particular Mastodon) will remove
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
@ -1182,7 +1182,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if err != nil {
return err
}
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
@ -1212,7 +1212,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
}
return nil
}
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err
}
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@ -1278,7 +1278,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
if err != nil {
return err
}
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
@ -1364,7 +1364,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
}
return conn.WriteJSON(msg)
}
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err
}
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@ -1397,7 +1397,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return err
}
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) {
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
poll = readBoolParam(r, false, "x-poll", "poll", "po")
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
since, err = parseSince(r, poll)
@ -1408,7 +1408,6 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
if err != nil {
return
}
rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
return
}
@ -1420,9 +1419,8 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
// - or the topic is reserved, and v.user is the owner
// - or the topic is not reserved, and v.user has write access
//
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
// until the Android app will send the "Rate-Topics" header.
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
// This only applies to UnifiedPush topics ("up...").
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic) error {
// Bail out if not enabled
if !s.config.VisitorSubscriberRateLimiting {
return nil
@ -1431,7 +1429,7 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top
// Make a list of topics that we'll actually set the RateVisitor on
eligibleRateTopics := make([]*topic, 0)
for _, t := range topics {
if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) {
if strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength {
eligibleRateTopics = append(eligibleRateTopics, t)
}
}

View file

@ -277,15 +277,14 @@
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
#
# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
# publishers (e.g. Matrix/Mastodon servers) are allowed to send.
# If subscriber-based rate limiting is enabled, messages published on UnifiedPush topics** (topics starting with "up")
# will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
#
# Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic.
# Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
# a "rate visitor", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor
# requires **read-write permission** on the topic.
#
# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
#
# visitor-subscriber-rate-limiting: false
@ -342,6 +341,10 @@
# - "field -> level" to match any value, e.g. "time_taken_ms -> debug"
# Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging.
#
# Check your permissions:
# If you are running ntfy with systemd, make sure this log file is owned by the
# ntfy user and group by running: chown ntfy.ntfy <filename>.
#
# Example (good for production):
# log-level: info
# log-format: json

View file

@ -2,9 +2,9 @@ package server
import (
"encoding/json"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"net/http"
"net/netip"
"strings"

View file

@ -3,9 +3,9 @@ package server
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/netip"
"path/filepath"

View file

@ -1,7 +1,7 @@
package server
import (
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/user"
"net/http"
)

View file

@ -2,8 +2,8 @@ package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"sync/atomic"
"testing"
"time"

View file

@ -8,8 +8,8 @@ import (
"firebase.google.com/go/v4/messaging"
"fmt"
"google.golang.org/api/option"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"strings"
)

View file

@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/user"
"net/netip"
"strings"
"sync"

View file

@ -1,8 +1,8 @@
package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"strings"
)

View file

@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"strings"

View file

@ -3,7 +3,7 @@ package server
import (
"net/http"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
)
type contextKey int

View file

@ -11,9 +11,9 @@ import (
"github.com/stripe/stripe-go/v74/price"
"github.com/stripe/stripe-go/v74/subscription"
"github.com/stripe/stripe-go/v74/webhook"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/netip"

View file

@ -6,8 +6,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74"
"golang.org/x/time/rate"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/netip"
"path/filepath"

View file

@ -3,13 +3,13 @@ package server
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/user"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"net/netip"
@ -24,8 +24,8 @@ import (
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
func TestMain(m *testing.M) {
@ -329,6 +329,27 @@ func TestServer_PublishPriority(t *testing.T) {
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishPriority_SpecialHTTPHeader(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"Priority": "u=4",
"X-Priority": "5",
})
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "POST", "/mytopic?priority=4", "test", map[string]string{
"Priority": "u=9",
})
require.Equal(t, 4, toMessage(t, response.Body.String()).Priority)
response = request(t, s, "POST", "/mytopic", "test", map[string]string{
"p": "2",
"priority": "u=9, i",
})
require.Equal(t, 2, toMessage(t, response.Body.String()).Priority)
}
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
@ -491,6 +512,8 @@ func TestServer_PublishAtAndPrune(t *testing.T) {
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) // Not affected by pruning
require.Equal(t, "a message", messages[0].Message)
time.Sleep(time.Second) // FIXME CI failing not sure why
}
func TestServer_PublishAndMultiPoll(t *testing.T) {
@ -1323,9 +1346,7 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber
response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{
"Rate-Topics": "up123456789012",
})
response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
// Publish message to topic
@ -1356,9 +1377,7 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
"Rate-Topics": "mytopic",
})
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
// Publish message to topic
@ -1377,9 +1396,7 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
// Register a UnifiedPush subscriber
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
"Rate-Topics": "mytopic",
})
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
// Publish UnifiedPush text message
@ -1411,9 +1428,7 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
"Rate-Topics": "mytopic", // Register first!
})
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
@ -2243,16 +2258,14 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
// "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor
subscriber1Fn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
}
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
"Rate-Topics": "subscriber1topic",
}, subscriber1Fn)
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String())
require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
subscriber2Fn := func(r *http.Request) {
@ -2266,10 +2279,10 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
// GET request before is also counted towards the request limiter.
for i := 0; i < 2; i++ {
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
rr := request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, rr.Code)
}
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
rr = request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 429, rr.Code)
// Publish another 2 messages to "up012345678912" as visitor 9.9.9.9
@ -2302,14 +2315,12 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
// Subscriber rate limiting is disabled!
// Registering visitor 1.2.3.4 to topic has no effect
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
"Rate-Topics": "subscriber1topic",
}, func(r *http.Request) {
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["subscriber1topic"].rateVisitor)
require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Registering visitor 8.7.7.1 to topic has no effect
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
@ -2319,7 +2330,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["up012345678912"].rateVisitor)
// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9
// Publish 3 messages to "upAAAAAAAAAAAA" as visitor 9.9.9.9
for i := 0; i < 3; i++ {
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
require.Equal(t, 200, rr.Code)
@ -2392,80 +2403,30 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
subscriberFn := func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
}
rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
"rate-topics": "mytopic",
}, subscriberFn)
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
require.Equal(t, 200, rr.Code)
require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Publish message, observe rate visitor tokens being decreased
response := request(t, s, "POST", "/mytopic", "some message", nil)
response := request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
require.Equal(t, int64(1), s.topics["upAAAAAAAAAAAA"].rateVisitor.messagesLimiter.Value())
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
// Expire visitor
s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour)
s.pruneVisitors()
// Publish message again, observe that rateVisitor is not used anymore and is reset
response = request(t, s, "POST", "/mytopic", "some message", nil)
response = request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
require.Nil(t, s.topics["mytopic"].rateVisitor)
require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor)
require.Nil(t, s.visitors["ip:1.2.3.4"])
}
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// Create some ACLs
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
MessageLimit: 5,
}))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("ben", "test"))
require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite))
// Set rate visitor as user "phil" on topic
// - "reserved-for-phil": Allowed, because I am the owner
// - "public_topic": Allowed, because it has read-write permissions for everyone
// - "announcements": NOT allowed, because it has read-only permissions for everyone
rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name)
require.Nil(t, s.topics["announcements"].rateVisitor)
// Set rate visitor as user "ben" on topic
// - "reserved-for-phil": NOT allowed, because I am not the owner
// - "public_topic": Allowed, because it has read-write permissions for everyone
// - "announcements": Allowed, because I have read-write permissions
rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name)
require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name)
}
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionReadWrite

View file

@ -4,9 +4,9 @@ import (
"bytes"
"encoding/xml"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/url"

View file

@ -2,8 +2,8 @@ package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"

View file

@ -8,8 +8,8 @@ import (
"strings"
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
)
const (

View file

@ -4,8 +4,8 @@ import (
"encoding/json"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"

View file

@ -11,8 +11,8 @@ import (
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
type mailer interface {

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"io"
"mime"
"mime/multipart"
@ -14,6 +15,7 @@ import (
"net/http"
"net/http/httptest"
"net/mail"
"regexp"
"strings"
"sync"
)
@ -27,6 +29,11 @@ var (
errUnsupportedContentType = errors.New("unsupported content type")
)
var (
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
)
const (
maxMultipartDepth = 2
)
@ -232,37 +239,66 @@ func readMailBody(body io.Reader, header mail.Header) (string, error) {
if err != nil {
return "", err
}
if strings.ToLower(contentType) == "text/plain" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
return readMultipartMailBody(body, params, 0)
canonicalContentType := strings.ToLower(contentType)
if canonicalContentType == "text/plain" || canonicalContentType == "text/html" {
return readTextMailBody(body, canonicalContentType, header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(canonicalContentType, "multipart/") {
return readMultipartMailBody(body, params)
}
return "", errUnsupportedContentType
}
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) {
func readMultipartMailBody(body io.Reader, params map[string]string) (string, error) {
parts := make(map[string]string)
if err := readMultipartMailBodyParts(body, params, 0, parts); err != nil && err != io.EOF {
return "", err
} else if s, ok := parts["text/plain"]; ok {
return s, nil
} else if s, ok := parts["text/html"]; ok {
return s, nil
}
return "", io.EOF
}
func readMultipartMailBodyParts(body io.Reader, params map[string]string, depth int, parts map[string]string) error {
if depth >= maxMultipartDepth {
return "", errMultipartNestedTooDeep
return errMultipartNestedTooDeep
}
mr := multipart.NewReader(body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
return err
}
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
return err
}
if strings.ToLower(partContentType) == "text/plain" {
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding"))
canonicalPartContentType := strings.ToLower(partContentType)
if canonicalPartContentType == "text/plain" || canonicalPartContentType == "text/html" {
s, err := readTextMailBody(part, canonicalPartContentType, part.Header.Get("Content-Transfer-Encoding"))
if err != nil {
return err
}
parts[canonicalPartContentType] = s
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
return readMultipartMailBody(part, partParams, depth+1)
if err := readMultipartMailBodyParts(part, partParams, depth+1, parts); err != nil {
return err
}
}
// Continue with next part
}
}
func readTextMailBody(reader io.Reader, contentType, transferEncoding string) (string, error) {
if contentType == "text/plain" {
return readPlainTextMailBody(reader, transferEncoding)
} else if contentType == "text/html" {
return readHTMLMailBody(reader, transferEncoding)
}
return "", fmt.Errorf("unsupported content type: %s", contentType)
}
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
if strings.ToLower(transferEncoding) == "base64" {
reader = base64.NewDecoder(base64.StdEncoding, reader)
@ -275,3 +311,21 @@ func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, e
}
return string(body), nil
}
func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error) {
body, err := readPlainTextMailBody(reader, transferEncoding)
if err != nil {
return "", err
}
stripped := bluemonday.
StrictPolicy().
AddSpaceWhenStrippingTag(true).
Sanitize(body)
return removeExtraEmptyLines(stripped), nil
}
func removeExtraEmptyLines(s string) string {
s = onlySpacesRegex.ReplaceAllString(s, "")
s = consecutiveNewLinesRegex.ReplaceAllString(s, "\n\n")
return s
}

View file

@ -568,6 +568,803 @@ L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep")
}
func TestSmtpBackend_HTMLEmail(t *testing.T) {
email := `EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Message-Id: <51610934ss4.mmailer@fritz.box>
From: <email@email.com>
To: <email@email.com>,
<ntfy-subjectatntfy@ntfy.sh>
Date: Thu, 30 Mar 2023 02:56:53 +0000
Subject: A HTML email
Mime-Version: 1.0
Content-Type: text/html;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<=21DOCTYPE html>
<html>
<head>
<title>Alerttitle</title>
<meta http-equiv=3D"content-type" content=3D"text/html;charset=3Dutf-8"/>
</head>
<body style=3D"color: =23000000; background-color: =23f0eee6;">
<table width=3D"100%" align=3D"center" style=3D"border:solid 2px =23eeeeee=
; border-collapse: collapse;">
<tr>
<td>
<table style=3D"border-collapse: collapse;">
<tr>
<td style=3D"background: =23FFFFFF;">
<table style=3D"color: =23FFFFFF; background-color: =23006EC0; border-coll=
apse: collapse;">
<tr>
<td style=3D"width: 1000px; text-align: center; font-size: 18pt; font-fami=
ly: Arial, Helvetica, sans-serif; padding: 10px;">
headertext of table
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
<table style=3D"border-collapse: collapse;">
<tr>
<td style=3D"width: 940px; font-size: 13pt; font-family: Arial, Helvetica,=
sans-serif; text-align: left;">
" Very important information about a change in your
home automation setup
Now the light is on
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style=3D"padding: 10px 20px; background: =23FFFFFF;">
<table>
<tr>
<td style=3D"width: 960px; font-size: 10pt; font-family: Arial, Helvetica,=
sans-serif; text-align: left;">
<hr />
If you don't want to receive this message anymore, stop the push
services in your <a href=3D"https:fritzbox" target=3D"_=
blank">FRITZ=21Box</a>=2E<br />
Here you can see the active push services: "System > Push Service"=2E
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table style=3D"color: =23FFFFFF; background-color: =23006EC0;">
<tr>
<td style=3D"width: 1000px; font-size: 10pt; font-family: Arial, Helvetica=
, sans-serif; text-align: center; padding: 10px;">
This mail has ben sent by your <a style=3D"color: =23FFFFFF;" href=3D"https:=
//fritzbox" target=3D"_blank">FRITZ=21Box</a=
> automatically=2E
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "A HTML email", r.Header.Get("Title"))
expected := `headertext of table
&#34; Very important information about a change in your
home automation setup
Now the light is on
If you don&#39;t want to receive this message anymore, stop the push
services in your FRITZ!Box .
Here you can see the active push services: &#34;System &gt; Push Service&#34;.
This mail has ben sent by your FRITZ!Box automatically.`
require.Equal(t, expected, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
const spamEmail = `
EHLO example.com
MAIL FROM: test@mydomain.me
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Delivered-To: somebody@gmail.com
Received: by 2002:a05:651c:1248:b0:2bf:c263:285 with SMTP id h8csp1096496ljh;
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
X-Google-Smtp-Source: AGHT+IFsB3WqbwbeefbeefbeefbeefbeefiXRNDHnIy2xBeaYHZCM3EC8DfPv55qDtgq9djTeBCF
X-Received: by 2002:a05:6808:147:b0:3af:66e5:5d3c with SMTP id h7-20020a056808014700b003af66e55d3cmr11662458oie.26.1698672188132;
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1698672188; cv=none;
d=google.com; s=arc-20160816;
b=XM96KvnTbr4h6bqrTPTuuDNXmFCr9Be/HvVhu+UsSQjP9RxPk0wDTPUPZ/HWIJs52y
beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef
BUmQ==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
h=list-unsubscribe-post:list-unsubscribe:mime-version:subject:to
:reply-to:from:date:message-id:dkim-signature:dkim-signature;
bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=;
fh=+kTCcNpX22TOI/SVSLygnrDqWeUt4zW7QKiv0TOVSGs=;
b=lyIBRuOxPOTY2s36OqP7M7awlBKd4t5PX9mJOEJB0eTnTZqML+cplrXUIg2ZTlAAi9
beeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeef
tgVQ==
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;
dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;
spf=pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) smtp.mailfrom="AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com";
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com
Return-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>
Received: from ccm30.constantcontact.com (ccm30.constantcontact.com. [208.75.123.226])
by mx.google.com with ESMTPS id h2-20020a05620a21c200b0076eeed38118si5450962qka.131.2023.10.30.06.23.07
for <somebody@gmail.com>
(version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128);
Mon, 30 Oct 2023 06:23:08 -0700 (PDT)
Received-SPF: pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) client-ip=208.75.123.226;
Authentication-Results: mx.google.com;
dkim=pass header.i=@spamspam.com header.s=2020294246 header.b=G8y6xmtK;
dkim=pass header.i=@auth.ccsend.com header.s=1000073432 header.b=ht8IksVK;
spf=pass (google.com: domain of aigxeklyirlg+dvwkrmsgua==_1133104752381_suqcukvbeeynm/owplvdba==@in.constantcontact.com designates 208.75.123.226 as permitted sender) smtp.mailfrom="AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com";
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=spamspam.com
Return-Path: <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>
Received: from [10.252.0.3] ([10.252.0.3:53254] helo=p2-jbemailsyndicator12.ctct.net) by 10.249.225.20 (envelope-from <AigXeKlyIRLG+DvWkRMsGUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com>) (ecelerity 4.3.1.999 r(:)) with ESMTP id A4/82-60517-B3EAF356; Mon, 30 Oct 2023 09:23:07 -0400
DKIM-Signature: v=1; q=dns/txt; a=rsa-sha256; c=relaxed/relaxed; s=2020294246; d=spamspam.com; h=date:mime-version:subject:X-Feedback-ID:X-250ok-CID:message-id:from:reply-to:list-unsubscribe:list-unsubscribe-post:to; bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=; b=G8y6xmtKv8asfEXA9o8dP+6foQjclo6j5sFREYVIJBbj5YJ5tqoiv5B04/qoRkoTBFDhmjt+BUua7AqDgPSnwbP2iPSA4fTJehnHhut1PyVUp/9vqSYlhxQehfdhma8tPg8ArKfYIKmfKJwKRaQBU0JHCaB1m+5LNQQX3UjkxAg=
DKIM-Signature: v=1; q=dns/txt; a=rsa-sha256; c=relaxed/relaxed; s=1000073432; d=auth.ccsend.com; h=date:mime-version:subject:X-Feedback-ID:X-250ok-CID:message-id:from:reply-to:list-unsubscribe:list-unsubscribe-post:to; bh=BERwBIp6fBgrZePFKQjyNMmgPkcnq1Zy1jPO8M0T4Ok=; b=ht8IksVKYY/Kb3dUERWoeW4eVdYjKL6F4PEoIZOhfFXor6XAIbPnd3A/CPmbmoqFZjnKh5OdcUy1N5qEoj8w1Q3TmN8/ySQkqrlrmSDSZIHZMY7Qp9/TJrqUe4RMFOO1KKIN6Y0vGP1+dWe98msMAHwvi2qMjG9aEKLfFr2JUTQ=
Message-ID: <1140728754828.1133104752381.1941549819.0.260913JL.2002@synd.ccsend.com>
Date: Mon, 30 Oct 2023 09:23:07 -0400 (EDT)
From: spamspam Loan Servicing <marklake@spamspam.com>
Reply-To: marklake@spamspam.com
To: somebody@gmail.com
Subject: Buying a home? You deserve the confidence of Pre-Approval
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_Part_75055660_144854819.1698672187348"
List-Unsubscribe: <https://visitor.constantcontact.com/do?p=un&m=beefbeefbeef>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
X-Campaign-Activity-ID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650
X-250ok-CID: 8a05de2a-5c88-44b1-be0e-f5a444cb0650
X-Channel-ID: b1441c50-a541-11ec-a79b-fa163e5bc304
X-Return-Path-Hint: AbeefbeefbeefbeefbeefUA==_1133104752381_sUQcUKVBEeynm/oWPlvDBA==@in.constantcontact.com
X-Roving-Campaignid: 1140728754811
X-Roving-Id: 1133104752381.1111111111
X-Feedback-ID: b1441c50-a541-11ec-beef-beefbeefbeefbeef5de2a-5c88-44b1-be0e-f5a444cb0650:1133104752381:CTCT
X-CTCT-ID: b13a9586-a541-11ec-beef-beefbeefbeef
------=_Part_75055660_144854819.1698672187348
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
When you're buying a home, Pre-Approval gives you confidence you're in the =
right price range and shows sellers you mean business. xxxxxxxxx SELLING or=
BUYING? Call: 844-590-2275 Get Your Homebuying PRE-APPROVAL IN 24-HOURS* G=
et Pre-Approved When you're buying a home, Pre-Approval gives you confidenc=
e you're in the right price range and shows sellers you mean business. xxx=
xxxxxxGet Pre-Approved today! Click or Call to Get Pre-Approved 844-590-227=
5 Get Pre-Approved nmlsconsumeraccess.org/ *The 24 hour timeframe is for mo=
st approvals, however if additional information is needed or a request is o=
n a holiday, the time for preapproval may be greater than 24 hours. This em=
ail is for informational purposes only and is not an offer, loan approval o=
r loan commitment. Mortgage rates are subject to change without notice. Som=
e terms and restrictions may apply to certain loan programs. Refinancing ex=
isting loans may result in total finance charges being higher over the life=
of the loan, reduction in payments may partially reflect a longer loan ter=
m. This information is provided as guidance and illustrative purposes only =
and does not constitute legal or financial advice. We are not liable or bou=
nd legally for any answers provided to any user for our process or position=
on an issue. This information may change from time to time and at any time=
without notification. The most current information will be updated periodi=
cally and posted in the online forum. spamspam Loan Servicing, LLC. NMLS#39=
1521. nmlsconsumeraccess.org. You are receiving this information as a curre=
nt loan customer with spamspam Loan Servicing, LLC. Not licensed for lendin=
g activities in any of the U.S. territories. Not authorized to originate lo=
ans in the State of New York. Licensed by the Dept. of Financial Protection=
and Innovation under the California Residential Mortgage .Lending Act #413=
1216. This email was sent to somebody@gmail.com Version 103023PCHPrAp=
9 xxxxxxxxx spamspam Loan Servicing | 4425 Ponce de Leon Blvd 5-251, Coral =
Gables, FL 33146-1837 Unsubscribe somebody@gmail.com Update Profile |=
Our Privacy Policy | Constant Contact Data Notice Sent by marklake@spamspa=
m.com
------=_Part_75055660_144854819.1698672187348
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE HTML>
<html lang=3D"en-US"> <head> <meta http-equiv=3D"Content-Type" content=3D"=
text/html; charset=3Dutf-8"> <meta name=3D"viewport" content=3D"width=3Ddev=
ice-width, initial-scale=3D1, maximum-scale=3D1"> <style type=3D"text/css=
" data-premailer=3D"ignore">=20
@media only screen and (max-width:480px) { .footer-main-width { width: 100%=
!important; } .footer-mobile-hidden { display: none !important; } .foote=
r-mobile-hidden { display: none !important; } .footer-column { display: bl=
ock !important; } .footer-mobile-stack { display: block !important; } .fo=
oter-mobile-stack-padding { padding-top: 3px; } }=20
/* IE: correctly scale images with w/h attbs */ img { -ms-interpolation-mod=
e: bicubic; }=20
.layout { min-width: 100%; }=20
table { table-layout: fixed; } .shell_outer-row { table-layout: auto; }=20
/* Gmail/Web viewport fix */ u + .body .shell_outer-row { width: 620px; }=
=20
/* LIST AND p STYLE OVERRIDES */ .text .text_content-cell p { margin: 0; pa=
dding: 0; margin-bottom: 0; } .text .text_content-cell ul, .text .text_cont=
ent-cell ol { padding: 0; margin: 0 0 0 40px; } .text .text_content-cell li=
{ padding: 0; margin: 0; /* line-height: 1.2; Remove after testing */ } /*=
Text Link Style Reset */ a { text-decoration: underline; } /* iOS: Autolin=
k styles inherited */ a[x-apple-data-detectors] { text-decoration: underlin=
e !important; font-size: inherit !important; font-family: inherit !importan=
t; font-weight: inherit !important; line-height: inherit !important; color:=
inherit !important; } /* FF/Chrome: Smooth font rendering */ .text .text_c=
ontent-cell { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing:=
grayscale; }=20
</style> <!--[if gte mso 9]> <style id=3D"ol-styles">=20
/* OUTLOOK-SPECIFIC STYLES */ li { text-indent: -1em; padding: 0; margin: 0=
; /* line-height: 1.2; Remove after testing */ } ul, ol { padding: 0; margi=
n: 0 0 0 40px; } p { margin: 0; padding: 0; margin-bottom: 0; }=20
</style> <![endif]--> <style>@media only screen and (max-width:480px) {
.button_content-cell {
padding-top: 10px !important; padding-right: 20px !important; padding-botto=
m: 10px !important; padding-left: 20px !important;
}
.button_border-row .button_content-cell {
padding-top: 10px !important; padding-right: 20px !important; padding-botto=
m: 10px !important; padding-left: 20px !important;
}
.column .content-padding-horizontal {
padding-left: 20px !important; padding-right: 20px !important;
}
.layout .column .content-padding-horizontal .content-padding-horizontal {
padding-left: 0px !important; padding-right: 0px !important;
}
.layout .column .content-padding-horizontal .block-wrapper_border-row .cont=
ent-padding-horizontal {
padding-left: 20px !important; padding-right: 20px !important;
}
.dataTable {
overflow: auto !important;
}
.dataTable .dataTable_content {
width: auto !important;
}
.image--mobile-scale .image_container img {
width: auto !important;
}
.image--mobile-center .image_container img {
margin-left: auto !important; margin-right: auto !important;
}
.layout-margin .layout-margin_cell {
padding: 0px 20px !important;
}
.layout-margin--uniform .layout-margin_cell {
padding: 20px 20px !important;
}
.scale {
width: 100% !important;
}
.stack {
display: block !important; box-sizing: border-box;
}
.hide {
display: none !important;
}
u + .body .shell_outer-row {
width: 100% !important;
}
.socialFollow_container {
text-align: center !important;
}
.text .text_content-cell {
font-size: 16px !important;
}
.text .text_content-cell h1 {
font-size: 24px !important;
}
.text .text_content-cell h2 {
font-size: 20px !important;
}
.text .text_content-cell h3 {
font-size: 20px !important;
}
.text--sectionHeading .text_content-cell {
font-size: 26px !important;
}
.text--heading .text_content-cell {
font-size: 26px !important;
}
.text--feature .text_content-cell h2 {
font-size: 20px !important;
}
.text--articleHeading .text_content-cell {
font-size: 20px !important;
}
.text--article .text_content-cell h3 {
font-size: 20px !important;
}
.text--featureHeading .text_content-cell {
font-size: 20px !important;
}
.text--feature .text_content-cell h3 {
font-size: 20px !important;
}
.text--dataTable .text_content-cell .dataTable .dataTable_content-cell {
font-size: 12px !important;
}
.text--dataTable .text_content-cell .dataTable th.dataTable_content-cell {
font-size: px !important;
}
}
</style>
</head> <body class=3D"body template template--en-US" data-template-version=
=3D"1.38.0" data-canonical-name=3D"CPE10001" lang=3D"en-US" align=3D"center=
" style=3D"-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; min-=
width: 100%; width: 100%; margin: 0px; padding: 0px;"> <div id=3D"preheader=
" style=3D"color: transparent; display: none; font-size: 1px; line-height: =
1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;"><span =
data-entity-ref=3D"preheader">When you&#x27;re buying a home, Pre-Approval =
gives you confidence you&#x27;re in the right price range and shows sellers=
you mean business. </span></div> <div id=3D"tracking-image" style=3D"color=
: transparent; display: none; font-size: 1px; line-height: 1px; max-height:=
0px; max-width: 0px; opacity: 0; overflow: hidden;"><img src=3D"https://r2=
0.rs6.net/on.jsp?ca=beefbeefbe-beef-44b1-be0e-f5a444cb0650&a=3D113310475238=
1&c=3Db13a9586-a541-11ec-a79b-fa163e5bc304&ch=3Db1441c50-a541-11ec-a79b-fa1=
63e5bc304" / alt=3D""></div> <div class=3D"shell" lang=3D"en-US" style=3D"b=
ackground-color: #015288;"> <table class=3D"shell_panel-row" width=3D"100%=
" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"background-colo=
r: #015288;" bgcolor=3D"#015288"> <tr class=3D""> <td class=3D"shell_panel-=
cell" style=3D"" align=3D"center" valign=3D"top"> <table class=3D"shell_wid=
th-row scale" style=3D"width: 620px;" align=3D"center" border=3D"0" cellpad=
ding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"shell_width-cell" style=3D"=
padding: 15px 10px;" align=3D"center" valign=3D"top"> <table class=3D"shell=
_content-row" width=3D"100%" align=3D"center" border=3D"0" cellpadding=3D"0=
" cellspacing=3D"0"> <tr> <td class=3D"shell_content-cell" style=3D"border-=
radius: 0px; background-color: #FFFFFF; padding: 0; border: 0px solid #0096=
d6;" align=3D"center" valign=3D"top" bgcolor=3D"#FFFFFF"> <table class=3D"l=
ayout layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" borde=
r=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colum=
n--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"divider" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0"=
border=3D"0"> <tr> <td class=3D"divider_container" style=3D"padding-top: 0=
px; padding-bottom: 10px;" width=3D"100%" align=3D"center" valign=3D"top"> =
<table class=3D"divider_content-row" style=3D"width: 100%; height: 1px;" ce=
llpadding=3D"0" cellspacing=3D"0" border=3D"0"> <tr> <td class=3D"divider_c=
ontent-cell" style=3D"padding-bottom: 5px; height: 1px; line-height: 1px; b=
ackground-color: #0096D6; border-bottom-width: 0px;" height=3D"1" align=3D"=
center" bgcolor=3D"#0096D6"> <img alt=3D"" width=3D"5" height=3D"1" border=
=3D"0" hspace=3D"0" vspace=3D"0" src=3D"https://imgssl.constantcontact.com/=
letters/images/1101116784221/S.gif" style=3D"display: block; height: 1px; w=
idth: 5px;"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=
<table class=3D"layout layout--1-column" style=3D"table-layout: fixed;" wi=
dth=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td cla=
ss=3D"column column--1 scale stack" style=3D"width: 100%;" align=3D"center"=
valign=3D"top"><div class=3D"spacer" style=3D"line-height: 10px; height: 1=
0px;">&#x200a;</div></td> </tr> </table> <table class=3D"layout layout--1-c=
olumn" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpaddi=
ng=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack"=
style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"image image--padding-vertical image--mobile-scale image--mo=
bile-center" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0=
"> <tr> <td class=3D"image_container" align=3D"center" valign=3D"top" style=
=3D"padding-top: 10px; padding-bottom: 10px;"> <a href=3D"https://r20.rs6.n=
et/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvTEiCsv-QEMhmJe9=
ch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D" data-tra=
ckable=3D"true"><img data-image-content class=3D"image_content" width=3D"26=
2" src=3D"https://files.constantcontact.com/beefbeefbee/057bff2a-bdba-4165-=
b108-a7baa91c42c6.jpg" alt=3D"" style=3D"display: block; height: auto; max-=
width: 100%;"></a> </td> </tr> </table> </td> </tr> </table> <table class=
=3D"layout layout--heading layout--1-column" style=3D"background-color: #00=
527e; table-layout: fixed;" width=3D"100%" border=3D"0" cellpadding=3D"0" c=
ellspacing=3D"0" bgcolor=3D"#00527e"> <tr> <td class=3D"column column--1 sc=
ale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
center; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; f=
ont-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; pa=
dding: 10px 20px;" align=3D"center" valign=3D"top">
<h1 style=3D"font-family: Arial,Verdana,Helvetica,sans-serif; color: #606d7=
8; font-size: 26px; font-weight: bold; margin: 0;"><span style=3D"color: rg=
b(0, 150, 214);">SELLING or BUYING?</span></h1>
<p style=3D"margin: 0;"><span style=3D"font-size: 16px; color: rgb(255, 255=
, 255); font-weight: bold;">Call: 844-590-2275</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--ar=
ticle layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" borde=
r=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colum=
n--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--heading text--padding-vertical" width=3D"100%" b=
order=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixe=
d;"> <tr> <td class=3D"text_content-cell content-padding-horizontal" style=
=3D"text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif; co=
lor: #606d78; font-size: 26px; line-height: 1.2; display: block; word-wrap:=
break-word; font-weight: bold; padding: 10px 20px;" align=3D"center" valig=
n=3D"top">
<p style=3D"margin: 0;"><span style=3D"font-size: 30px; color: rgb(0, 150, =
214);">Get Your Homebuying</span></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 30px; color: rgb(0, 82, 1=
26);">PRE-APPROVAL IN 24-HOURS</span><span style=3D"font-size: 30px; color:=
rgb(0, 82, 126); font-weight: normal;">*</span></p>
</td> </tr> </table> <table class=3D"image image--padding-vertical image--m=
obile-scale image--mobile-center" width=3D"100%" border=3D"0" cellpadding=
=3D"0" cellspacing=3D"0"> <tr> <td class=3D"image_container content-padding=
-horizontal" align=3D"center" valign=3D"top" style=3D"padding: 10px 20px;">=
<img data-image-content class=3D"image_content" width=3D"548" src=3D"https=
://files.constantcontact.com/df66e42d701/2092a2d7-0bda-4289-910b-bf50a2398d=
60.jpg" alt=3D"" style=3D"display: block; height: auto; max-width: 100%;"> =
</td> </tr> </table> <table class=3D"button button--padding-vertical" widt=
h=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"table-=
layout: fixed;"> <tr> <td class=3D"button_container content-padding-horizon=
tal" align=3D"center" style=3D"padding: 10px 20px;"> <table class=3D"but=
ton_content-row" style=3D"width: inherit; border-radius: 3px; border-spacin=
g: 0; background-color: #0096D6; border: none;" border=3D"0" cellpadding=3D=
"0" cellspacing=3D"0" bgcolor=3D"#0096D6"> <tr> <td class=3D"button_content=
-cell" style=3D"padding: 10px 40px;" align=3D"center"> <a class=3D"button_l=
ink" href=3D"https://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEG=
O0v-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCias4f7-QkeggQvxIvbs-6TTaZHHhXLKf88NID=
dci4Ge7aYN-QihEgqblie1-DQ2Fa1BKLbT3AM8rtrgeYQgVxJ6cG8POsvFzv7JstrGkCkg3a3AE=
633LfQpAddyVLFkTv6oyS4T2j_YjYIPKDOZktqK_5rOR-Fh8cWGtUD8YPpPNnZ037z6_t9Nkemu=
hxG&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DH=
u9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTgRJoVIo_si10jiydw=3D=3D" data-trackable=
=3D"true" style=3D"font-size: 16px; font-weight: bold; color: #FFFFFF; font=
-family: Helvetica,Arial,sans-serif; word-wrap: break-word; text-decoration=
: none;">Get Pre-Approved</a> </td> </tr> </table> </td> </tr> </table> =
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" =
cellpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <t=
d class=3D"text_content-cell content-padding-horizontal" style=3D"line-heig=
ht: 1; text-align: center; font-family: Arial,Verdana,Helvetica,sans-serif;=
color: #000000; font-size: 14px; display: block; word-wrap: break-word; pa=
dding: 10px 20px;" align=3D"center" valign=3D"top">
<p style=3D"text-align: left; margin: 0;" align=3D"left"><br></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 19px;">When you're buying=
a home, Pre-Approval gives you confidence you're in the right price range =
and shows sellers you mean business. </span></p>
<p style=3D"margin: 0;"><span style=3D"font-size: 19px;">&#xfeff;Get Pre-Ap=
proved today!</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=
t-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=
ing: 10px 20px;" align=3D"left" valign=3D"top">
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 23px; color: rgb(0, 82, 126); font-weight: bold; font-family: A=
rial, Verdana, Helvetica, sans-serif;">Click or Call to Get Pre-Approved </=
span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 28px; color: rgb(0, 150, 214); font-weight: bold;">844-590-2275=
</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top"> <table class=3D"b=
utton button--padding-vertical" width=3D"100%" border=3D"0" cellpadding=3D"=
0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td class=3D"butt=
on_container content-padding-horizontal" align=3D"center" style=3D"padding:=
10px 20px;"> <table class=3D"button_content-row" style=3D"background-co=
lor: #0096D6; width: inherit; border-radius: 3px; border-spacing: 0; border=
: none;" border=3D"0" cellpadding=3D"0" cellspacing=3D"0" bgcolor=3D"#0096D=
6"> <tr> <td class=3D"button_content-cell" style=3D"padding: 10px 40px;" al=
ign=3D"center"> <a class=3D"button_link" href=3D"https://r20.rs6.net/tn.jsp=
?f=3D001thisisfakethisisfakethisisfakev-Vy-0SCUlMWvTEiCsv-QEMuu9ZVVi6WGHhCi=
oVIo_si10jiydw=3D=3D" data-trackable=3D"true" style=3D"font-size: 16px; fon=
t-weight: bold; color: #FFFFFF; font-family: Helvetica,Arial,sans-serif; wo=
rd-wrap: break-word; text-decoration: none;">Get Pre-Approved</a> </td> </t=
r> </table> </td> </tr> </table> </td> </tr> </table> <table class=3D"=
layout layout--1-column" style=3D"table-layout: fixed;" width=3D"100%" bord=
er=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column colu=
mn--1 scale stack" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"image image--padding-vertical image--mobile-scale image--mo=
bile-center" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0=
"> <tr> <td class=3D"image_container" align=3D"center" valign=3D"top" style=
=3D"padding-top: 10px; padding-bottom: 10px;"> <img data-image-content clas=
s=3D"image_content" width=3D"87" src=3D"https://files.constantcontact.com/d=
f66e42d701/beefbeef-beef-beef-9a13-2779ab497b8d.png" alt=3D"" style=3D"disp=
lay: block; height: auto; max-width: 100%;"> </td> </tr> </table> </td> </t=
r> </table> <table class=3D"layout layout--1-column" style=3D"table-layout:=
fixed;" width=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <=
tr> <td class=3D"column column--1 scale stack" style=3D"width: 100%;" align=
=3D"center" valign=3D"top">
<table class=3D"text text--padding-vertical" width=3D"100%" border=3D"0" ce=
llpadding=3D"0" cellspacing=3D"0" style=3D"table-layout: fixed;"> <tr> <td =
class=3D"text_content-cell content-padding-horizontal" style=3D"text-align:=
left; font-family: Arial,Verdana,Helvetica,sans-serif; color: #000000; fon=
t-size: 14px; line-height: 1.2; display: block; word-wrap: break-word; padd=
ing: 10px 20px;" align=3D"left" valign=3D"top">
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><a href=3D"htt=
ps://r20.rs6.net/tn.jsp?f=3D001YKO1VR2jLW0SuSLZLfN7qCP9AwEGO0v-Vy-0SCUlMWvT=
EiCsv-QEMgYju54LKeEV1_a2OCyOAfG7VhZpxtOW89WM-s6S5iiXcmnbK-Z6XDc9LL569h6DE4L=
IRMWiBWHOlFB9TZWQVuX6Ycz3505y1keCrca4QArp&c=3DA65qX-dQJPS0J4afCS7H0Je5N-_6Q=
8Nh2fNHkb5-5biUYd5B9SY3zA=3D=3D&ch=3DHu9wLy0fth6D8jxFBWPA_NhdnWcZZPivk0KUTg=
RJoVIo_si10jiydw=3D=3D" target=3D"_blank" style=3D"font-size: 11px; color: =
rgb(153, 153, 153); text-decoration: underline; font-weight: normal; font-s=
tyle: normal;">nmlsconsumeraccess.org/</a></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">*The 24 hour timeframe is for=
most approvals, however if additional information is needed or a request i=
s on a holiday, the time for preapproval may be greater than 24 hours.</spa=
n></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=
, 255);">This email is for informational purposes only and is not an offer,=
loan approval or loan commitment. Mortgage rates are subject to change wit=
hout notice. Some terms and restrictions may apply to certain loan programs=
. Refinancing existing loans may result in total finance charges being high=
er over the life of the loan, reduction in payments may partially reflect a=
longer loan term. This information is provided as guidance and illustrativ=
e purposes only and does not constitute legal or financial advice. We are n=
ot liable or bound legally for any answers provided to any user for our pro=
cess or position on an issue. This information may change from time to time=
and at any time without notification. The most current information will be=
updated periodically and posted in the online forum.</span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153); background-color: rgb(255, 255=
, 255);">spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org.=
You are receiving this information as a current loan customer with spamspa=
m Loan Servicing, LLC. Not licensed for lending activities in any of the U.=
S. territories. Not authorized to originate loans in the State of New York.=
Licensed by the Dept. of Financial Protection and Innovation under the Cal=
ifornia Residential Mortgage .Lending Act #4131216.</span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><br></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">This email was sent to <span =
data-id=3D"emailAddress">somebody@gmail.com</span></span></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(153, 153, 153);">Version 103023PCHPrAp9 </span=
></p>
<p style=3D"text-align: center; margin: 0;" align=3D"center"><span style=3D=
"font-size: 11px; color: rgb(162, 162, 162);">&#xfeff;</span></p>
</td> </tr> </table> </td> </tr> </table> <table class=3D"layout layout--1-=
column" style=3D"table-layout: fixed;" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0"> <tr> <td class=3D"column column--1 scale stack=
" style=3D"width: 100%;" align=3D"center" valign=3D"top">
<table class=3D"divider" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0"=
border=3D"0"> <tr> <td class=3D"divider_container" style=3D"padding-top: 1=
0px; padding-bottom: 0px;" width=3D"100%" align=3D"center" valign=3D"top"> =
<table class=3D"divider_content-row" style=3D"width: 100%; height: 1px;" ce=
llpadding=3D"0" cellspacing=3D"0" border=3D"0"> <tr> <td class=3D"divider_c=
ontent-cell" style=3D"padding-bottom: 2px; height: 1px; line-height: 1px; b=
ackground-color: #0096D6; border-bottom-width: 0px;" height=3D"1" align=3D"=
center" bgcolor=3D"#0096D6"> <img alt=3D"" width=3D"5" height=3D"1" border=
=3D"0" hspace=3D"0" vspace=3D"0" src=3D"https://imgssl.constantcontact.com/=
letters/images/1111111111111/S.gif" style=3D"display: block; height: 1px; w=
idth: 5px;"> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table>=
</td> </tr> </table> </td> </tr> </table> </td> </tr> <tr> <td class=3D"s=
hell_panel-cell shell_panel-cell--systemFooter" style=3D"" align=3D"center"=
valign=3D"top"> <table class=3D"shell_width-row scale" style=3D"width: 100=
%;" align=3D"center" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr>=
<td class=3D"shell_width-cell" style=3D"padding: 0px;" align=3D"center" va=
lign=3D"top"> <table class=3D"shell_content-row" width=3D"100%" align=3D"ce=
nter" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=3D"s=
hell_content-cell" style=3D"background-color: #FFFFFF; padding: 0; border: =
0 solid #0096d6;" align=3D"center" valign=3D"top" bgcolor=3D"#FFFFFF"> <tab=
le class=3D"layout layout--1-column" style=3D"table-layout: fixed;" width=
=3D"100%" border=3D"0" cellpadding=3D"0" cellspacing=3D"0"> <tr> <td class=
=3D"column column--1 scale stack" style=3D"width: 100%;" align=3D"center" v=
align=3D"top"> <table class=3D"footer" width=3D"100%" border=3D"0" cellpadd=
ing=3D"0" cellspacing=3D"0" style=3D"font-family: Verdana,Geneva,sans-serif=
; color: #5d5d5d; font-size: 12px;"> <tr> <td class=3D"footer_container" al=
ign=3D"center"> <table class=3D"footer-container" width=3D"100%" cellpaddin=
g=3D"0" cellspacing=3D"0" border=3D"0" style=3D"background-color: #ffffff; =
margin-left: auto; margin-right: auto; table-layout: auto !important;" bgco=
lor=3D"#ffffff">
<tr>
<td width=3D"100%" align=3D"center" valign=3D"top" style=3D"width: 100%;">
<div class=3D"footer-max-main-width" align=3D"center" style=3D"margin-left:=
auto; margin-right: auto; max-width: 100%;">
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td class=3D"footer-layout" align=3D"center" valign=3D"top" style=3D"paddin=
g: 16px 0px;">
<table class=3D"footer-main-width" style=3D"width: 580px;" border=3D"0" cel=
lpadding=3D"0" cellspacing=3D"0">
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<span class=3D"footer-column">spamspam Loan Servicing<span class=3D"footer-=
mobile-hidden"> | </span></span><span class=3D"footer-column">4425 Ponce de=
Leon Blvd 5-251<span class=3D"footer-mobile-hidden">, </span></span><span =
class=3D"footer-column"></span><span class=3D"footer-column"></span><span c=
lass=3D"footer-column">Coral Gables, FL 33146-1837</span><span class=3D"foo=
ter-column"></span>
</td>
</tr>
<tr>
<td class=3D"footer-row" align=3D"center" valign=3D"top" style=3D"padding: =
10px 0px;">
<table cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<a href=3D"https://visitor.constantcontact.com/do?p=3Dun&m=3D001g3dtlqhzM3v=
-44b1-be0e-f5a444cb0650" data-track=3D"false" style=3D"color: #5d5d5d;">Uns=
ubscribe somebody@gmail.com<span class=3D"partnerOptOut"></span></a>
<span class=3D"partnerOptOut"></span>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
<a href=3D"https://visitor.constantcontact.com/do?p=3Doo&m=3D001g3dtlqhzM3v=
-44b1-be0e-f5a444cb0650" data-track=3D"false" style=3D"color: #5d5d5d;">Upd=
ate Profile</a> |
<a href=3D"https://spamspam.com/privacy-notice/" data-track=3D"false" style=
=3D"color: #5d5d5d;">Our Privacy Policy</a><span class=3D"footer-mobile-hid=
den"> |</span>
<a class=3D"footer-about-provider footer-mobile-stack footer-mobile-stack-p=
adding" href=3D"http://www.constantcontact.com/legal/about-constant-contact=
" data-track=3D"false" style=3D"color: #5d5d5d;">Constant Contact Data Noti=
ce</a>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
Sent by
<a href=3D"mailto:marklake@spamspam.com" style=3D"color: #5d5d5d; text-deco=
ration: none;">marklake@spamspam.com</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class=3D"footer-text" align=3D"center" valign=3D"top" style=3D"color: #=
5d5d5d; font-family: Verdana,Geneva,sans-serif; font-size: 12px; padding: 4=
px 0px;">
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table> </td> </tr> </table> </td> </tr> </table> </td> </tr> </table> =
</td> </tr> </table> </td> </tr> </table> </div> </body> </html>
------=_Part_75055660_144854819.1698672187348--
.
`
func TestSmtpBackend_Spam_Text(t *testing.T) {
email := spamEmail
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Buying a home? You deserve the confidence of Pre-Approval", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := "When you're buying a home, Pre-Approval gives you confidence you're in the right price range and shows sellers you mean business. xxxxxxxxx SELLING or BUYING? Call: 844-590-2275 Get Your Homebuying PRE-APPROVAL IN 24-HOURS* Get Pre-Approved When you're buying a home, Pre-Approval gives you confidence you're in the right price range and shows sellers you mean business. xxxxxxxxxGet Pre-Approved today! Click or Call to Get Pre-Approved 844-590-2275 Get Pre-Approved nmlsconsumeraccess.org/ *The 24 hour timeframe is for most approvals, however if additional information is needed or a request is on a holiday, the time for preapproval may be greater than 24 hours. This email is for informational purposes only and is not an offer, loan approval or loan commitment. Mortgage rates are subject to change without notice. Some terms and restrictions may apply to certain loan programs. Refinancing existing loans may result in total finance charges being higher over the life of the loan, reduction in payments may partially reflect a longer loan term. This information is provided as guidance and illustrative purposes only and does not constitute legal or financial advice. We are not liable or bound legally for any answers provided to any user for our process or position on an issue. This information may change from time to time and at any time without notification. The most current information will be updated periodically and posted in the online forum. spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org. You are receiving this information as a current loan customer with spamspam Loan Servicing, LLC. Not licensed for lending activities in any of the U.S. territories. Not authorized to originate loans in the State of New York. Licensed by the Dept. of Financial Protection and Innovation under the California Residential Mortgage .Lending Act #4131216. This email was sent to somebody@gmail.com Version 103023PCHPrAp9 xxxxxxxxx spamspam Loan Servicing | 4425 Ponce de Leon Blvd 5-251, Coral Gables, FL 33146-1837 Unsubscribe somebody@gmail.com Update Profile | Our Privacy Policy | Constant Contact Data Notice Sent by marklake@spamspam.com"
require.Equal(t, expected, actual)
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_Spam_HTML(t *testing.T) {
email := strings.ReplaceAll(spamEmail, "text/plain", "text/not-plain-anymore") // We artificially force HTML parsing here
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Buying a home? You deserve the confidence of Pre-Approval", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `When you&#39;re buying a home, Pre-Approval gives you confidence you&#39;re in the right price range and shows sellers you mean business.
` + "\u200a" + `
SELLING or BUYING?
Call: 844-590-2275
Get Your Homebuying
PRE-APPROVAL IN 24-HOURS *
Get Pre-Approved
When you&#39;re buying a home, Pre-Approval gives you confidence you&#39;re in the right price range and shows sellers you mean business.
` + "\ufeff" + `Get Pre-Approved today!
Click or Call to Get Pre-Approved
844-590-2275
Get Pre-Approved
nmlsconsumeraccess.org/
*The 24 hour timeframe is for most approvals, however if additional information is needed or a request is on a holiday, the time for preapproval may be greater than 24 hours.
This email is for informational purposes only and is not an offer, loan approval or loan commitment. Mortgage rates are subject to change without notice. Some terms and restrictions may apply to certain loan programs Refinancing existing loans may result in total finance charges being higher over the life of the loan, reduction in payments may partially reflect a longer loan term. This information is provided as guidance and illustrative purposes only and does not constitute legal or financial advice. We are not liable or bound legally for any answers provided to any user for our process or position on an issue. This information may change from time to time and at any time without notification. The most current information will be updated periodically and posted in the online forum.
spamspam Loan Servicing, LLC. NMLS#391521. nmlsconsumeraccess.org. You are receiving this information as a current loan customer with spamspam Loan Servicing, LLC. Not licensed for lending activities in any of the U.S. territories. Not authorized to originate loans in the State of New York. Licensed by the Dept. of Financial Protection and Innovation under the California Residential Mortgage .Lending Act #4131216.
This email was sent to somebody@gmail.com
Version 103023PCHPrAp9
` + "\ufeff" + `
spamspam Loan Servicing | 4425 Ponce de Leon Blvd 5-251 , Coral Gables, FL 33146-1837
Unsubscribe somebody@gmail.com
Update Profile |
Our Privacy Policy |
Constant Contact Data Notice
Sent by
marklake@spamspam.com`
require.Equal(t, expected, actual)
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_HTMLOnly_FromDiskStation(t *testing.T) {
email := `EHLO example.com
MAIL FROM: synology@mydomain.me
RCPT TO: synology@mydomain.me
DATA
From: "=?UTF-8?B?Um9iYmll?=" <synology@mydomain.me>
To: <synology@mydomain.me>
Message-Id: <640e6f562895d.6c9584bcfa491ac9c546b480b32ffc1d@mydomain.me>
MIME-Version: 1.0
Subject: =?UTF-8?B?W1N5bm9sb2d5IE5BU10gVGVzdCBNZXNzYWdlIGZyb20gTGl0dHNfTkFT?=
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 8bit
Congratulations! You have successfully set up the email notification on Synology_NAS.<BR>For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.<BR>(If you cannot connect to the server, please contact the administrator.)<BR><BR>From Synology_NAS<BR><BR><BR>
.
`
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/synology", r.URL.Path)
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS`
require.Equal(t, expected, actual)
})
conf.SMTPServerDomain = "mydomain.me"
conf.SMTPServerAddrPrefix = ""
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
@ -639,7 +1436,6 @@ func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expected
return
}
output += text + "\n"
//fmt.Println(text)
}
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
}

View file

@ -5,8 +5,8 @@ import (
"sync"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (

View file

@ -69,7 +69,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
t.Parallel()
to := newTopic("mytopic")
// Fix random seed to force same number generation
//lint:ignore SA1019 Fix random seed to force same number generation
rand.Seed(1)
a := rand.Int()
to.subscribers[a] = &topicSubscriber{
@ -82,7 +82,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
return nil
}
// Force rand.Int to generate the same id once more
//lint:ignore SA1019 Force rand.Int to generate the same id once more
rand.Seed(1)
id := to.Subscribe(subFn, "b", func() {})
res := to.subscribers[id]

View file

@ -5,10 +5,10 @@ import (
"net/netip"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
)
// List of possible events

View file

@ -3,15 +3,19 @@ package server
import (
"context"
"fmt"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
"io"
"mime"
"net/http"
"net/netip"
"regexp"
"strings"
)
var mimeDecoder mime.WordDecoder
var (
mimeDecoder mime.WordDecoder
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
value := strings.ToLower(readParam(r, names...))
@ -50,9 +54,9 @@ func readParam(r *http.Request, names ...string) string {
func readHeaderParam(r *http.Request, names ...string) string {
for _, name := range names {
value := maybeDecodeHeader(r.Header.Get(name))
value := strings.TrimSpace(maybeDecodeHeader(name, r.Header.Get(name)))
if value != "" {
return strings.TrimSpace(value)
return value
}
}
return ""
@ -126,10 +130,26 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
return t, nil
}
func maybeDecodeHeader(header string) string {
decoded, err := mimeDecoder.DecodeHeader(header)
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
// to ignore new HTTP "Priority" header.
func maybeDecodeHeader(name, value string) string {
decoded, err := mimeDecoder.DecodeHeader(value)
if err != nil {
return header
return maybeIgnoreSpecialHeader(name, value)
}
return decoded
return maybeIgnoreSpecialHeader(name, decoded)
}
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
//
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
// Returning an empty string will allow the rest of the logic to continue searching for another header (x-priority, prio, p),
// or in the Query parameters.
func maybeIgnoreSpecialHeader(name, value string) string {
if strings.ToLower(name) == "priority" && priorityHeaderIgnoreRegex.MatchString(strings.TrimSpace(value)) {
return ""
}
return value
}

View file

@ -2,9 +2,9 @@ package server
import (
"bytes"
"crypto/rand"
"fmt"
"github.com/stretchr/testify/require"
"math/rand"
"net/http"
"strings"
"testing"
@ -75,3 +75,16 @@ Accept: */*
(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)`
require.Equal(t, expected, renderHTTPRequest(r))
}
func TestMaybeIgnoreSpecialHeader(t *testing.T) {
require.Empty(t, maybeIgnoreSpecialHeader("priority", "u=1"))
require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1"))
require.Empty(t, maybeIgnoreSpecialHeader("Priority", "u=1, i"))
}
func TestMaybeDecodeHeaders(t *testing.T) {
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
r.Header.Set("Priority", "u=1") // Cloudflare priority header
r.Header.Set("X-Priority", "5") // ntfy priority header
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
}

View file

@ -2,14 +2,14 @@ package server
import (
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"net/netip"
"sync"
"time"
"golang.org/x/time/rate"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
)
const (

View file

@ -3,7 +3,7 @@ package server
import (
"database/sql"
"errors"
"heckel.io/ntfy/util"
"heckel.io/ntfy/v2/util"
"net/netip"
"time"