cmc-sales/go/internal/cmc/handlers/emails.go

870 lines
23 KiB
Go

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 := `<div class="notification is-light">
<p class="has-text-grey">No attachments found for this email.</p>
</div>`
w.Write([]byte(html))
return
}
// Build HTML table for attachments
var htmlBuilder strings.Builder
htmlBuilder.WriteString(`<div class="box">
<h3 class="title is-5">Attachments</h3>
<div class="table-container">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>`)
for _, att := range attachments {
icon := `<i class="fas fa-paperclip"></i>`
if att.IsMessageBody {
icon = `<i class="fas fa-envelope"></i>`
}
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(`
<tr>
<td>
<span class="icon has-text-grey">%s</span>
%s
</td>
<td><span class="tag is-light">%s</span></td>
<td>%d bytes</td>
<td>
<a href="%s" target="_blank" class="button is-small is-info">
<span class="icon"><i class="fas fa-download"></i></span>
<span>Download</span>
</a>
</td>
</tr>`, icon, att.Name, att.Type, att.Size, downloadURL))
}
htmlBuilder.WriteString(`
</tbody>
</table>
</div>
</div>`)
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 := `
<div class="notification is-warning">
<strong>Local Email</strong><br>
This email is not from Gmail and does not have stored content available for display.
</div>`
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(`
<div class="content">
<div class="notification is-success is-light">
<strong>Stored Email Content</strong><br>
Message body is stored locally as attachment: %s (%s, %d bytes)
</div>
<div class="box">
<p><em>Content would be loaded from local storage here.</em></p>
<p>Attachment ID: %d</p>
</div>
</div>`, 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(`
<div class="notification is-danger">
<strong>Gmail API Error</strong><br>
Failed to fetch email from Gmail: %v
</div>`, 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(`
<div class="notification is-danger">
<strong>Decode Error</strong><br>
Failed to decode email: %v
</div>`, 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(`
<div class="notification is-danger">
<strong>Parse Error</strong><br>
Failed to parse email: %v
</div>`, 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(`
<div class="content">
<div class="notification is-info is-light">
<strong>Plain Text Email</strong><br>
This email contains only plain text content.
</div>
<div class="box">
<pre style="white-space: pre-wrap; font-family: inherit;">%s</pre>
</div>
</div>`, env.Text)
w.Write([]byte(html))
} else {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := `
<div class="notification is-warning">
<strong>No Content</strong><br>
No HTML or text content found in this email.
</div>`
w.Write([]byte(html))
}
return
}
// No Gmail service available - show error
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="content">
<div class="notification is-warning is-light">
<strong>Gmail Service Unavailable</strong><br>
<small>Subject: %s</small><br>
<small>Date: %s</small><br>
<small>Gmail Message ID: <code>%s</code></small>
</div>
<div class="box">
<div class="content">
<h4>Integration Status</h4>
<p>Gmail service is not available. To enable email content display:</p>
<ol>
<li>Ensure <code>credentials.json</code> and <code>token.json</code> files are present</li>
<li>Configure Gmail API OAuth2 authentication</li>
<li>Restart the application</li>
</ol>
<p class="has-text-grey-light is-size-7">
<strong>Gmail Message ID:</strong> <code>%s</code>
</p>
</div>
</div>
</div>`,
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
}