Skip to content

Commit ba63690

Browse files
committed
fixes
1 parent ca9c23b commit ba63690

File tree

13 files changed

+744
-25
lines changed

13 files changed

+744
-25
lines changed

CHANGELOG.md

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

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

5+
## [26.13] - 2026-01-25
6+
7+
- **Segments**: Added real-time validation when creating segments to detect duplicate IDs before submission (#243)
8+
- **Email Builder**: Fixed clicking image blocks with links navigating away instead of allowing block selection (#239)
9+
- **Email Builder**: Fixed popover buttons overflowing in certain languages by using auto-width with minimum constraint (#240)
10+
- **Amazon SES**: Fixed Notifuse message ID extraction from webhook
11+
512
## [26.12] - 2026-01-24
613

714
- **Transactional Notifications**: Added card-based UI with delivery stats (sent, delivered, failed, bounced) and period selector (7D/30D/60D)

config/config.go

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

17-
const VERSION = "26.12"
17+
const VERSION = "26.13"
1818

1919
type Config struct {
2020
Server ServerConfig

console/src/components/email_builder/blocks/MjImageBlock.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,13 @@ export class MjImageBlock extends BaseEmailBlock {
340340
href={attrs.href}
341341
target={attrs.target || '_blank'}
342342
rel={attrs.rel}
343-
style={{
343+
style={{
344344
textDecoration: 'none',
345345
display: 'block',
346346
borderRadius: attrs.borderRadius,
347347
overflow: 'hidden'
348348
}}
349+
onClick={(e) => e.preventDefault()}
349350
>
350351
{imageElement}
351352
</a>

console/src/components/email_builder/editor.css

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,17 @@
1818
}
1919

2020
/* Popover content buttons (for link and emoji popovers) */
21-
.tiptap-link-popover .ant-btn,
22-
.tiptap-emoji-popover .ant-btn {
23-
color: inherit !important;
24-
/* Let these inherit from their parent context */
21+
/* Only apply to default buttons, not primary/danger which need white text */
22+
.tiptap-link-popover .ant-btn-default,
23+
.tiptap-emoji-popover .ant-btn-default {
24+
color: rgba(0, 0, 0, 0.88) !important;
25+
}
26+
27+
.tiptap-link-popover .ant-btn-primary,
28+
.tiptap-link-popover .ant-btn-dangerous,
29+
.tiptap-emoji-popover .ant-btn-primary,
30+
.tiptap-emoji-popover .ant-btn-dangerous {
31+
color: #fff !important;
2532
}
2633

2734
/* Input elements inside TiptapToolbar popovers */

console/src/components/email_builder/ui/StringPopoverInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const StringPopoverInput: React.FC<StringPopoverInputProps> = ({
7373
}
7474

7575
const content = (
76-
<div className="w-64">
76+
<div className="w-fit min-w-64">
7777
<Input
7878
size="small"
7979
value={inputValue}

console/src/components/email_builder/ui/tiptap/components/TiptapToolbar.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ export const ColorButton: React.FC<ColorButtonProps> = ({
193193
>
194194
<div style={{ position: 'relative' }}>
195195
<ToolbarButton title={title} isActive={isActive}>
196-
<FontAwesomeIcon icon={icon as import('@fortawesome/fontawesome-svg-core').IconProp} size="xs" />
196+
<FontAwesomeIcon
197+
icon={icon as import('@fortawesome/fontawesome-svg-core').IconProp}
198+
size="xs"
199+
/>
197200
</ToolbarButton>
198201
<div
199202
style={{
@@ -300,7 +303,7 @@ export const LinkButton: React.FC<LinkButtonProps> = ({ editor, title }) => {
300303
setLinkType('url')
301304
}
302305
}
303-
// eslint-disable-next-line react-hooks/exhaustive-deps -- getCurrentLink is stable
306+
// eslint-disable-next-line react-hooks/exhaustive-deps -- getCurrentLink is stable
304307
}, [visible, isActiveLink])
305308

306309
const handleInsertLink = () => {
@@ -355,7 +358,7 @@ export const LinkButton: React.FC<LinkButtonProps> = ({ editor, title }) => {
355358
return (
356359
<Popover
357360
content={
358-
<div style={{ width: '300px', padding: '8px' }}>
361+
<div style={{ minWidth: '300px', padding: '8px' }}>
359362
<div style={{ marginBottom: '12px' }}>
360363
<label
361364
style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}
@@ -377,10 +380,10 @@ export const LinkButton: React.FC<LinkButtonProps> = ({ editor, title }) => {
377380
{linkType === 'url'
378381
? t`URL`
379382
: linkType === 'email'
380-
? t`Email Address`
381-
: linkType === 'phone'
382-
? t`Phone Number`
383-
: t`Anchor ID`}
383+
? t`Email Address`
384+
: linkType === 'phone'
385+
? t`Phone Number`
386+
: t`Anchor ID`}
384387
</label>
385388
<Input
386389
value={linkValue}
@@ -395,7 +398,7 @@ export const LinkButton: React.FC<LinkButtonProps> = ({ editor, title }) => {
395398
{t`Cancel`}
396399
</Button>
397400
{isActiveLink && (
398-
<Button size="small" onClick={handleRemoveLink} danger>
401+
<Button size="small" onClick={handleRemoveLink} color="danger" variant="outlined">
399402
{t`Remove Link`}
400403
</Button>
401404
)}

console/src/components/segment/button_upsert.tsx

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
message
1515
} from 'antd'
1616
import React, { useMemo, useState, useEffect } from 'react'
17+
import { debounce } from 'lodash'
1718
import { useParams } from '@tanstack/react-router'
1819
import { useAuth } from '../../contexts/AuthContext'
1920
import { TreeNodeInput, HasLeaf } from './input'
@@ -26,6 +27,7 @@ import {
2627
createSegment,
2728
updateSegment,
2829
previewSegment,
30+
getSegment,
2931
CreateSegmentRequest,
3032
UpdateSegmentRequest,
3133
PreviewSegmentRequest,
@@ -125,6 +127,10 @@ const DrawerSegment = (props: {
125127
const [loadingPreview, setLoadingPreview] = useState(false)
126128
const [previewedData, setPreviewedData] = useState<string | undefined>() // track the tree hash to avoid re-render
127129
const [previewResponse, setPreviewResponse] = useState<PreviewSegmentResponse | undefined>()
130+
const [idValidation, setIdValidation] = useState<{
131+
status: '' | 'validating' | 'error' | 'success'
132+
message: string
133+
}>({ status: '', message: '' })
128134

129135
// Find the current workspace
130136
const workspace = useMemo(() => {
@@ -168,6 +174,56 @@ const DrawerSegment = (props: {
168174

169175
const lists = listsData?.lists || []
170176

177+
// Generate segment ID from name (same logic as in onFinish)
178+
const generateSegmentId = (name: string): string => {
179+
return name
180+
.toLowerCase()
181+
.replace(/[\s-]+/g, '_')
182+
.replace(/[^a-z0-9_]/g, '')
183+
.replace(/^_+|_+$/g, '')
184+
.replace(/_+/g, '_')
185+
}
186+
187+
// Debounced function to check if segment ID exists
188+
const checkIdExists = useMemo(
189+
() =>
190+
debounce(async (name: string) => {
191+
// Skip validation in edit mode or if no workspace
192+
if (!name || !workspaceId || props.segment) {
193+
setIdValidation({ status: '', message: '' })
194+
return
195+
}
196+
197+
const id = generateSegmentId(name)
198+
if (!id) {
199+
setIdValidation({ status: '', message: '' })
200+
return
201+
}
202+
203+
setIdValidation({ status: 'validating', message: '' })
204+
205+
try {
206+
await getSegment({ workspace_id: workspaceId, id })
207+
// Segment exists (active or deleted) - show error
208+
setIdValidation({
209+
status: 'error',
210+
message: t`A segment with ID "${id}" already exists`
211+
})
212+
} catch {
213+
// Segment not found - ID is available
214+
setIdValidation({ status: 'success', message: '' })
215+
}
216+
}, 500),
217+
[workspaceId, props.segment, t]
218+
)
219+
220+
// Cleanup debounce on unmount
221+
useEffect(() => {
222+
return () => {
223+
checkIdExists.cancel()
224+
}
225+
}, [checkIdExists])
226+
171227
const preview = async () => {
172228
if (loadingPreview || !workspaceId) return
173229
setLoadingPreview(true)
@@ -211,6 +267,12 @@ const DrawerSegment = (props: {
211267
const onFinish = async (values: { name: string; color: string; tree: TreeNode; timezone: string }) => {
212268
if (loading || !workspaceId) return
213269

270+
// Block submission if ID validation failed (only for create mode)
271+
if (!props.segment && idValidation.status === 'error') {
272+
message.error(t`Please choose a different segment name`)
273+
return
274+
}
275+
214276
setLoading(true)
215277

216278
try {
@@ -309,8 +371,26 @@ const DrawerSegment = (props: {
309371
<Col span={18}>
310372
<Form.Item label={t`Name`} required>
311373
<Space.Compact style={{ width: '100%' }}>
312-
<Form.Item name="name" noStyle rules={[{ required: true, type: 'string' }]}>
313-
<Input placeholder={t`i.e: Big spenders...`} style={{ flex: 1 }} />
374+
<Form.Item
375+
name="name"
376+
rules={[{ required: true, type: 'string' }]}
377+
validateStatus={
378+
idValidation.status === 'validating'
379+
? 'validating'
380+
: idValidation.status === 'error'
381+
? 'error'
382+
: idValidation.status === 'success'
383+
? 'success'
384+
: undefined
385+
}
386+
help={idValidation.status === 'error' ? idValidation.message : undefined}
387+
hasFeedback={!props.segment && idValidation.status !== ''}
388+
style={{ flex: 1, marginBottom: 0 }}
389+
>
390+
<Input
391+
placeholder={t`i.e: Big spenders...`}
392+
onChange={(e) => checkIdExists(e.target.value)}
393+
/>
314394
</Form.Item>
315395
<Form.Item noStyle name="color">
316396
<Select

env.example

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@
2121
# Example: openssl rand -base64 32
2222
SECRET_KEY=your-secret-key-here
2323

24-
# Database Configuration (REQUIRED)
25-
DB_HOST=localhost
26-
DB_PORT=5432
27-
DB_USER=postgres
28-
DB_PASSWORD=your-secure-password
29-
DB_NAME=notifuse_system
24+
# Database Configuration (Optional - compose.yaml has working defaults for Docker)
25+
# Uncomment and modify only if using an external database or custom setup
26+
# DB_HOST=postgres
27+
# DB_PORT=5432
28+
# DB_USER=postgres
29+
# DB_PASSWORD=postgres
30+
# DB_NAME=notifuse_system
3031

3132
# =============================================================================
3233
# SETUP WIZARD - Configure via Web Interface (Recommended)

internal/service/inbound_webhook_event_service.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ func isHardBounce(bounceType, bounceCategory string) bool {
260260
return false
261261
}
262262

263+
// extractXMessageIDFromHeaders searches for the X-Message-ID header in SES mail headers.
264+
// This is used as a fallback when notifuse_message_id tag is not present
265+
// (e.g., for emails sent via SendRawEmail before tags were added).
266+
func extractXMessageIDFromHeaders(headers []domain.SESHeader) string {
267+
for _, header := range headers {
268+
if strings.EqualFold(header.Name, "X-Message-ID") {
269+
return header.Value
270+
}
271+
}
272+
return ""
273+
}
274+
263275
// processSESWebhook processes a webhook event from Amazon SES
264276
func (s *InboundWebhookEventService) processSESWebhook(integrationID string, rawPayload []byte) (events []*domain.InboundWebhookEvent, err error) {
265277

@@ -342,6 +354,10 @@ func (s *InboundWebhookEventService) processSESWebhook(integrationID string, raw
342354
notifuseMessageID = ids[0]
343355
}
344356
}
357+
// Fallback to X-Message-ID header if tag not found
358+
if notifuseMessageID == "" {
359+
notifuseMessageID = extractXMessageIDFromHeaders(bounceNotification.Mail.Headers)
360+
}
345361

346362
// Parse timestamp
347363
if t, err := time.Parse(time.RFC3339, bounceNotification.Bounce.Timestamp); err == nil {
@@ -366,6 +382,10 @@ func (s *InboundWebhookEventService) processSESWebhook(integrationID string, raw
366382
notifuseMessageID = ids[0]
367383
}
368384
}
385+
// Fallback to X-Message-ID header if tag not found
386+
if notifuseMessageID == "" {
387+
notifuseMessageID = extractXMessageIDFromHeaders(complaintNotification.Mail.Headers)
388+
}
369389

370390
// Parse timestamp
371391
if t, err := time.Parse(time.RFC3339, complaintNotification.Complaint.Timestamp); err == nil {
@@ -389,6 +409,10 @@ func (s *InboundWebhookEventService) processSESWebhook(integrationID string, raw
389409
notifuseMessageID = ids[0]
390410
}
391411
}
412+
// Fallback to X-Message-ID header if tag not found
413+
if notifuseMessageID == "" {
414+
notifuseMessageID = extractXMessageIDFromHeaders(deliveryNotification.Mail.Headers)
415+
}
392416

393417
// Parse timestamp
394418
if t, err := time.Parse(time.RFC3339, deliveryNotification.Delivery.Timestamp); err == nil {

0 commit comments

Comments
 (0)