Merge pull request #234 from abhinavxd/update-plus-addressing

feat: add support for toggling plus addressing for email inbox.
This commit is contained in:
Abhinav Raut
2026-01-29 00:06:48 +05:30
committed by GitHub
12 changed files with 90 additions and 41 deletions
+9 -8
View File
@@ -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)
+1
View File
@@ -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
@@ -54,11 +54,25 @@
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
<p class="!mt-2 text-muted-foreground text-sm">
<p class="!mt-2 text-muted-foreground text-xs">
{{ $t('admin.inbox.csatSurveys.description_2') }}
</p>
</FormField>
<FormField v-if="showFormFields" v-slot="{ componentField, handleChange }" name="enable_plus_addressing">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.enablePlusAddressing') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.enablePlusAddressing.description') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-if="setupMethod" v-slot="{ componentField }" name="auth_type">
<FormItem>
<FormControl>
@@ -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',
@@ -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(),
@@ -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, {
@@ -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]
}
+2
View File
@@ -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",
+8 -5
View File
@@ -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,
}
}
+2 -2
View File
@@ -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)
+22 -20
View File
@@ -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, &currentCfg); 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 != "" {
+6 -5
View File
@@ -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.
+20
View File
@@ -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
}