diff --git a/cmd/oauth.go b/cmd/oauth.go
index e3dc9984..ca09a047 100644
--- a/cmd/oauth.go
+++ b/cmd/oauth.go
@@ -152,9 +152,9 @@ func handleOAuthCallback(r *fastglue.Request) error {
redirectURI := oauthData["redirect_uri"]
clientID := oauthData["client_id"]
clientSecret := oauthData["client_secret"]
- tenantID := oauthData["tenant_id"] // Empty string if not set
- flowType := oauthData["flow_type"] // "new_inbox" or "reconnect"
- inboxIDStr := oauthData["inbox_id"] // Inbox ID for reconnect flow
+ tenantID := oauthData["tenant_id"] // Empty string if not set
+ flowType := oauthData["flow_type"] // "new_inbox" or "reconnect"
+ inboxIDStr := oauthData["inbox_id"] // Inbox ID for reconnect flow
// Validate provider matches URL parameter
if storedProvider != provider {
@@ -310,11 +310,12 @@ func handleOAuthCallback(r *fastglue.Request) error {
// Create inbox config
config := imodels.Config{
- SMTP: []imodels.SMTPConfig{smtpConfig},
- IMAP: []imodels.IMAPConfig{imapConfig},
- From: userEmail,
- AuthType: imodels.AuthTypeOAuth2,
- OAuth: oauthConfig,
+ SMTP: []imodels.SMTPConfig{smtpConfig},
+ IMAP: []imodels.IMAPConfig{imapConfig},
+ From: userEmail,
+ AuthType: imodels.AuthTypeOAuth2,
+ OAuth: oauthConfig,
+ EnablePlusAddressing: true,
}
configJSON, err := json.Marshal(config)
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index bb0cf3be..95ac5b56 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -39,6 +39,7 @@ var migList = []migFunc{
{"v0.8.5", migrations.V0_8_5},
{"v0.9.1", migrations.V0_9_1},
{"v0.10.0", migrations.V0_10_0},
+ {"v1.0.1", migrations.V1_0_1},
}
// upgrade upgrades the database to the current version by running SQL migration files
diff --git a/frontend/src/features/admin/inbox/EmailInboxForm.vue b/frontend/src/features/admin/inbox/EmailInboxForm.vue
index 232c4de6..9bc1bd13 100644
--- a/frontend/src/features/admin/inbox/EmailInboxForm.vue
+++ b/frontend/src/features/admin/inbox/EmailInboxForm.vue
@@ -54,11 +54,25 @@
-
+
{{ $t('admin.inbox.csatSurveys.description_2') }}
+
+
+
+ {{ $t('admin.inbox.enablePlusAddressing') }}
+
+ {{ $t('admin.inbox.enablePlusAddressing.description') }}
+
+
+
+
+
+
+
+
@@ -729,6 +743,7 @@ const form = useForm({
from: '',
enabled: true,
csat_enabled: false,
+ enable_plus_addressing: true,
auth_type: AUTH_TYPE_PASSWORD,
imap: {
host: 'imap.gmail.com',
diff --git a/frontend/src/features/admin/inbox/formSchema.js b/frontend/src/features/admin/inbox/formSchema.js
index 69d3db21..c5a22357 100644
--- a/frontend/src/features/admin/inbox/formSchema.js
+++ b/frontend/src/features/admin/inbox/formSchema.js
@@ -7,6 +7,7 @@ export const createFormSchema = (t) => z.object({
from: z.string().min(1, t('globals.messages.required')),
enabled: z.boolean().optional(),
csat_enabled: z.boolean().optional(),
+ enable_plus_addressing: z.boolean().optional(),
auth_type: z.enum([AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2]),
oauth: z.object({
access_token: z.string().optional(),
diff --git a/frontend/src/views/admin/inbox/EditInbox.vue b/frontend/src/views/admin/inbox/EditInbox.vue
index 9c914be2..2a4f18e2 100644
--- a/frontend/src/views/admin/inbox/EditInbox.vue
+++ b/frontend/src/views/admin/inbox/EditInbox.vue
@@ -32,6 +32,7 @@ const submitForm = (values) => {
// Prepare request payload from form values
const config = {
auth_type: values.auth_type,
+ enable_plus_addressing: values.enable_plus_addressing,
imap: [{ ...values.imap }],
smtp: [{ ...values.smtp }]
}
@@ -106,6 +107,7 @@ onMounted(async () => {
}
inboxData.auth_type = inboxData?.config?.auth_type || AUTH_TYPE_PASSWORD
inboxData.oauth = inboxData?.config?.oauth || {}
+ inboxData.enable_plus_addressing = inboxData?.config?.enable_plus_addressing || false
inbox.value = inboxData
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
diff --git a/frontend/src/views/admin/inbox/NewInbox.vue b/frontend/src/views/admin/inbox/NewInbox.vue
index 1ac41435..e1a45f61 100644
--- a/frontend/src/views/admin/inbox/NewInbox.vue
+++ b/frontend/src/views/admin/inbox/NewInbox.vue
@@ -158,6 +158,7 @@ const submitForm = (values) => {
from: values.from,
channel: channelName,
config: {
+ enable_plus_addressing: values.enable_plus_addressing,
imap: [values.imap],
smtp: [values.smtp]
}
diff --git a/i18n/en.json b/i18n/en.json
index 2f8b62d2..70b0b93b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -511,6 +511,8 @@
"admin.inbox.heloHostname.description": "The hostname to use in the HELO/EHLO command. If not set, defaults to localhost.",
"admin.inbox.skipTLSVerification": "Skip TLS Verification",
"admin.inbox.skipTLSVerification.description": "Skip hostname check on the TLS certificate.",
+ "admin.inbox.enablePlusAddressing": "Enable plus addressing",
+ "admin.inbox.enablePlusAddressing.description": "Improves conversation threading but requires provider support (e.g., Gmail, Microsoft 365).",
"admin.inbox.chooseChannel": "Choose a channel",
"admin.inbox.configureChannel": "Configure channel",
"admin.inbox.createEmailInbox": "Create Email Inbox",
diff --git a/internal/inbox/channel/email/email.go b/internal/inbox/channel/email/email.go
index 53eed3ce..8b437aa3 100644
--- a/internal/inbox/channel/email/email.go
+++ b/internal/inbox/channel/email/email.go
@@ -33,6 +33,7 @@ type Email struct {
headers map[string]string
lo *logf.Logger
from string
+ enablePlusAddressing bool
messageStore inbox.MessageStore
userStore inbox.UserStore
wg sync.WaitGroup
@@ -77,6 +78,7 @@ func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*Email
userStore: userStore,
oauth: opts.Config.OAuth,
authType: opts.Config.AuthType,
+ enablePlusAddressing: opts.Config.EnablePlusAddressing,
tokenRefreshCallback: opts.TokenRefreshCallback,
}
return e, nil
@@ -124,11 +126,12 @@ func (e *Email) getCurrentConfig() models.Config {
e.oauthMu.RUnlock()
return models.Config{
- SMTP: e.smtpCfg,
- IMAP: e.imapCfg,
- From: e.from,
- OAuth: oauth,
- AuthType: e.authType,
+ SMTP: e.smtpCfg,
+ IMAP: e.imapCfg,
+ From: e.from,
+ OAuth: oauth,
+ AuthType: e.authType,
+ EnablePlusAddressing: e.enablePlusAddressing,
}
}
diff --git a/internal/inbox/channel/email/smtp.go b/internal/inbox/channel/email/smtp.go
index 0d7b5c6a..082ecc8e 100644
--- a/internal/inbox/channel/email/smtp.go
+++ b/internal/inbox/channel/email/smtp.go
@@ -180,9 +180,9 @@ func (e *Email) Send(m models.Message) error {
}
email.Headers.Set(headerLibredeskLoopPrevention, emailAddress)
- // Set Reply-To with plus-addressing for conversation matching
+ // Set Reply-To with plus-addressing for conversation matching (if enabled)
// e.g., support@company.com → support+conv-{uuid}@company.com
- if m.ConversationUUID != "" {
+ if e.enablePlusAddressing && m.ConversationUUID != "" {
replyToAddr := buildPlusAddress(emailAddress, m.ConversationUUID)
email.Headers.Set("Reply-To", replyToAddr)
e.lo.Debug("Reply-To header set with plus-addressing", "reply_to", replyToAddr)
diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go
index 78827c5d..933e864c 100644
--- a/internal/inbox/inbox.go
+++ b/internal/inbox/inbox.go
@@ -305,16 +305,18 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
switch current.Channel {
case "email":
var currentCfg struct {
- AuthType string `json:"auth_type"`
- OAuth map[string]string `json:"oauth"`
- IMAP []map[string]interface{} `json:"imap"`
- SMTP []map[string]interface{} `json:"smtp"`
+ AuthType string `json:"auth_type"`
+ OAuth map[string]string `json:"oauth"`
+ IMAP []map[string]any `json:"imap"`
+ SMTP []map[string]any `json:"smtp"`
+ EnablePlusAddressing bool `json:"enable_plus_addressing"`
}
var updateCfg struct {
- AuthType string `json:"auth_type"`
- OAuth map[string]string `json:"oauth"`
- IMAP []map[string]interface{} `json:"imap"`
- SMTP []map[string]interface{} `json:"smtp"`
+ AuthType string `json:"auth_type"`
+ OAuth map[string]string `json:"oauth"`
+ IMAP []map[string]any `json:"imap"`
+ SMTP []map[string]any `json:"smtp"`
+ EnablePlusAddressing bool `json:"enable_plus_addressing"`
}
if err := json.Unmarshal(current.Config, ¤tCfg); err != nil {
@@ -496,15 +498,15 @@ func (m *Manager) encryptInboxConfig(config json.RawMessage) (json.RawMessage, e
return config, nil
}
- var cfg map[string]interface{}
+ var cfg map[string]any
if err := json.Unmarshal(config, &cfg); err != nil {
return nil, fmt.Errorf("unmarshalling config: %w", err)
}
// Encrypt SMTP passwords
- if smtpSlice, ok := cfg["smtp"].([]interface{}); ok {
+ if smtpSlice, ok := cfg["smtp"].([]any); ok {
for i, smtpItem := range smtpSlice {
- if smtpMap, ok := smtpItem.(map[string]interface{}); ok {
+ if smtpMap, ok := smtpItem.(map[string]any); ok {
if password, ok := smtpMap["password"].(string); ok && password != "" {
encrypted, err := crypto.Encrypt(password, m.encryptionKey)
if err != nil {
@@ -517,9 +519,9 @@ func (m *Manager) encryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
// Encrypt IMAP passwords
- if imapSlice, ok := cfg["imap"].([]interface{}); ok {
+ if imapSlice, ok := cfg["imap"].([]any); ok {
for i, imapItem := range imapSlice {
- if imapMap, ok := imapItem.(map[string]interface{}); ok {
+ if imapMap, ok := imapItem.(map[string]any); ok {
if password, ok := imapMap["password"].(string); ok && password != "" {
encrypted, err := crypto.Encrypt(password, m.encryptionKey)
if err != nil {
@@ -532,7 +534,7 @@ func (m *Manager) encryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
// Encrypt OAuth fields if present
- if oauthMap, ok := cfg["oauth"].(map[string]interface{}); ok {
+ if oauthMap, ok := cfg["oauth"].(map[string]any); ok {
fields := []string{"client_secret", "access_token", "refresh_token"}
for _, fieldName := range fields {
if fieldValue, ok := oauthMap[fieldName].(string); ok && fieldValue != "" {
@@ -559,15 +561,15 @@ func (m *Manager) decryptInboxConfig(config json.RawMessage) (json.RawMessage, e
return config, nil
}
- var cfg map[string]interface{}
+ var cfg map[string]any
if err := json.Unmarshal(config, &cfg); err != nil {
return nil, fmt.Errorf("unmarshalling config: %w", err)
}
// Decrypt SMTP passwords
- if smtpSlice, ok := cfg["smtp"].([]interface{}); ok {
+ if smtpSlice, ok := cfg["smtp"].([]any); ok {
for i, smtpItem := range smtpSlice {
- if smtpMap, ok := smtpItem.(map[string]interface{}); ok {
+ if smtpMap, ok := smtpItem.(map[string]any); ok {
if password, ok := smtpMap["password"].(string); ok && password != "" {
decrypted, err := crypto.Decrypt(password, m.encryptionKey)
if err != nil {
@@ -580,9 +582,9 @@ func (m *Manager) decryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
// Decrypt IMAP passwords
- if imapSlice, ok := cfg["imap"].([]interface{}); ok {
+ if imapSlice, ok := cfg["imap"].([]any); ok {
for i, imapItem := range imapSlice {
- if imapMap, ok := imapItem.(map[string]interface{}); ok {
+ if imapMap, ok := imapItem.(map[string]any); ok {
if password, ok := imapMap["password"].(string); ok && password != "" {
decrypted, err := crypto.Decrypt(password, m.encryptionKey)
if err != nil {
@@ -595,7 +597,7 @@ func (m *Manager) decryptInboxConfig(config json.RawMessage) (json.RawMessage, e
}
// Decrypt OAuth fields if present
- if oauthMap, ok := cfg["oauth"].(map[string]interface{}); ok {
+ if oauthMap, ok := cfg["oauth"].(map[string]any); ok {
fields := []string{"client_secret", "access_token", "refresh_token"}
for _, fieldName := range fields {
if fieldValue, ok := oauthMap[fieldName].(string); ok && fieldValue != "" {
diff --git a/internal/inbox/models/models.go b/internal/inbox/models/models.go
index 7874dd6f..24efeb0b 100644
--- a/internal/inbox/models/models.go
+++ b/internal/inbox/models/models.go
@@ -31,11 +31,12 @@ type Inbox struct {
// Config holds the email inbox configuration with multiple SMTP servers and IMAP clients.
type Config struct {
- AuthType string `json:"auth_type"` // AuthTypePassword or AuthTypeOAuth2
- OAuth *OAuthConfig `json:"oauth"` // OAuth config when auth_type is "oauth2"
- SMTP []SMTPConfig `json:"smtp"`
- IMAP []IMAPConfig `json:"imap"`
- From string `json:"from"`
+ AuthType string `json:"auth_type"` // AuthTypePassword or AuthTypeOAuth2
+ OAuth *OAuthConfig `json:"oauth"` // OAuth config when auth_type is "oauth2"
+ SMTP []SMTPConfig `json:"smtp"`
+ IMAP []IMAPConfig `json:"imap"`
+ From string `json:"from"`
+ EnablePlusAddressing bool `json:"enable_plus_addressing"` // Enable plus-addressing in Reply-To header for conversation matching
}
// OAuthConfig holds OAuth 2.0 authentication details.
diff --git a/internal/migrations/v1.0.1.go b/internal/migrations/v1.0.1.go
new file mode 100644
index 00000000..c12ecc16
--- /dev/null
+++ b/internal/migrations/v1.0.1.go
@@ -0,0 +1,20 @@
+package migrations
+
+import (
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/koanf/v2"
+ "github.com/knadh/stuffbin"
+)
+
+// V1_0_1 updates the database schema to v1.0.1.
+func V1_0_1(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
+ // Backfill enable_plus_addressing to true for existing email inboxes
+ // that don't have this field in their config JSON.
+ _, err := db.Exec(`
+ UPDATE inboxes
+ SET config = jsonb_set(config, '{enable_plus_addressing}', 'true'::jsonb, true)
+ WHERE channel = 'email'
+ AND NOT (config ? 'enable_plus_addressing');
+ `)
+ return err
+}