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 +}