Skip to content

Commit 57a42d6

Browse files
committed
fix utm params
1 parent 8aaae0e commit 57a42d6

5 files changed

Lines changed: 185 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [30.3] - 2026-05-14
6+
7+
- **Fix**: UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`) were dropped from tracked links when click tracking was enabled — the encrypted `/r/` redirect token embedded the raw destination URL instead of the UTM-augmented one. The UTM parameters are now preserved in the redirect target.
8+
59
## [30.2] - 2026-05-13
610

711
- **Fix**: SES `4.4.7 Message expired` (retry-exhaustion) now suppresses on the first event, and any recipient that accumulates 5 consecutive soft bounces with no successful delivery in between is also suppressed; `MessageTooLarge`/`ContentRejected`/`AttachmentRejected` never count (#323).

config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
"github.com/spf13/viper"
1616
)
1717

18-
const VERSION = "30.2"
18+
const VERSION = "30.3"
1919

2020
type Config struct {
2121
Server ServerConfig

pkg/notifuse_mjml/template_compilation.go

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -172,61 +172,70 @@ func isNonTrackableURL(urlStr string) bool {
172172
return false
173173
}
174174

175-
func (t *TrackingSettings) GetTrackingURL(sourceURL string) string {
176-
// Ignore if URL is empty, a placeholder, mailto:, tel:, or already tracked (basic check)
177-
if sourceURL == "" || strings.Contains(sourceURL, "{{") || strings.Contains(sourceURL, "{%") || strings.HasPrefix(sourceURL, "mailto:") || strings.HasPrefix(sourceURL, "tel:") {
175+
// applyUTMParameters appends the configured UTM parameters to sourceURL and
176+
// returns the result. The URL is returned unchanged when it is empty, a Liquid
177+
// placeholder, a mailto:/tel: link, cannot be parsed, or already carries any
178+
// utm_* query parameter.
179+
func (t *TrackingSettings) applyUTMParameters(sourceURL string) string {
180+
if sourceURL == "" || strings.Contains(sourceURL, "{{") || strings.Contains(sourceURL, "{%") ||
181+
strings.HasPrefix(sourceURL, "mailto:") || strings.HasPrefix(sourceURL, "tel:") {
178182
return sourceURL
179183
}
180184

181-
// parse sourceURL to get the domain
182185
parsedURL, err := url.Parse(sourceURL)
183186
if err != nil {
184187
return sourceURL
185188
}
186189

187-
// Get existing query parameters
188190
queryParams := parsedURL.Query()
189191

190-
// Check if URL already has UTM parameters - if yes, don't modify them
191-
hasExistingUTM := false
192+
// If the URL already has UTM parameters, leave them untouched
192193
for key := range queryParams {
193194
if strings.HasPrefix(strings.ToLower(key), "utm_") {
194-
hasExistingUTM = true
195-
break
195+
return sourceURL
196196
}
197197
}
198198

199-
// Add UTM parameters to the URL if no existing UTM parameters
200-
if !hasExistingUTM {
201-
if t.UTMSource != "" {
202-
queryParams.Add("utm_source", t.UTMSource)
203-
}
204-
if t.UTMMedium != "" {
205-
queryParams.Add("utm_medium", t.UTMMedium)
206-
}
207-
if t.UTMCampaign != "" {
208-
queryParams.Add("utm_campaign", t.UTMCampaign)
209-
}
210-
if t.UTMContent != "" {
211-
queryParams.Add("utm_content", t.UTMContent)
212-
}
213-
if t.UTMTerm != "" {
214-
queryParams.Add("utm_term", t.UTMTerm)
215-
}
216-
parsedURL.RawQuery = queryParams.Encode()
199+
if t.UTMSource != "" {
200+
queryParams.Add("utm_source", t.UTMSource)
201+
}
202+
if t.UTMMedium != "" {
203+
queryParams.Add("utm_medium", t.UTMMedium)
204+
}
205+
if t.UTMCampaign != "" {
206+
queryParams.Add("utm_campaign", t.UTMCampaign)
207+
}
208+
if t.UTMContent != "" {
209+
queryParams.Add("utm_content", t.UTMContent)
217210
}
211+
if t.UTMTerm != "" {
212+
queryParams.Add("utm_term", t.UTMTerm)
213+
}
214+
parsedURL.RawQuery = queryParams.Encode()
215+
216+
return parsedURL.String()
217+
}
218+
219+
func (t *TrackingSettings) GetTrackingURL(sourceURL string) string {
220+
// Ignore if URL is empty, a placeholder, mailto:, tel:, or already tracked (basic check)
221+
if sourceURL == "" || strings.Contains(sourceURL, "{{") || strings.Contains(sourceURL, "{%") || strings.HasPrefix(sourceURL, "mailto:") || strings.HasPrefix(sourceURL, "tel:") {
222+
return sourceURL
223+
}
224+
225+
// Append UTM parameters to the destination URL
226+
destinationURL := t.applyUTMParameters(sourceURL)
218227

219228
if !t.EnableTracking {
220-
return parsedURL.String()
229+
return destinationURL
221230
}
222231

223-
// parse endpoint and add url to the query params
232+
// parse endpoint and add the UTM-augmented destination URL to the query params
224233
parsedEndpoint, err := url.Parse(t.Endpoint)
225234
if err != nil {
226235
return sourceURL
227236
}
228237
endpointParams := parsedEndpoint.Query()
229-
endpointParams.Add("url", parsedURL.String()) // Use the URL with UTM parameters
238+
endpointParams.Add("url", destinationURL)
230239
parsedEndpoint.RawQuery = endpointParams.Encode()
231240

232241
return parsedEndpoint.String()
@@ -622,13 +631,16 @@ func TrackLinks(htmlString string, trackingSettings TrackingSettings) (updatedHT
622631
return match // Return original link unchanged
623632
}
624633

625-
// Apply tracking to the URL
626-
trackedURL := trackingSettings.GetTrackingURL(originalURL)
634+
// Append UTM parameters to the destination URL
635+
destinationURL := trackingSettings.applyUTMParameters(originalURL)
636+
trackedURL := destinationURL
627637

628638
if trackingSettings.EnableTracking {
629-
// Use current Unix timestamp (seconds) for bot detection
639+
// Use current Unix timestamp (seconds) for bot detection.
640+
// The UTM-augmented destination URL is what gets encrypted into the
641+
// token, so the redirect target preserves the UTM parameters.
630642
sentTimestamp := time.Now().Unix()
631-
trackedURL = GenerateEmailRedirectionEndpoint(trackingSettings.WorkspaceID, trackingSettings.MessageID, trackingSettings.Endpoint, originalURL, sentTimestamp)
643+
trackedURL = GenerateEmailRedirectionEndpoint(trackingSettings.WorkspaceID, trackingSettings.MessageID, trackingSettings.Endpoint, destinationURL, sentTimestamp)
632644
}
633645

634646
// Return the updated tag

pkg/notifuse_mjml/template_compilation_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package notifuse_mjml
22

33
import (
44
"encoding/json"
5+
"regexp"
56
"strings"
67
"testing"
78

9+
"github.com/Notifuse/notifuse/pkg/crypto"
810
"github.com/stretchr/testify/assert"
911
)
1012

@@ -296,6 +298,62 @@ func TestTrackLinksInvalidHTML(t *testing.T) {
296298
}
297299
}
298300

301+
// TestTrackLinks_PreservesUTMInEncryptedToken verifies that when click tracking
302+
// is enabled, the UTM parameters are NOT dropped: they must be appended to the
303+
// destination URL that gets encrypted into the /r/{token} redirect link.
304+
func TestTrackLinks_PreservesUTMInEncryptedToken(t *testing.T) {
305+
trackingSettings := TrackingSettings{
306+
EnableTracking: true,
307+
Endpoint: "https://track.example.com",
308+
UTMSource: "newsletter",
309+
UTMMedium: "email",
310+
UTMCampaign: "spring-sale",
311+
UTMContent: "hero-button",
312+
UTMTerm: "running-shoes",
313+
WorkspaceID: "ws-1",
314+
MessageID: "msg-1",
315+
}
316+
317+
htmlInput := `<a href="https://shop.example.com/product?ref=email">Buy Now</a>`
318+
319+
result, err := TrackLinks(htmlInput, trackingSettings)
320+
if err != nil {
321+
t.Fatalf("TrackLinks failed: %v", err)
322+
}
323+
324+
// Extract the encrypted /r/{token} link that TrackLinks generated
325+
tokenRegex := regexp.MustCompile(`href="https://track\.example\.com/r/([^"]+)"`)
326+
m := tokenRegex.FindStringSubmatch(result)
327+
if m == nil {
328+
t.Fatalf("expected a /r/{token} tracking link, got: %s", result)
329+
}
330+
331+
// Decrypt the token: format is "messageID\nworkspaceID\ntimestamp\ndestinationURL"
332+
plaintext, err := crypto.DecryptTrackingToken(m[1])
333+
if err != nil {
334+
t.Fatalf("failed to decrypt tracking token: %v", err)
335+
}
336+
parts := strings.Split(plaintext, "\n")
337+
if len(parts) != 4 {
338+
t.Fatalf("expected 4 token parts, got %d: %q", len(parts), plaintext)
339+
}
340+
destinationURL := parts[3]
341+
342+
// The destination URL embedded in the token must carry every UTM parameter
343+
for _, want := range []string{
344+
"utm_source=newsletter",
345+
"utm_medium=email",
346+
"utm_campaign=spring-sale",
347+
"utm_content=hero-button",
348+
"utm_term=running-shoes",
349+
"ref=email", // pre-existing query params must be preserved too
350+
} {
351+
if !strings.Contains(destinationURL, want) {
352+
t.Errorf("expected destination URL %q to contain %q", destinationURL, want)
353+
}
354+
}
355+
}
356+
299357
func TestGetTrackingURL(t *testing.T) {
300358
trackingSettings := TrackingSettings{
301359
EnableTracking: true,

tests/integration/email_handler_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import (
77
"io"
88
"net/http"
99
"net/url"
10+
"regexp"
1011
"strings"
1112
"testing"
1213
"time"
1314

1415
"github.com/Notifuse/notifuse/internal/domain"
1516
"github.com/Notifuse/notifuse/pkg/crypto"
17+
"github.com/Notifuse/notifuse/pkg/notifuse_mjml"
1618
"github.com/Notifuse/notifuse/tests/testutil"
1719
"github.com/stretchr/testify/assert"
1820
"github.com/stretchr/testify/require"
@@ -944,6 +946,80 @@ func TestEmailHandler_EncryptedTracking(t *testing.T) {
944946
})
945947
}
946948

949+
// TestEmailHandler_TrackedLinkPreservesUTM exercises the real pipeline end to
950+
// end: it compiles an email template with click tracking enabled and UTM
951+
// parameters configured (exactly as the broadcast message sender does), then
952+
// follows the generated /r/{token} link against the running server and asserts
953+
// that the final redirect destination still carries every UTM parameter.
954+
func TestEmailHandler_TrackedLinkPreservesUTM(t *testing.T) {
955+
testutil.SkipIfShort(t)
956+
testutil.SetupTestEnvironment()
957+
defer testutil.CleanupTestEnvironment()
958+
959+
suite := testutil.NewIntegrationTestSuite(t, appFactory)
960+
defer func() { suite.Cleanup() }()
961+
962+
baseURL := suite.ServerManager.GetURL()
963+
964+
mjmlSource := `<mjml><mj-body><mj-section><mj-column>` +
965+
`<mj-button href="https://shop.example.com/product?ref=email">Buy Now</mj-button>` +
966+
`</mj-column></mj-section></mj-body></mjml>`
967+
968+
compileResp, err := notifuse_mjml.CompileTemplate(notifuse_mjml.CompileTemplateRequest{
969+
WorkspaceID: "ws-utm",
970+
MessageID: "msg-utm",
971+
MjmlSource: &mjmlSource,
972+
TrackingSettings: notifuse_mjml.TrackingSettings{
973+
EnableTracking: true,
974+
Endpoint: baseURL,
975+
UTMSource: "newsletter",
976+
UTMMedium: "email",
977+
UTMCampaign: "spring-sale",
978+
UTMContent: "hero-button",
979+
UTMTerm: "running-shoes",
980+
WorkspaceID: "ws-utm",
981+
MessageID: "msg-utm",
982+
},
983+
})
984+
require.NoError(t, err)
985+
require.NotNil(t, compileResp)
986+
require.True(t, compileResp.Success)
987+
require.NotNil(t, compileResp.HTML)
988+
989+
// Extract the encrypted /r/{token} tracking link the pipeline generated
990+
linkRegex := regexp.MustCompile(`href="(` + regexp.QuoteMeta(baseURL) + `/r/[^"]+)"`)
991+
match := linkRegex.FindStringSubmatch(*compileResp.HTML)
992+
require.NotNil(t, match, "expected a tracked /r/ link in compiled HTML: %s", *compileResp.HTML)
993+
trackedLink := match[1]
994+
995+
client := &http.Client{
996+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
997+
return http.ErrUseLastResponse
998+
},
999+
}
1000+
req, err := http.NewRequest(http.MethodGet, trackedLink, nil)
1001+
require.NoError(t, err)
1002+
req.Header.Set("User-Agent", "Mozilla/5.0 Chrome/120.0.0.0")
1003+
1004+
resp, err := client.Do(req)
1005+
require.NoError(t, err)
1006+
defer func() { _ = resp.Body.Close() }()
1007+
1008+
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
1009+
1010+
// The final redirect destination must carry every configured UTM parameter
1011+
location := resp.Header.Get("Location")
1012+
parsedLocation, err := url.Parse(location)
1013+
require.NoError(t, err)
1014+
q := parsedLocation.Query()
1015+
assert.Equal(t, "newsletter", q.Get("utm_source"))
1016+
assert.Equal(t, "email", q.Get("utm_medium"))
1017+
assert.Equal(t, "spring-sale", q.Get("utm_campaign"))
1018+
assert.Equal(t, "hero-button", q.Get("utm_content"))
1019+
assert.Equal(t, "running-shoes", q.Get("utm_term"))
1020+
assert.Equal(t, "email", q.Get("ref"), "pre-existing query params must be preserved")
1021+
}
1022+
9471023
func TestEmailHandler_ConcurrentRequests(t *testing.T) {
9481024
testutil.SkipIfShort(t)
9491025
testutil.SetupTestEnvironment()

0 commit comments

Comments
 (0)