mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			12 Commits
		
	
	
		
			v0.7.0-alp
			...
			v0.7.3-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					958f5e38c0 | ||
| 
						 | 
					550a3fa801 | ||
| 
						 | 
					6bbfbe8cf6 | ||
| 
						 | 
					f9ed326d72 | ||
| 
						 | 
					e0dc0285a4 | ||
| 
						 | 
					b971619ea6 | ||
| 
						 | 
					69accaebef | ||
| 
						 | 
					27de73536e | ||
| 
						 | 
					df108a3363 | ||
| 
						 | 
					266c3dab72 | ||
| 
						 | 
					bf2c1fff6f | ||
| 
						 | 
					2930af0c4f | 
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
---
 | 
			
		||||
name: Confirmed Bug Report
 | 
			
		||||
about: Report a confirmed bug in Libredesk
 | 
			
		||||
title: "[Bug] <brief summary>"
 | 
			
		||||
labels: bug
 | 
			
		||||
assignees: ""
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Version:**
 | 
			
		||||
- libredesk: [eg: v0.7.0]
 | 
			
		||||
 | 
			
		||||
**Description of the bug and steps to reproduce:**
 | 
			
		||||
A clear and concise description of what the bug is.
 | 
			
		||||
 | 
			
		||||
**Logs / Screenshots:**
 | 
			
		||||
Attach any relevant logs or screenshots to help diagnose the issue.
 | 
			
		||||
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
---
 | 
			
		||||
name: Possible Bug Report
 | 
			
		||||
about: Something in Libredesk might be broken but needs confirmation
 | 
			
		||||
title: "[Possible Bug] <brief summary>"
 | 
			
		||||
labels: bug, needs-investigation
 | 
			
		||||
assignees: ""
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Version:**
 | 
			
		||||
 - libredesk: [eg: v0.7.0]
 | 
			
		||||
 
 | 
			
		||||
**Description of the bug and steps to reproduce:**
 | 
			
		||||
A clear and concise description of what the bug is.
 | 
			
		||||
 | 
			
		||||
**Logs / Screenshots:**
 | 
			
		||||
Attach any relevant logs or screenshots to help diagnose the issue.
 | 
			
		||||
@@ -3,7 +3,6 @@ package main
 | 
			
		||||
import (
 | 
			
		||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
			
		||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
			
		||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
			
		||||
	realip "github.com/ferluci/fast-realip"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
 | 
			
		||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set user availability status to online.
 | 
			
		||||
	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	user.AvailabilityStatus = umodels.Online
 | 
			
		||||
 | 
			
		||||
	if err := app.auth.SaveSession(amodels.User{
 | 
			
		||||
		ID:        user.ID,
 | 
			
		||||
		Email:     user.Email.String,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								docs/docs/api-getting-started.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								docs/docs/api-getting-started.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
# API getting started
 | 
			
		||||
 | 
			
		||||
You can access the Libredesk API to interact with your instance programmatically.
 | 
			
		||||
 | 
			
		||||
## Generating API keys
 | 
			
		||||
 | 
			
		||||
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
 | 
			
		||||
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
 | 
			
		||||
3. **Save the credentials**: Keep both the API Key and API Secret secure
 | 
			
		||||
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
 | 
			
		||||
 | 
			
		||||
## Using the API
 | 
			
		||||
 | 
			
		||||
LibreDesk supports two authentication schemes:
 | 
			
		||||
 | 
			
		||||
### Basic authentication
 | 
			
		||||
```bash
 | 
			
		||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
 | 
			
		||||
  -H "Authorization: Basic <base64_encoded_key:secret>"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Token authentication
 | 
			
		||||
```bash
 | 
			
		||||
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
 | 
			
		||||
  -H "Authorization: token your_api_key:your_api_secret"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## API Documentation
 | 
			
		||||
 | 
			
		||||
Complete API documentation with available endpoints and examples coming soon.
 | 
			
		||||
@@ -32,6 +32,7 @@ nav:
 | 
			
		||||
      - Email Templates: templating.md
 | 
			
		||||
      - SSO Setup: sso.md
 | 
			
		||||
      - Webhooks: webhooks.md
 | 
			
		||||
      - API Getting Started: api-getting-started.md
 | 
			
		||||
  - Contributions:
 | 
			
		||||
      - Developer Setup: developer-setup.md
 | 
			
		||||
      - Translate Libredesk: translations.md
 | 
			
		||||
 
 | 
			
		||||
@@ -1,33 +1,32 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="max-w-5xl mx-auto p-6 min-h-screen">
 | 
			
		||||
    <div class="space-y-8">
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="(items, type) in results"
 | 
			
		||||
        :key="type"
 | 
			
		||||
        class="bg-card rounded shadow overflow-hidden"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- Header for each section -->
 | 
			
		||||
        <h2
 | 
			
		||||
          class="bg-primary dark:bg-primary text-lg font-bold text-white dark:text-primary-foreground py-2 px-6 capitalize"
 | 
			
		||||
        >
 | 
			
		||||
          {{ type }}
 | 
			
		||||
        </h2>
 | 
			
		||||
    <Tabs :default-value="defaultTab" v-model="activeTab">
 | 
			
		||||
      <TabsList class="grid w-full mb-6" :class="tabsGridClass">
 | 
			
		||||
        <TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize">
 | 
			
		||||
          {{ type }} ({{ items.length }})
 | 
			
		||||
        </TabsTrigger>
 | 
			
		||||
      </TabsList>
 | 
			
		||||
 | 
			
		||||
      <TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0">
 | 
			
		||||
        <div class="bg-background rounded border overflow-hidden">
 | 
			
		||||
          <!-- No results message -->
 | 
			
		||||
        <div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground">
 | 
			
		||||
          <div v-if="items.length === 0" class="p-8 text-center text-muted-foreground">
 | 
			
		||||
            <div class="text-lg font-medium mb-2">
 | 
			
		||||
              {{
 | 
			
		||||
                $t('globals.messages.noResults', {
 | 
			
		||||
                  name: type
 | 
			
		||||
                })
 | 
			
		||||
              }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Results list -->
 | 
			
		||||
        <div class="divide-y divide-gray-200 dark:divide-border">
 | 
			
		||||
          <div v-else class="divide-y divide-border">
 | 
			
		||||
            <div
 | 
			
		||||
              v-for="item in items"
 | 
			
		||||
              :key="item.id || item.uuid"
 | 
			
		||||
            class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group"
 | 
			
		||||
              class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group"
 | 
			
		||||
            >
 | 
			
		||||
              <router-link
 | 
			
		||||
                :to="{
 | 
			
		||||
@@ -43,7 +42,7 @@
 | 
			
		||||
                  <div class="flex-grow">
 | 
			
		||||
                    <!-- Reference number -->
 | 
			
		||||
                    <div
 | 
			
		||||
                    class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300"
 | 
			
		||||
                      class="text-sm font-semibold mb-2 text-muted-foreground group-hover:text-primary transition duration-200"
 | 
			
		||||
                    >
 | 
			
		||||
                      #{{
 | 
			
		||||
                        type === 'conversations'
 | 
			
		||||
@@ -54,15 +53,18 @@
 | 
			
		||||
 | 
			
		||||
                    <!-- Content -->
 | 
			
		||||
                    <div
 | 
			
		||||
                    class="text-gray-900 dark:text-card-foreground font-medium mb-2 text-lg group-hover:text-gray-950 dark:group-hover:text-foreground transition duration-300"
 | 
			
		||||
                      class="text-foreground font-medium mb-2 text-lg group-hover:text-primary transition duration-200"
 | 
			
		||||
                    >
 | 
			
		||||
                      {{
 | 
			
		||||
                      truncateText(type === 'conversations' ? item.subject : item.text_content, 100)
 | 
			
		||||
                        truncateText(
 | 
			
		||||
                          type === 'conversations' ? item.subject : item.text_content,
 | 
			
		||||
                          100
 | 
			
		||||
                        )
 | 
			
		||||
                      }}
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <!-- Timestamp -->
 | 
			
		||||
                  <div class="text-sm text-gray-500 dark:text-muted-foreground flex items-center">
 | 
			
		||||
                    <div class="text-sm text-muted-foreground flex items-center">
 | 
			
		||||
                      <ClockIcon class="h-4 w-4 mr-1" />
 | 
			
		||||
                      {{
 | 
			
		||||
                        formatDate(
 | 
			
		||||
@@ -74,10 +76,10 @@
 | 
			
		||||
 | 
			
		||||
                  <!-- Right arrow icon -->
 | 
			
		||||
                  <div
 | 
			
		||||
                  class="bg-gray-200 dark:bg-secondary rounded-full p-2 group-hover:bg-primary dark:group-hover:bg-primary transition duration-300"
 | 
			
		||||
                    class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-200"
 | 
			
		||||
                  >
 | 
			
		||||
                    <ChevronRightIcon
 | 
			
		||||
                    class="h-5 w-5 text-gray-700 dark:text-secondary-foreground group-hover:text-white dark:group-hover:text-primary-foreground"
 | 
			
		||||
                      class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
 | 
			
		||||
                      aria-hidden="true"
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
@@ -86,20 +88,52 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
      </TabsContent>
 | 
			
		||||
    </Tabs>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, ref, watch } from 'vue'
 | 
			
		||||
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
 | 
			
		||||
import { format, parseISO } from 'date-fns'
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  results: {
 | 
			
		||||
    type: Object,
 | 
			
		||||
    required: true
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// Get the first available tab as default
 | 
			
		||||
const defaultTab = computed(() => {
 | 
			
		||||
  const types = Object.keys(props.results)
 | 
			
		||||
  return types.length > 0 ? types[0] : ''
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const activeTab = ref('')
 | 
			
		||||
 | 
			
		||||
// Watch for changes in results and set the first tab as active
 | 
			
		||||
watch(
 | 
			
		||||
  () => props.results,
 | 
			
		||||
  (newResults) => {
 | 
			
		||||
    const types = Object.keys(newResults)
 | 
			
		||||
    if (types.length > 0 && !activeTab.value) {
 | 
			
		||||
      activeTab.value = types[0]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Dynamic grid class based on number of tabs
 | 
			
		||||
const tabsGridClass = computed(() => {
 | 
			
		||||
  const tabCount = Object.keys(props.results).length
 | 
			
		||||
  if (tabCount <= 2) return 'grid-cols-2'
 | 
			
		||||
  if (tabCount <= 3) return 'grid-cols-3'
 | 
			
		||||
  if (tabCount <= 4) return 'grid-cols-4'
 | 
			
		||||
  return 'grid-cols-5'
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const formatDate = (dateString) => {
 | 
			
		||||
  const date = parseISO(dateString)
 | 
			
		||||
  return format(date, 'MMM d, yyyy HH:mm')
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							@@ -38,7 +38,7 @@ require (
 | 
			
		||||
	github.com/zerodha/simplesessions/v3 v3.0.0
 | 
			
		||||
	golang.org/x/crypto v0.38.0
 | 
			
		||||
	golang.org/x/mod v0.17.0
 | 
			
		||||
	golang.org/x/oauth2 v0.21.0
 | 
			
		||||
	golang.org/x/oauth2 v0.27.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.sum
									
									
									
									
									
								
							@@ -140,8 +140,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
 | 
			
		||||
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.0 h1:It6/glyqRTRooRzXcYOuqpKwjGg3lsXgNmeGgxpBtjA=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
 | 
			
		||||
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
 | 
			
		||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 | 
			
		||||
@@ -211,8 +209,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 | 
			
		||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 | 
			
		||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
 | 
			
		||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
 | 
			
		||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
 | 
			
		||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 | 
			
		||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
 | 
			
		||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
 | 
			
		||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
 
 | 
			
		||||
@@ -568,6 +568,7 @@
 | 
			
		||||
  "search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
 | 
			
		||||
  "search.minQueryLength": " Please enter at least {length} characters to search.",
 | 
			
		||||
  "search.searchBy": "Search by reference number, contact email address or messages in conversations.",
 | 
			
		||||
  "search.adjustSearchTerms": "Try adjusting your search terms or filters.",
 | 
			
		||||
  "sla.overdueBy": "Overdue by",
 | 
			
		||||
  "sla.met": "SLA met",
 | 
			
		||||
  "view.form.description": "Create and save custom filter views for quick access to your conversations.",
 | 
			
		||||
 
 | 
			
		||||
@@ -438,6 +438,7 @@ SELECT
 | 
			
		||||
    m.sender_type,
 | 
			
		||||
    m.sender_id,
 | 
			
		||||
    m.meta,
 | 
			
		||||
    c.uuid as conversation_uuid,
 | 
			
		||||
    COALESCE(
 | 
			
		||||
        json_agg(
 | 
			
		||||
            json_build_object(
 | 
			
		||||
@@ -452,10 +453,11 @@ SELECT
 | 
			
		||||
        '[]'::json
 | 
			
		||||
    ) AS attachments
 | 
			
		||||
FROM conversation_messages m
 | 
			
		||||
INNER JOIN conversations c ON c.id = m.conversation_id
 | 
			
		||||
LEFT JOIN media ON media.model_type = 'messages' AND media.model_id = m.id
 | 
			
		||||
WHERE m.uuid = $1
 | 
			
		||||
GROUP BY 
 | 
			
		||||
    m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type
 | 
			
		||||
    m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type, c.uuid
 | 
			
		||||
ORDER BY m.created_at;
 | 
			
		||||
 | 
			
		||||
-- name: get-messages
 | 
			
		||||
 
 | 
			
		||||
@@ -140,6 +140,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
					headerAutoSubmitted,
 | 
			
		||||
					headerAutoreply,
 | 
			
		||||
					headerLibredeskLoopPrevention,
 | 
			
		||||
					headerMessageID,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
@@ -151,6 +152,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
		seqNum             uint32
 | 
			
		||||
		autoReply          bool
 | 
			
		||||
		isLoop             bool
 | 
			
		||||
		extractedMessageID string
 | 
			
		||||
	}
 | 
			
		||||
	var messages []msgData
 | 
			
		||||
 | 
			
		||||
@@ -185,6 +187,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
			env                *imap.Envelope
 | 
			
		||||
			autoReply          bool
 | 
			
		||||
			isLoop             bool
 | 
			
		||||
			extractedMessageID string
 | 
			
		||||
		)
 | 
			
		||||
		// Process all fetch items for the current message.
 | 
			
		||||
		for {
 | 
			
		||||
@@ -215,6 +218,9 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
				if isLoopMessage(envelope, inboxEmail) {
 | 
			
		||||
					isLoop = true
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Extract Message-Id from raw headers as fallback for problematic Message IDs
 | 
			
		||||
				extractedMessageID = extractMessageIDFromHeaders(envelope)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Envelope.
 | 
			
		||||
@@ -223,12 +229,13 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Skip if we couldn't get headers or envelope.
 | 
			
		||||
		// Skip if we couldn't get the envelope.
 | 
			
		||||
		if env == nil {
 | 
			
		||||
			e.lo.Warn("skipping message without envelope", "seq_num", msg.SeqNum, "inbox_id", e.Identifier())
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop})
 | 
			
		||||
		messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop, extractedMessageID: extractedMessageID})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Now process each collected message.
 | 
			
		||||
@@ -253,7 +260,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Process the envelope.
 | 
			
		||||
		if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID); err != nil && err != context.Canceled {
 | 
			
		||||
		if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID, msgData.extractedMessageID); err != nil && err != context.Canceled {
 | 
			
		||||
			e.lo.Error("error processing envelope", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -262,17 +269,32 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// processEnvelope processes a single email envelope.
 | 
			
		||||
func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int) error {
 | 
			
		||||
func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int, extractedMessageID string) error {
 | 
			
		||||
	if len(env.From) == 0 {
 | 
			
		||||
		e.lo.Warn("no sender received for email", "message_id", env.MessageID)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	var fromAddress = strings.ToLower(env.From[0].Addr())
 | 
			
		||||
 | 
			
		||||
	// Determine final Message ID - prefer IMAP-parsed, fallback to raw header extraction
 | 
			
		||||
	messageID := env.MessageID
 | 
			
		||||
	if messageID == "" {
 | 
			
		||||
		messageID = extractedMessageID
 | 
			
		||||
		if messageID != "" {
 | 
			
		||||
			e.lo.Debug("using raw header Message-ID as fallback for malformed ID", "message_id", messageID, "subject", env.Subject, "from", fromAddress)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Drop message if we still don't have a valid Message ID
 | 
			
		||||
	if messageID == "" {
 | 
			
		||||
		e.lo.Error("dropping message: no valid Message-ID found in IMAP parsing or raw headers", "subject", env.Subject, "from", fromAddress)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if the message already exists in the database; if it does, ignore it.
 | 
			
		||||
	exists, err := e.messageStore.MessageExists(env.MessageID)
 | 
			
		||||
	exists, err := e.messageStore.MessageExists(messageID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		e.lo.Error("error checking if message exists", "message_id", env.MessageID)
 | 
			
		||||
		e.lo.Error("error checking if message exists", "message_id", messageID)
 | 
			
		||||
		return fmt.Errorf("checking if message exists in DB: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if exists {
 | 
			
		||||
@@ -291,7 +313,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	e.lo.Debug("processing new incoming message", "message_id", env.MessageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID)
 | 
			
		||||
	e.lo.Debug("processing new incoming message", "message_id", messageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID)
 | 
			
		||||
 | 
			
		||||
	// Make contact.
 | 
			
		||||
	firstName, lastName := getContactName(env.From[0])
 | 
			
		||||
@@ -350,7 +372,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
 | 
			
		||||
			InboxID:    inboxID,
 | 
			
		||||
			Status:     models.MessageStatusReceived,
 | 
			
		||||
			Subject:    env.Subject,
 | 
			
		||||
			SourceID:   null.StringFrom(env.MessageID),
 | 
			
		||||
			SourceID:   null.StringFrom(messageID),
 | 
			
		||||
			Meta:       meta,
 | 
			
		||||
		},
 | 
			
		||||
		Contact: contact,
 | 
			
		||||
@@ -385,7 +407,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if fullItem, ok := fullFetchItem.(imapclient.FetchItemDataBodySection); ok {
 | 
			
		||||
			e.lo.Debug("fetching full message body", "message_id", env.MessageID)
 | 
			
		||||
			e.lo.Debug("fetching full message body", "message_id", messageID)
 | 
			
		||||
			return e.processFullMessage(fullItem, incomingMsg)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -534,3 +556,13 @@ func extractAllHTMLParts(part *enmime.Part) []string {
 | 
			
		||||
 | 
			
		||||
	return htmlParts
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// extractMessageIDFromHeaders extracts and cleans the Message-ID from email headers.
 | 
			
		||||
// This function handles problematic Message IDs by extracting them from raw headers
 | 
			
		||||
// and cleaning them of angle brackets and whitespace.
 | 
			
		||||
func extractMessageIDFromHeaders(envelope *enmime.Envelope) string {
 | 
			
		||||
	if rawMessageID := envelope.GetHeader(headerMessageID); rawMessageID != "" {
 | 
			
		||||
		return strings.TrimSpace(strings.Trim(rawMessageID, "<>"))
 | 
			
		||||
	}
 | 
			
		||||
	return ""
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										123
									
								
								internal/inbox/channel/email/imap_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								internal/inbox/channel/email/imap_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
package email
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/emersion/go-message/mail"
 | 
			
		||||
	"github.com/jhillyerd/enmime"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// TestGoIMAPMessageIDParsing shows how go-imap fails to parse malformed Message-IDs
 | 
			
		||||
// and demonstrates the fallback solution.
 | 
			
		||||
// go-imap uses mail.Header.MessageID() which strictly follows RFC 5322 and returns
 | 
			
		||||
// empty strings for Message-IDs with multiple @ symbols.
 | 
			
		||||
//
 | 
			
		||||
// This caused emails to be dropped since we require Message-IDs for deduplication.
 | 
			
		||||
// References:
 | 
			
		||||
// - https://community.mailcow.email/d/701-multiple-at-in-message-id/5
 | 
			
		||||
// - https://github.com/emersion/go-message/issues/154#issuecomment-1425634946
 | 
			
		||||
func TestGoIMAPMessageIDParsing(t *testing.T) {
 | 
			
		||||
	testCases := []struct {
 | 
			
		||||
		input            string
 | 
			
		||||
		expectedIMAP     string
 | 
			
		||||
		expectedFallback string
 | 
			
		||||
		name             string
 | 
			
		||||
	}{
 | 
			
		||||
		{"<normal@example.com>", "normal@example.com", "normal@example.com", "normal message ID"},
 | 
			
		||||
		{"<malformed@@example.com>", "", "malformed@@example.com", "double @ - IMAP fails, fallback works"},
 | 
			
		||||
		{"<001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com>", "", "001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com", "mailcow-style - IMAP fails, fallback works"},
 | 
			
		||||
		{"<test@@@domain.com>", "", "test@@@domain.com", "triple @ - IMAP fails, fallback works"},
 | 
			
		||||
		{"  <abc123@example.com>  ", "abc123@example.com", "abc123@example.com", "with whitespace - both handle correctly"},
 | 
			
		||||
		{"abc123@example.com", "", "abc123@example.com", "no angle brackets - IMAP fails, fallback works"},
 | 
			
		||||
		{"", "", "", "empty input"},
 | 
			
		||||
		{"<>", "", "", "empty brackets"},
 | 
			
		||||
		{"<CAFnQjQFhY8z@mail.example.com@gateway.company.com>", "", "CAFnQjQFhY8z@mail.example.com@gateway.company.com", "gateway-style - IMAP fails, fallback works"},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tc := range testCases {
 | 
			
		||||
		t.Run(tc.name, func(t *testing.T) {
 | 
			
		||||
			// Test go-imap parsing behavior
 | 
			
		||||
			var h mail.Header
 | 
			
		||||
			h.Set("Message-Id", tc.input)
 | 
			
		||||
			imapResult, _ := h.MessageID()
 | 
			
		||||
 | 
			
		||||
			if imapResult != tc.expectedIMAP {
 | 
			
		||||
				t.Errorf("IMAP parsing of %q: expected %q, got %q", tc.input, tc.expectedIMAP, imapResult)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Test fallback solution
 | 
			
		||||
			if tc.input != "" {
 | 
			
		||||
				rawEmail := "From: test@example.com\nMessage-ID: " + tc.input + "\n\nBody"
 | 
			
		||||
				envelope, err := enmime.ReadEnvelope(strings.NewReader(rawEmail))
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					t.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				fallbackResult := extractMessageIDFromHeaders(envelope)
 | 
			
		||||
				if fallbackResult != tc.expectedFallback {
 | 
			
		||||
					t.Errorf("Fallback extraction of %q: expected %q, got %q", tc.input, tc.expectedFallback, fallbackResult)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Critical check: ensure fallback works when IMAP fails
 | 
			
		||||
				if imapResult == "" && tc.expectedFallback != "" && fallbackResult == "" {
 | 
			
		||||
					t.Errorf("CRITICAL: Both IMAP and fallback failed for %q - would drop email!", tc.input)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// TestEdgeCasesMessageID tests additional edge cases for Message-ID extraction.
 | 
			
		||||
func TestEdgeCasesMessageID(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name     string
 | 
			
		||||
		email    string
 | 
			
		||||
		expected string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name: "no Message-ID header",
 | 
			
		||||
			email: `From: test@example.com
 | 
			
		||||
To: inbox@test.com
 | 
			
		||||
Subject: Test
 | 
			
		||||
 | 
			
		||||
Body`,
 | 
			
		||||
			expected: "",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name: "malformed header syntax",
 | 
			
		||||
			email: `From: test@example.com
 | 
			
		||||
Message-ID: malformed-no-brackets@@domain.com
 | 
			
		||||
To: inbox@test.com
 | 
			
		||||
 | 
			
		||||
Body`,
 | 
			
		||||
			expected: "malformed-no-brackets@@domain.com",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name: "multiple Message-ID headers (first wins)",
 | 
			
		||||
			email: `From: test@example.com
 | 
			
		||||
Message-ID: <first@example.com>
 | 
			
		||||
Message-ID: <second@@example.com>
 | 
			
		||||
To: inbox@test.com
 | 
			
		||||
 | 
			
		||||
Body`,
 | 
			
		||||
			expected: "first@example.com",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
			envelope, err := enmime.ReadEnvelope(strings.NewReader(tt.email))
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			result := extractMessageIDFromHeaders(envelope)
 | 
			
		||||
			if result != tt.expected {
 | 
			
		||||
				t.Errorf("Expected %q, got %q", tt.expected, result)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user