package handlers import ( "bytes" "context" "database/sql" "encoding/base64" "encoding/json" "fmt" "io/ioutil" "net/http" "os" "strconv" "strings" "time" "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db" "github.com/gorilla/mux" "github.com/jhillyerd/enmime" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" ) type EmailHandler struct { queries *db.Queries db *sql.DB gmailService *gmail.Service } type EmailResponse struct { ID int32 `json:"id"` Subject string `json:"subject"` UserID int32 `json:"user_id"` Created time.Time `json:"created"` GmailMessageID *string `json:"gmail_message_id,omitempty"` AttachmentCount int32 `json:"attachment_count"` IsDownloaded *bool `json:"is_downloaded,omitempty"` } type EmailDetailResponse struct { ID int32 `json:"id"` Subject string `json:"subject"` UserID int32 `json:"user_id"` Created time.Time `json:"created"` GmailMessageID *string `json:"gmail_message_id,omitempty"` GmailThreadID *string `json:"gmail_thread_id,omitempty"` RawHeaders *string `json:"raw_headers,omitempty"` IsDownloaded *bool `json:"is_downloaded,omitempty"` Enquiries []int32 `json:"enquiries,omitempty"` Invoices []int32 `json:"invoices,omitempty"` PurchaseOrders []int32 `json:"purchase_orders,omitempty"` Jobs []int32 `json:"jobs,omitempty"` } type EmailAttachmentResponse struct { ID int32 `json:"id"` Name string `json:"name"` Type string `json:"type"` Size int32 `json:"size"` Filename string `json:"filename"` IsMessageBody bool `json:"is_message_body"` GmailAttachmentID *string `json:"gmail_attachment_id,omitempty"` Created time.Time `json:"created"` } func NewEmailHandler(queries *db.Queries, database *sql.DB) *EmailHandler { // Try to initialize Gmail service gmailService, err := getGmailService("credentials.json", "token.json") if err != nil { // Log the error but continue without Gmail service fmt.Printf("Warning: Gmail service not available: %v\n", err) } return &EmailHandler{ queries: queries, db: database, gmailService: gmailService, } } // List emails with pagination and filtering func (h *EmailHandler) List(w http.ResponseWriter, r *http.Request) { // Parse query parameters limitStr := r.URL.Query().Get("limit") offsetStr := r.URL.Query().Get("offset") search := r.URL.Query().Get("search") userID := r.URL.Query().Get("user_id") // Set defaults limit := 50 offset := 0 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } if offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { offset = o } } // Build query query := ` SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded FROM emails e` var args []interface{} var conditions []string if search != "" { conditions = append(conditions, "e.subject LIKE ?") args = append(args, "%"+search+"%") } if userID != "" { conditions = append(conditions, "e.user_id = ?") args = append(args, userID) } if len(conditions) > 0 { query += " WHERE " + joinConditions(conditions, " AND ") } query += " ORDER BY e.id DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) rows, err := h.db.Query(query, args...) if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } defer rows.Close() var emails []EmailResponse for rows.Next() { var email EmailResponse var gmailMessageID sql.NullString var isDownloaded sql.NullBool err := rows.Scan( &email.ID, &email.Subject, &email.UserID, &email.Created, &gmailMessageID, &email.AttachmentCount, &isDownloaded, ) if err != nil { continue } if gmailMessageID.Valid { email.GmailMessageID = &gmailMessageID.String } if isDownloaded.Valid { email.IsDownloaded = &isDownloaded.Bool } emails = append(emails, email) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(emails) } // Get a specific email with details func (h *EmailHandler) Get(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) emailID, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "Invalid email ID", http.StatusBadRequest) return } // Get email details query := ` SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.gmail_thread_id, e.raw_headers, e.is_downloaded FROM emails e WHERE e.id = ?` var email EmailDetailResponse var gmailMessageID, gmailThreadID, rawHeaders sql.NullString var isDownloaded sql.NullBool err = h.db.QueryRow(query, emailID).Scan( &email.ID, &email.Subject, &email.UserID, &email.Created, &gmailMessageID, &gmailThreadID, &rawHeaders, &isDownloaded, ) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Email not found", http.StatusNotFound) } else { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) } return } if gmailMessageID.Valid { email.GmailMessageID = &gmailMessageID.String } if gmailThreadID.Valid { email.GmailThreadID = &gmailThreadID.String } if rawHeaders.Valid { email.RawHeaders = &rawHeaders.String } if isDownloaded.Valid { email.IsDownloaded = &isDownloaded.Bool } // Get associated enquiries enquiryRows, err := h.db.Query("SELECT enquiry_id FROM emails_enquiries WHERE email_id = ?", emailID) if err == nil { defer enquiryRows.Close() for enquiryRows.Next() { var enquiryID int32 if enquiryRows.Scan(&enquiryID) == nil { email.Enquiries = append(email.Enquiries, enquiryID) } } } // Get associated invoices invoiceRows, err := h.db.Query("SELECT invoice_id FROM emails_invoices WHERE email_id = ?", emailID) if err == nil { defer invoiceRows.Close() for invoiceRows.Next() { var invoiceID int32 if invoiceRows.Scan(&invoiceID) == nil { email.Invoices = append(email.Invoices, invoiceID) } } } // Get associated purchase orders poRows, err := h.db.Query("SELECT purchase_order_id FROM emails_purchase_orders WHERE email_id = ?", emailID) if err == nil { defer poRows.Close() for poRows.Next() { var poID int32 if poRows.Scan(&poID) == nil { email.PurchaseOrders = append(email.PurchaseOrders, poID) } } } // Get associated jobs jobRows, err := h.db.Query("SELECT job_id FROM emails_jobs WHERE email_id = ?", emailID) if err == nil { defer jobRows.Close() for jobRows.Next() { var jobID int32 if jobRows.Scan(&jobID) == nil { email.Jobs = append(email.Jobs, jobID) } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(email) } // List attachments for an email func (h *EmailHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) emailID, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "Invalid email ID", http.StatusBadRequest) return } // First check if attachments are already in database query := ` SELECT id, name, type, size, filename, is_message_body, gmail_attachment_id, created FROM email_attachments WHERE email_id = ? ORDER BY is_message_body DESC, created ASC` rows, err := h.db.Query(query, emailID) if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } defer rows.Close() var attachments []EmailAttachmentResponse hasStoredAttachments := false for rows.Next() { hasStoredAttachments = true var attachment EmailAttachmentResponse var gmailAttachmentID sql.NullString err := rows.Scan( &attachment.ID, &attachment.Name, &attachment.Type, &attachment.Size, &attachment.Filename, &attachment.IsMessageBody, &gmailAttachmentID, &attachment.Created, ) if err != nil { continue } if gmailAttachmentID.Valid { attachment.GmailAttachmentID = &gmailAttachmentID.String } attachments = append(attachments, attachment) } // If no stored attachments and this is a Gmail email, try to fetch from Gmail if !hasStoredAttachments && h.gmailService != nil { // Get Gmail message ID var gmailMessageID sql.NullString err := h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID) if err == nil && gmailMessageID.Valid { // Fetch message metadata from Gmail message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String). Format("FULL").Do() if err == nil && message.Payload != nil { // Extract attachment info from Gmail message attachmentIndex := int32(1) h.extractGmailAttachments(message.Payload, &attachments, &attachmentIndex) } } } // Check if this is an HTMX request if r.Header.Get("HX-Request") == "true" { // Return HTML for HTMX w.Header().Set("Content-Type", "text/html; charset=utf-8") if len(attachments) == 0 { // No attachments found html := `

No attachments found for this email.

` w.Write([]byte(html)) return } // Build HTML table for attachments var htmlBuilder strings.Builder htmlBuilder.WriteString(`

Attachments

`) for _, att := range attachments { icon := `` if att.IsMessageBody { icon = `` } downloadURL := fmt.Sprintf("/api/v1/emails/%d/attachments/%d", emailID, att.ID) if att.GmailAttachmentID != nil { downloadURL = fmt.Sprintf("/api/v1/emails/%d/attachments/%d/stream", emailID, att.ID) } htmlBuilder.WriteString(fmt.Sprintf(` `, icon, att.Name, att.Type, att.Size, downloadURL)) } htmlBuilder.WriteString(`
Name Type Size Actions
%s %s %s %d bytes Download
`) w.Write([]byte(htmlBuilder.String())) return } // Return JSON for API requests w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(attachments) } // Helper function to extract attachment info from Gmail message parts func (h *EmailHandler) extractGmailAttachments(part *gmail.MessagePart, attachments *[]EmailAttachmentResponse, index *int32) { // Check if this part is an attachment // Some attachments may not have filenames or may be inline if part.Body != nil && part.Body.AttachmentId != "" { filename := part.Filename if filename == "" { // Try to generate a filename from content type switch part.MimeType { case "application/pdf": filename = "attachment.pdf" case "image/png": filename = "image.png" case "image/jpeg": filename = "image.jpg" case "text/plain": filename = "text.txt" default: filename = "attachment" } } attachment := EmailAttachmentResponse{ ID: *index, Name: filename, Type: part.MimeType, Size: int32(part.Body.Size), Filename: filename, IsMessageBody: false, GmailAttachmentID: &part.Body.AttachmentId, Created: time.Now(), // Use current time as placeholder } *attachments = append(*attachments, attachment) *index++ } // Process sub-parts for _, subPart := range part.Parts { h.extractGmailAttachments(subPart, attachments, index) } } // Search emails func (h *EmailHandler) Search(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { http.Error(w, "Search query is required", http.StatusBadRequest) return } // Parse optional parameters limitStr := r.URL.Query().Get("limit") limit := 20 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } // Search in subjects and headers sqlQuery := ` SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded FROM emails e WHERE e.subject LIKE ? OR e.raw_headers LIKE ? ORDER BY e.id DESC LIMIT ?` searchTerm := "%" + query + "%" rows, err := h.db.Query(sqlQuery, searchTerm, searchTerm, limit) if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } defer rows.Close() var emails []EmailResponse for rows.Next() { var email EmailResponse var gmailMessageID sql.NullString var isDownloaded sql.NullBool err := rows.Scan( &email.ID, &email.Subject, &email.UserID, &email.Created, &gmailMessageID, &email.AttachmentCount, &isDownloaded, ) if err != nil { continue } if gmailMessageID.Valid { email.GmailMessageID = &gmailMessageID.String } if isDownloaded.Valid { email.IsDownloaded = &isDownloaded.Bool } emails = append(emails, email) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(emails) } // Stream email content from Gmail func (h *EmailHandler) StreamContent(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) emailID, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "Invalid email ID", http.StatusBadRequest) return } // Get email details to check if it's a Gmail email query := ` SELECT e.gmail_message_id, e.subject, e.created, e.user_id FROM emails e WHERE e.id = ?` var gmailMessageID sql.NullString var subject string var created time.Time var userID int32 err = h.db.QueryRow(query, emailID).Scan(&gmailMessageID, &subject, &created, &userID) if err != nil { if err == sql.ErrNoRows { http.Error(w, "Email not found", http.StatusNotFound) } else { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) } return } if !gmailMessageID.Valid { w.Header().Set("Content-Type", "text/html; charset=utf-8") html := `
Local Email
This email is not from Gmail and does not have stored content available for display.
` w.Write([]byte(html)) return } // Check for stored message body content in attachments attachmentQuery := ` SELECT id, name, type, size FROM email_attachments WHERE email_id = ? AND is_message_body = 1 ORDER BY created ASC` attachmentRows, err := h.db.Query(attachmentQuery, emailID) if err == nil { defer attachmentRows.Close() if attachmentRows.Next() { var attachmentID int32 var name, attachmentType string var size int32 if attachmentRows.Scan(&attachmentID, &name, &attachmentType, &size) == nil { // Found stored message body - would normally read the content from file storage w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Stored Email Content
Message body is stored locally as attachment: %s (%s, %d bytes)

Content would be loaded from local storage here.

Attachment ID: %d

`, name, attachmentType, size, attachmentID) w.Write([]byte(html)) return } } } // Try to fetch from Gmail if service is available if h.gmailService != nil { // Fetch from Gmail message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String). Format("RAW").Do() if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Gmail API Error
Failed to fetch email from Gmail: %v
`, err) w.Write([]byte(html)) return } // Decode message rawEmail, err := base64.URLEncoding.DecodeString(message.Raw) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Decode Error
Failed to decode email: %v
`, err) w.Write([]byte(html)) return } // Parse with enmime env, err := enmime.ReadEnvelope(bytes.NewReader(rawEmail)) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Parse Error
Failed to parse email: %v
`, err) w.Write([]byte(html)) return } // Stream HTML or Text directly to client if env.HTML != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(env.HTML)) } else if env.Text != "" { // Convert plain text to HTML for better display w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Plain Text Email
This email contains only plain text content.
%s
`, env.Text) w.Write([]byte(html)) } else { w.Header().Set("Content-Type", "text/html; charset=utf-8") html := `
No Content
No HTML or text content found in this email.
` w.Write([]byte(html)) } return } // No Gmail service available - show error w.Header().Set("Content-Type", "text/html; charset=utf-8") html := fmt.Sprintf(`
Gmail Service Unavailable
Subject: %s
Date: %s
Gmail Message ID: %s

Integration Status

Gmail service is not available. To enable email content display:

  1. Ensure credentials.json and token.json files are present
  2. Configure Gmail API OAuth2 authentication
  3. Restart the application

Gmail Message ID: %s

`, subject, created.Format("2006-01-02 15:04:05"), gmailMessageID.String, gmailMessageID.String) w.Write([]byte(html)) } // Stream attachment from Gmail func (h *EmailHandler) StreamAttachment(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) emailID, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "Invalid email ID", http.StatusBadRequest) return } attachmentID := vars["attachmentId"] // Get email's Gmail message ID var gmailMessageID sql.NullString err = h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID) if err != nil || !gmailMessageID.Valid { http.Error(w, "Email not found or not a Gmail email", http.StatusNotFound) return } if h.gmailService == nil { http.Error(w, "Gmail service not available", http.StatusServiceUnavailable) return } // For dynamic attachments, we need to fetch the message and find the attachment message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String). Format("FULL").Do() if err != nil { http.Error(w, "Failed to fetch email from Gmail", http.StatusInternalServerError) return } // Find the attachment by index var targetAttachment *gmail.MessagePart attachmentIndex := 1 findAttachment(message.Payload, attachmentID, &attachmentIndex, &targetAttachment) if targetAttachment == nil || targetAttachment.Body == nil || targetAttachment.Body.AttachmentId == "" { http.Error(w, "Attachment not found", http.StatusNotFound) return } // Fetch attachment data from Gmail attachment, err := h.gmailService.Users.Messages.Attachments. Get("me", gmailMessageID.String, targetAttachment.Body.AttachmentId).Do() if err != nil { http.Error(w, "Failed to fetch attachment from Gmail", http.StatusInternalServerError) return } // Decode base64 data, err := base64.URLEncoding.DecodeString(attachment.Data) if err != nil { http.Error(w, "Failed to decode attachment", http.StatusInternalServerError) return } // Set headers and stream filename := targetAttachment.Filename if filename == "" { // Generate filename from content type (same logic as extractGmailAttachments) switch targetAttachment.MimeType { case "application/pdf": filename = "attachment.pdf" case "image/png": filename = "image.png" case "image/jpeg": filename = "image.jpg" case "text/plain": filename = "text.txt" default: filename = "attachment" } } w.Header().Set("Content-Type", targetAttachment.MimeType) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) w.Write(data) } // Helper function to find attachment by index func findAttachment(part *gmail.MessagePart, targetID string, currentIndex *int, result **gmail.MessagePart) { // Check if this part is an attachment (same logic as extractGmailAttachments) if part.Body != nil && part.Body.AttachmentId != "" { fmt.Printf("Checking attachment %d (looking for %s): %s\n", *currentIndex, targetID, part.Filename) if strconv.Itoa(*currentIndex) == targetID { fmt.Printf("Found matching attachment!\n") *result = part return } *currentIndex++ } for _, subPart := range part.Parts { findAttachment(subPart, targetID, currentIndex, result) if *result != nil { return } } } // Helper function to join conditions func joinConditions(conditions []string, separator string) string { if len(conditions) == 0 { return "" } if len(conditions) == 1 { return conditions[0] } result := conditions[0] for i := 1; i < len(conditions); i++ { result += separator + conditions[i] } return result } // Gmail OAuth2 functions func getGmailService(credentialsFile, tokenFile string) (*gmail.Service, error) { ctx := context.Background() b, err := ioutil.ReadFile(credentialsFile) if err != nil { return nil, fmt.Errorf("unable to read client secret file: %v", err) } config, err := google.ConfigFromJSON(b, gmail.GmailReadonlyScope) if err != nil { return nil, fmt.Errorf("unable to parse client secret file to config: %v", err) } client := getClient(config, tokenFile) srv, err := gmail.NewService(ctx, option.WithHTTPClient(client)) if err != nil { return nil, fmt.Errorf("unable to retrieve Gmail client: %v", err) } return srv, nil } func getClient(config *oauth2.Config, tokFile string) *http.Client { tok, err := tokenFromFile(tokFile) if err != nil { return nil } return config.Client(context.Background(), tok) } func tokenFromFile(file string) (*oauth2.Token, error) { f, err := os.Open(file) if err != nil { return nil, err } defer f.Close() tok := &oauth2.Token{} err = json.NewDecoder(f).Decode(tok) return tok, err }