From 918a2a4164fbd3d44751fe0255965ae514ffab71 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:14:22 +0000 Subject: [PATCH 01/22] feat: store gmail thread ids --- backend/src/models/company.go | 4 +++ backend/src/models/speaker.go | 4 +++ backend/src/models/thread.go | 4 +++ backend/src/mongodb/company.go | 29 ++++++++++++++++++++ backend/src/mongodb/speaker.go | 29 ++++++++++++++++++++ backend/src/router/company.go | 48 ++++++++++++++++++++++++++++++++++ backend/src/router/init.go | 2 ++ backend/src/router/speaker.go | 36 +++++++++++++++++++++++++ 8 files changed, 156 insertions(+) diff --git a/backend/src/models/company.go b/backend/src/models/company.go index bbda4947..6f56e166 100644 --- a/backend/src/models/company.go +++ b/backend/src/models/company.go @@ -39,6 +39,10 @@ type CompanyParticipation struct { // Some random notes about this participation. Notes string `json:"notes" bson:"notes"` + // GmailThreadIds is an array of Gmail thread IDs linked to this participation. + // These are used to sync communications with Gmail. + GmailThreadIds []string `json:"gmailThreadIds,omitempty" bson:"gmailThreadIds,omitempty"` + // Stand details StandDetails StandDetails `json:"standDetails,omitempty" bson:"standDetails,omitempty"` diff --git a/backend/src/models/speaker.go b/backend/src/models/speaker.go index 1c644d2c..a7295e2b 100644 --- a/backend/src/models/speaker.go +++ b/backend/src/models/speaker.go @@ -40,6 +40,10 @@ type SpeakerParticipation struct { // Hotel information regarding this speaker. Room SpeakerParticipationRoom `json:"room" bson:"room"` + + // GmailThreadIds is an array of Gmail thread IDs linked to this participation. + // These are used to sync communications with Gmail. + GmailThreadIds []string `json:"gmailThreadIds,omitempty" bson:"gmailThreadIds,omitempty"` } type SpeakerImages struct { diff --git a/backend/src/models/thread.go b/backend/src/models/thread.go index c5184a51..85102947 100644 --- a/backend/src/models/thread.go +++ b/backend/src/models/thread.go @@ -66,6 +66,10 @@ type Thread struct { // REVIEWED => thread is posted, but some changed must be made before it's ready to be approved. // PENDING => thread is posted and is waiting for the coordination's approval/review. Status ThreadStatus `json:"status" bson:"status"` + + // GmailMessageId is the ID of the Gmail message this thread was synced from. + // This is used to prevent duplicate syncs. + GmailMessageId string `json:"gmailMessageId,omitempty" bson:"gmailMessageId,omitempty"` } type ThreadWithEntry struct { diff --git a/backend/src/mongodb/company.go b/backend/src/mongodb/company.go index 8a792eb0..5e511d8f 100644 --- a/backend/src/mongodb/company.go +++ b/backend/src/mongodb/company.go @@ -824,6 +824,35 @@ func (c *CompaniesType) DeleteCompanyThread(id, threadID primitive.ObjectID) (*m return &updatedCompany, nil } +// UpdateCompanyGmailThreadIds updates the gmail thread IDs for a company's current participation +func (c *CompaniesType) UpdateCompanyGmailThreadIds(companyID primitive.ObjectID, gmailThreadIds []string) (*models.Company, error) { + ctx := context.Background() + currentEvent, err := Events.GetCurrentEvent() + if err != nil { + return nil, err + } + + var updatedCompany models.Company + + var updateQuery = bson.M{ + "$set": bson.M{ + "participations.$.gmailThreadIds": gmailThreadIds, + }, + } + + var filterQuery = bson.M{"_id": companyID, "participations.event": currentEvent.ID} + + var optionsQuery = options.FindOneAndUpdate() + optionsQuery.SetReturnDocument(options.After) + + if err := c.Collection.FindOneAndUpdate(ctx, filterQuery, updateQuery, optionsQuery).Decode(&updatedCompany); err != nil { + log.Println("Error updating company gmail thread IDs:", err) + return nil, err + } + + return &updatedCompany, nil +} + // UpdateCompanyParticipationStatus updates a company's participation status // related to the current event. This is the method used when one does not want necessarily to follow // the state machine described on models.ParticipationStatus. diff --git a/backend/src/mongodb/speaker.go b/backend/src/mongodb/speaker.go index f7c6e77b..fc328f8c 100644 --- a/backend/src/mongodb/speaker.go +++ b/backend/src/mongodb/speaker.go @@ -862,6 +862,35 @@ func (s *SpeakersType) GetSpeakerParticipationStatusValidSteps(speakerID primiti return nil, errors.New("No participation found") } +// UpdateSpeakerGmailThreadIds updates the gmail thread IDs for a speaker's current participation +func (s *SpeakersType) UpdateSpeakerGmailThreadIds(speakerID primitive.ObjectID, gmailThreadIds []string) (*models.Speaker, error) { + ctx := context.Background() + currentEvent, err := Events.GetCurrentEvent() + if err != nil { + return nil, err + } + + var updatedSpeaker models.Speaker + + var updateQuery = bson.M{ + "$set": bson.M{ + "participations.$.gmailThreadIds": gmailThreadIds, + }, + } + + var filterQuery = bson.M{"_id": speakerID, "participations.event": currentEvent.ID} + + var optionsQuery = options.FindOneAndUpdate() + optionsQuery.SetReturnDocument(options.After) + + if err := s.Collection.FindOneAndUpdate(ctx, filterQuery, updateQuery, optionsQuery).Decode(&updatedSpeaker); err != nil { + log.Println("Error updating speaker gmail thread IDs:", err) + return nil, err + } + + return &updatedSpeaker, nil +} + // UpdateSpeakerParticipationStatus updates a speaker's participation status // related to the current event. This is the method used when one does not want necessarily to follow // the state machine described on models.ParticipationStatus. diff --git a/backend/src/router/company.go b/backend/src/router/company.go index 2b67bb93..30241e52 100644 --- a/backend/src/router/company.go +++ b/backend/src/router/company.go @@ -1195,6 +1195,7 @@ func unsubscribeToCompany(w http.ResponseWriter, r *http.Request) { type ParticipationCommunications struct { Event int `json:"event"` Communications []*models.ThreadWithEntry `json:"communications"` + GmailThreadIds []string `json:"gmailThreadIds,omitempty"` } func getCompanyThreads(w http.ResponseWriter, r *http.Request) { @@ -1263,8 +1264,55 @@ func getCompanyThreads(w http.ResponseWriter, r *http.Request) { participationComms = append(participationComms, &ParticipationCommunications{ Event: participation.Event, Communications: comms, + GmailThreadIds: participation.GmailThreadIds, }) } json.NewEncoder(w).Encode(participationComms) } + +type updateGmailThreadIdsData struct { + GmailThreadIds []string `json:"gmailThreadIds"` +} + +func (ugtd *updateGmailThreadIdsData) ParseBody(body io.Reader) error { + if err := json.NewDecoder(body).Decode(ugtd); err != nil { + return err + } + return nil +} + +func updateCompanyGmailThreadIds(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + companyID, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid company id", http.StatusBadRequest) + return + } + + _, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials) + if !ok { + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + // Verify company exists + if _, err := mongodb.Companies.GetCompany(companyID); err != nil { + http.Error(w, "Company not found: "+err.Error(), http.StatusNotFound) + return + } + + var data = &updateGmailThreadIdsData{} + if err := data.ParseBody(r.Body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + company, err := mongodb.Companies.UpdateCompanyGmailThreadIds(companyID, data.GmailThreadIds) + if err != nil { + http.Error(w, "Could not update gmail thread IDs: "+err.Error(), http.StatusExpectationFailed) + return + } + + json.NewEncoder(w).Encode(company) +} diff --git a/backend/src/router/init.go b/backend/src/router/init.go index 3b585713..4b875a89 100644 --- a/backend/src/router/init.go +++ b/backend/src/router/init.go @@ -175,6 +175,7 @@ func InitializeRouter() { companyRouter.HandleFunc("/{id}/participation/billing/{billingID}", authCoordinator(deleteCompanyParticipationBilling)).Methods("DELETE") companyRouter.HandleFunc("/{id}/threads", authMember(getCompanyThreads)).Methods("GET") companyRouter.HandleFunc("/{id}/thread", authMember(addCompanyThread)).Methods("POST") + companyRouter.HandleFunc("/{id}/participation/gmail-threads", authMember(updateCompanyGmailThreadIds)).Methods("PUT") companyRouter.HandleFunc("/{id}/employers", authMember(getCompanyEmployers)).Methods("GET") companyRouter.HandleFunc("/{id}/employers", authMember(updateEmployersOrder)).Methods("PUT") companyRouter.HandleFunc("/{id}/employer", authMember(addEmployer)).Methods("POST") @@ -205,6 +206,7 @@ func InitializeRouter() { speakerRouter.HandleFunc("/{id}/image/public/company", authCoordinator(setSpeakerCompanyImage)).Methods("POST") speakerRouter.HandleFunc("/{id}/threads", authMember(getSpeakerThreads)).Methods("GET") speakerRouter.HandleFunc("/{id}/thread", authMember(addSpeakerThread)).Methods("POST") + speakerRouter.HandleFunc("/{id}/participation/gmail-threads", authMember(updateSpeakerGmailThreadIds)).Methods("PUT") speakerRouter.HandleFunc("/{id}/participation", authCoordinator(removeSpeakerParticipation)).Methods("DELETE") // flightInfo handlers diff --git a/backend/src/router/speaker.go b/backend/src/router/speaker.go index 7e8c4be3..cfb8dae3 100644 --- a/backend/src/router/speaker.go +++ b/backend/src/router/speaker.go @@ -879,6 +879,7 @@ func getSpeakerThreads(w http.ResponseWriter, r *http.Request) { participationComms = append(participationComms, &ParticipationCommunications{ Event: participation.Event, Communications: comms, + GmailThreadIds: participation.GmailThreadIds, }) } @@ -1103,3 +1104,38 @@ func removeSpeakerParticipation(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(speaker) } + +func updateSpeakerGmailThreadIds(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + speakerID, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid speaker id", http.StatusBadRequest) + return + } + + _, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials) + if !ok { + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + // Verify speaker exists + if _, err := mongodb.Speakers.GetSpeaker(speakerID); err != nil { + http.Error(w, "Speaker not found: "+err.Error(), http.StatusNotFound) + return + } + + var data = &updateGmailThreadIdsData{} + if err := data.ParseBody(r.Body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + speaker, err := mongodb.Speakers.UpdateSpeakerGmailThreadIds(speakerID, data.GmailThreadIds) + if err != nil { + http.Error(w, "Could not update gmail thread IDs: "+err.Error(), http.StatusExpectationFailed) + return + } + + json.NewEncoder(w).Encode(speaker) +} From 78d0d000e8f37088d83ed570f10765c3afaf3e1b Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:15:25 +0000 Subject: [PATCH 02/22] chore: save threads with gmail message id --- backend/src/mongodb/thread.go | 61 ++++++++++++++- backend/src/router/company.go | 138 ++++++++++++++++++++++++++++++++++ backend/src/router/init.go | 2 + backend/src/router/speaker.go | 115 ++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 4 deletions(-) diff --git a/backend/src/mongodb/thread.go b/backend/src/mongodb/thread.go index 6c2cde99..200777d2 100644 --- a/backend/src/mongodb/thread.go +++ b/backend/src/mongodb/thread.go @@ -23,9 +23,11 @@ type ThreadsType struct { // CreateThreadData holds data needed to create a thread type CreateThreadData struct { - Entry primitive.ObjectID - Meeting *primitive.ObjectID - Kind models.ThreadKind + Entry primitive.ObjectID + Meeting *primitive.ObjectID + Kind models.ThreadKind + GmailMessageId string + Posted *time.Time } type UpdateThreadData struct { @@ -55,18 +57,27 @@ func (utd *UpdateThreadData) ParseBody(body io.Reader) error { func (t *ThreadsType) CreateThread(data CreateThreadData) (*models.Thread, error) { ctx := context.Background() + postedTime := time.Now().UTC() + if data.Posted != nil { + postedTime = *data.Posted + } + query := bson.M{ "entry": data.Entry, "comments": []primitive.ObjectID{}, "status": models.ThreadStatusPending, "kind": data.Kind, - "posted": time.Now().UTC(), + "posted": postedTime, } if data.Meeting != nil { query["meeting"] = *data.Meeting } + if data.GmailMessageId != "" { + query["gmailMessageId"] = data.GmailMessageId + } + insertResult, err := t.Collection.InsertOne(ctx, query) if err != nil { @@ -96,6 +107,48 @@ func (t *ThreadsType) GetThread(threadID primitive.ObjectID) (*models.Thread, er return &thread, nil } +// GetThreadByGmailMessageId finds a thread by its Gmail message ID. +func (t *ThreadsType) GetThreadByGmailMessageId(gmailMessageId string) (*models.Thread, error) { + ctx := context.Background() + var thread models.Thread + + err := t.Collection.FindOne(ctx, bson.M{"gmailMessageId": gmailMessageId}).Decode(&thread) + if err != nil { + return nil, err + } + + return &thread, nil +} + +// GetThreadsByGmailMessageIds finds threads by multiple Gmail message IDs. +// Returns a map of gmailMessageId -> thread for quick lookup. +func (t *ThreadsType) GetThreadsByGmailMessageIds(gmailMessageIds []string) (map[string]*models.Thread, error) { + ctx := context.Background() + result := make(map[string]*models.Thread) + + if len(gmailMessageIds) == 0 { + return result, nil + } + + cursor, err := t.Collection.Find(ctx, bson.M{ + "gmailMessageId": bson.M{"$in": gmailMessageIds}, + }) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var thread models.Thread + if err := cursor.Decode(&thread); err != nil { + continue + } + result[thread.GmailMessageId] = &thread + } + + return result, cursor.Err() +} + // DeleteThread deletes a thread by its ID and cleans up related data. func (t *ThreadsType) DeleteThread(threadID primitive.ObjectID) (*models.Thread, error) { ctx := context.Background() diff --git a/backend/src/router/company.go b/backend/src/router/company.go index 30241e52..2f27542e 100644 --- a/backend/src/router/company.go +++ b/backend/src/router/company.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "strconv" + "time" "github.com/sinfo/deck2/src/config" "github.com/sinfo/deck2/src/spaces" @@ -1316,3 +1317,140 @@ func updateCompanyGmailThreadIds(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(company) } + +// GmailMessageData represents a Gmail message to be synced +type GmailMessageData struct { + MessageId string `json:"messageId"` + ThreadId string `json:"threadId"` + Subject string `json:"subject"` + From string `json:"from"` + To string `json:"to"` + Date string `json:"date"` + Body string `json:"body"` + IsOutgoing bool `json:"isOutgoing"` +} + +type syncGmailMessagesData struct { + Messages []GmailMessageData `json:"messages"` +} + +func (sgmd *syncGmailMessagesData) ParseBody(body io.Reader) error { + if err := json.NewDecoder(body).Decode(sgmd); err != nil { + return err + } + return nil +} + +func syncCompanyGmailMessages(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + params := mux.Vars(r) + companyID, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid company id", http.StatusBadRequest) + return + } + + credentials, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials) + if !ok { + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + // Verify company exists + if _, err := mongodb.Companies.GetCompany(companyID); err != nil { + http.Error(w, "Company not found: "+err.Error(), http.StatusNotFound) + return + } + + var data = &syncGmailMessagesData{} + if err := data.ParseBody(r.Body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + // Get existing synced messages to avoid duplicates + messageIds := make([]string, len(data.Messages)) + for i, msg := range data.Messages { + messageIds[i] = msg.MessageId + } + existingThreads, err := mongodb.Threads.GetThreadsByGmailMessageIds(messageIds) + if err != nil { + log.Println("Error checking existing gmail threads:", err) + } + + var syncedCount int + for _, msg := range data.Messages { + // Skip if already synced + if _, exists := existingThreads[msg.MessageId]; exists { + continue + } + + // Parse the date + var postedTime *time.Time + if msg.Date != "" { + if parsed, err := time.Parse(time.RFC3339, msg.Date); err == nil { + postedTime = &parsed + } + } + + // Determine thread kind based on direction + kind := models.ThreadKindFrom + if msg.IsOutgoing { + kind = models.ThreadKindTo + } + + // Format the message text - just subject as header and body content + var formattedText string + if msg.Subject != "" && msg.Subject != "(No subject)" { + formattedText = fmt.Sprintf("📧 %s\n\n%s", msg.Subject, msg.Body) + } else { + formattedText = msg.Body + } + + // Create post + cpd := mongodb.CreatePostData{ + Member: credentials.ID, + Text: formattedText, + } + + newPost, err := mongodb.Posts.CreatePost(cpd) + if err != nil { + log.Printf("Error creating post for gmail message %s: %s", msg.MessageId, err) + continue + } + + // Create thread + ctd := mongodb.CreateThreadData{ + Entry: newPost.ID, + Kind: kind, + GmailMessageId: msg.MessageId, + Posted: postedTime, + } + + newThread, err := mongodb.Threads.CreateThread(ctd) + if err != nil { + log.Printf("Error creating thread for gmail message %s: %s", msg.MessageId, err) + // Clean up post + mongodb.Posts.DeletePost(newPost.ID) + continue + } + + // Attach thread to company participation + _, err = mongodb.Companies.AddThread(companyID, newThread.ID) + if err != nil { + log.Printf("Error attaching thread to company for gmail message %s: %s", msg.MessageId, err) + // Clean up + mongodb.Posts.DeletePost(newPost.ID) + mongodb.Threads.DeleteThread(newThread.ID) + continue + } + + syncedCount++ + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "synced": syncedCount, + "total": len(data.Messages), + }) +} diff --git a/backend/src/router/init.go b/backend/src/router/init.go index 4b875a89..4324114f 100644 --- a/backend/src/router/init.go +++ b/backend/src/router/init.go @@ -176,6 +176,7 @@ func InitializeRouter() { companyRouter.HandleFunc("/{id}/threads", authMember(getCompanyThreads)).Methods("GET") companyRouter.HandleFunc("/{id}/thread", authMember(addCompanyThread)).Methods("POST") companyRouter.HandleFunc("/{id}/participation/gmail-threads", authMember(updateCompanyGmailThreadIds)).Methods("PUT") + companyRouter.HandleFunc("/{id}/participation/gmail-sync", authMember(syncCompanyGmailMessages)).Methods("POST") companyRouter.HandleFunc("/{id}/employers", authMember(getCompanyEmployers)).Methods("GET") companyRouter.HandleFunc("/{id}/employers", authMember(updateEmployersOrder)).Methods("PUT") companyRouter.HandleFunc("/{id}/employer", authMember(addEmployer)).Methods("POST") @@ -207,6 +208,7 @@ func InitializeRouter() { speakerRouter.HandleFunc("/{id}/threads", authMember(getSpeakerThreads)).Methods("GET") speakerRouter.HandleFunc("/{id}/thread", authMember(addSpeakerThread)).Methods("POST") speakerRouter.HandleFunc("/{id}/participation/gmail-threads", authMember(updateSpeakerGmailThreadIds)).Methods("PUT") + speakerRouter.HandleFunc("/{id}/participation/gmail-sync", authMember(syncSpeakerGmailMessages)).Methods("POST") speakerRouter.HandleFunc("/{id}/participation", authCoordinator(removeSpeakerParticipation)).Methods("DELETE") // flightInfo handlers diff --git a/backend/src/router/speaker.go b/backend/src/router/speaker.go index cfb8dae3..2db45686 100644 --- a/backend/src/router/speaker.go +++ b/backend/src/router/speaker.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "strconv" + "time" "github.com/gorilla/mux" "github.com/h2non/filetype" @@ -1139,3 +1140,117 @@ func updateSpeakerGmailThreadIds(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(speaker) } + +func syncSpeakerGmailMessages(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + params := mux.Vars(r) + speakerID, err := primitive.ObjectIDFromHex(params["id"]) + if err != nil { + http.Error(w, "Invalid speaker id", http.StatusBadRequest) + return + } + + credentials, ok := r.Context().Value(credentialsKey).(models.AuthorizationCredentials) + if !ok { + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + // Verify speaker exists + if _, err := mongodb.Speakers.GetSpeaker(speakerID); err != nil { + http.Error(w, "Speaker not found: "+err.Error(), http.StatusNotFound) + return + } + + var data = &syncGmailMessagesData{} + if err := data.ParseBody(r.Body); err != nil { + http.Error(w, "Could not parse body: "+err.Error(), http.StatusBadRequest) + return + } + + // Get existing synced messages to avoid duplicates + messageIds := make([]string, len(data.Messages)) + for i, msg := range data.Messages { + messageIds[i] = msg.MessageId + } + existingThreads, err := mongodb.Threads.GetThreadsByGmailMessageIds(messageIds) + if err != nil { + log.Println("Error checking existing gmail threads:", err) + } + + var syncedCount int + for _, msg := range data.Messages { + // Skip if already synced + if _, exists := existingThreads[msg.MessageId]; exists { + continue + } + + // Parse the date + var postedTime *time.Time + if msg.Date != "" { + if parsed, err := time.Parse(time.RFC3339, msg.Date); err == nil { + postedTime = &parsed + } + } + + // Determine thread kind based on direction + kind := models.ThreadKindFrom + if msg.IsOutgoing { + kind = models.ThreadKindTo + } + + // Format the message text - just subject as header and body content + var formattedText string + if msg.Subject != "" && msg.Subject != "(No subject)" { + formattedText = fmt.Sprintf("📧 %s\n\n%s", msg.Subject, msg.Body) + } else { + formattedText = msg.Body + } + + // Create post + cpd := mongodb.CreatePostData{ + Member: credentials.ID, + Text: formattedText, + } + + newPost, err := mongodb.Posts.CreatePost(cpd) + if err != nil { + log.Printf("Error creating post for gmail message %s: %s", msg.MessageId, err) + continue + } + + // Create thread + ctd := mongodb.CreateThreadData{ + Entry: newPost.ID, + Kind: kind, + GmailMessageId: msg.MessageId, + Posted: postedTime, + } + + newThread, err := mongodb.Threads.CreateThread(ctd) + if err != nil { + log.Printf("Error creating thread for gmail message %s: %s", msg.MessageId, err) + // Clean up post + mongodb.Posts.DeletePost(newPost.ID) + continue + } + + // Attach thread to speaker participation + _, err = mongodb.Speakers.AddThread(speakerID, newThread.ID) + if err != nil { + log.Printf("Error attaching thread to speaker for gmail message %s: %s", msg.MessageId, err) + // Clean up + mongodb.Posts.DeletePost(newPost.ID) + mongodb.Threads.DeleteThread(newThread.ID) + continue + } + + syncedCount++ + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "synced": syncedCount, + "total": len(data.Messages), + }) +} From a1a13b07063e5e39f8b4ea8ab45d6f40459c7caf Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:22:22 +0000 Subject: [PATCH 03/22] feat: page to view gmail messages --- frontend/src/router.ts | 6 + .../Dashboard/Gmail/GmailMessagesView.vue | 349 ++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue diff --git a/frontend/src/router.ts b/frontend/src/router.ts index df0b8afc..132850fb 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -98,6 +98,12 @@ const router = createRouter({ ), meta: { roles: ["COORDINATOR"] }, }, + { + path: "gmail", + name: "gmail-messages", + component: () => + import("./views/Dashboard/Gmail/GmailMessagesView.vue"), + }, ], }, ], diff --git a/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue b/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue new file mode 100644 index 00000000..cdad2bfd --- /dev/null +++ b/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue @@ -0,0 +1,349 @@ + + + From 1a16ee1a2f570ded7e3b17ac2f17d0bb632003f4 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:27:11 +0000 Subject: [PATCH 04/22] feat: refresh google token --- frontend/src/composables/useBulkEmails.ts | 40 +++++++++++++---- frontend/src/composables/useDirectEmail.ts | 22 +++++++-- frontend/src/composables/useGmailDrafts.ts | 41 ++++++++++++++++- frontend/src/composables/useGoogleAuth.ts | 52 +++++++++++++++++++++- 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/frontend/src/composables/useBulkEmails.ts b/frontend/src/composables/useBulkEmails.ts index f88b3236..b794befe 100644 --- a/frontend/src/composables/useBulkEmails.ts +++ b/frontend/src/composables/useBulkEmails.ts @@ -207,8 +207,9 @@ const speakerEmailFetcher: EmailFetcher = { export const useBulkEmails = ( emailFetcher: EmailFetcher, ) => { - const { createBulkDraftEmails, isLoading, error } = useGmailDrafts(); - const { signInWithGoogle, isSigningIn } = useGoogleAuth(); + const { createBulkDraftEmails, isLoading, error, needsReauth } = + useGmailDrafts(); + const { signInWithGoogle, requestGoogleToken, isSigningIn } = useGoogleAuth(); const authStore = useAuthStore(); const eventStore = useEventStore(); @@ -221,7 +222,7 @@ export const useBulkEmails = ( const sentCount = ref(0); const totalToSend = ref(0); - const isGoogleConnected = computed(() => !!authStore.googleAccessToken); + const isGoogleConnected = computed(() => authStore.isGoogleAuthenticated); // Generic function to process and verify bulk emails before sending const processBulkEmails = async ( @@ -367,12 +368,33 @@ export const useBulkEmails = ( } // Create bulk draft emails with progress tracking - const bulkResult = await createBulkDraftEmails( - draftEmails, - (completed) => { - sentCount.value = completed; - }, - ); + let bulkResult = await createBulkDraftEmails(draftEmails, (completed) => { + sentCount.value = completed; + }); + + // Handle re-authentication if token expired during bulk send + if (needsReauth.value) { + const reauthSuccess = await requestGoogleToken(); + if (!reauthSuccess) { + throw new Error( + "Google session expired. Please re-authenticate and try again.", + ); + } + // Retry the remaining emails after re-authentication + const remainingEmails = draftEmails.slice(sentCount.value); + if (remainingEmails.length > 0) { + const retryResult = await createBulkDraftEmails( + remainingEmails, + (completed) => { + sentCount.value = sentCount.value + completed; + }, + ); + bulkResult = { + success: [...bulkResult.success, ...retryResult.success], + failed: [...bulkResult.failed, ...retryResult.failed], + }; + } + } const finalResult: BulkEmailResult = { success: bulkResult.success as { entityInfo: EntityInfo }[], diff --git a/frontend/src/composables/useDirectEmail.ts b/frontend/src/composables/useDirectEmail.ts index 82fc217a..2a3dbef1 100644 --- a/frontend/src/composables/useDirectEmail.ts +++ b/frontend/src/composables/useDirectEmail.ts @@ -44,8 +44,8 @@ export interface SendEmailResult { } export const useDirectEmail = (entity: DirectEmailEntity) => { - const { createDraftEmail, isLoading } = useGmailDrafts(); - const { signInWithGoogle, isSigningIn } = useGoogleAuth(); + const { createDraftEmail, isLoading, needsReauth } = useGmailDrafts(); + const { signInWithGoogle, requestGoogleToken, isSigningIn } = useGoogleAuth(); const authStore = useAuthStore(); const eventStore = useEventStore(); @@ -54,7 +54,7 @@ export const useDirectEmail = (entity: DirectEmailEntity) => { const isSending = ref(false); const result = ref(null); - const isGoogleConnected = computed(() => !!authStore.googleAccessToken); + const isGoogleConnected = computed(() => authStore.isGoogleAuthenticated); const fetchAvailableEmails = async () => { isFetchingEmails.value = true; @@ -129,7 +129,21 @@ export const useDirectEmail = (entity: DirectEmailEntity) => { body: `${body}
${signature}`, }; - await createDraftEmail(draftOptions); + let draftResult = await createDraftEmail(draftOptions); + + // Handle re-authentication if token expired during request + if (needsReauth.value) { + const reauthSuccess = await requestGoogleToken(); + if (!reauthSuccess) { + throw new Error("Google re-authentication failed."); + } + // Retry after re-authentication + draftResult = await createDraftEmail(draftOptions); + } + + if (!draftResult) { + throw new Error("Failed to create email draft."); + } const sendResult = { success: true }; result.value = sendResult; diff --git a/frontend/src/composables/useGmailDrafts.ts b/frontend/src/composables/useGmailDrafts.ts index d72f84ca..17a9e71b 100644 --- a/frontend/src/composables/useGmailDrafts.ts +++ b/frontend/src/composables/useGmailDrafts.ts @@ -31,17 +31,44 @@ export const useGmailDrafts = () => { const authStore = useAuthStore(); const isLoading = ref(false); const error = ref(null); + const needsReauth = ref(false); + + /** + * Check if Google token is valid before making requests + */ + const checkAuth = (): boolean => { + if (!authStore.isGoogleAuthenticated) { + error.value = "Google authentication required"; + needsReauth.value = true; + return false; + } + return true; + }; + + /** + * Handle API response and check for auth errors + */ + const handleAuthError = (response: Response): boolean => { + if (response.status === 401) { + // Token is invalid or expired + authStore.clearGoogleToken(); + error.value = "Google session expired. Please re-authenticate."; + needsReauth.value = true; + return true; + } + return false; + }; const createDraftEmail = async ( options: DraftEmailOptions, ): Promise => { - if (!authStore.googleAccessToken) { - error.value = "Google access token not available"; + if (!checkAuth()) { return null; } isLoading.value = true; error.value = null; + needsReauth.value = false; try { // Create the email message in RFC 2822 format @@ -80,6 +107,10 @@ export const useGmailDrafts = () => { }, ); + if (handleAuthError(response)) { + return null; + } + if (!response.ok) { const errorData = await response.json(); throw new Error( @@ -132,6 +163,11 @@ export const useGmailDrafts = () => { email, error: error.value || "Unknown error", }); + + // If re-auth is needed, stop processing and return early + if (needsReauth.value) { + break; + } } // Call progress callback if provided @@ -149,6 +185,7 @@ export const useGmailDrafts = () => { return { isLoading, error, + needsReauth, createDraftEmail, createBulkDraftEmails, }; diff --git a/frontend/src/composables/useGoogleAuth.ts b/frontend/src/composables/useGoogleAuth.ts index cae47a19..c1d57fc8 100644 --- a/frontend/src/composables/useGoogleAuth.ts +++ b/frontend/src/composables/useGoogleAuth.ts @@ -49,22 +49,70 @@ export const useGoogleAuth = () => { try { const jwt = await generateJwt({ access_token: response.access_token }); authStore.setToken(jwt.data.deck_token); - authStore.googleAccessToken = response.access_token; + // Store Google token with expiration (expires_in is in seconds, default 3600) + const expiresIn = response.expires_in + ? Number(response.expires_in) + : 3600; + authStore.setGoogleToken(response.access_token, expiresIn); } catch (err) { console.error("Failed to generate JWT:", err); throw new Error("Failed to complete authentication"); } }; + /** + * Request only Google access token without Deck JWT (for Gmail operations) + * Use this when the user is already authenticated with Deck but Google token expired + */ + const requestGoogleToken = async (): Promise => { + return new Promise((resolve) => { + isSigningIn.value = true; + error.value = null; + + googleSdkLoaded((google) => { + google.accounts.oauth2 + .initTokenClient({ + client_id: env.GOOGLE_CLIENT_ID, + scope: env.GOOGLE_SCOPE, + callback: async (response) => { + try { + const expiresIn = response.expires_in + ? Number(response.expires_in) + : 3600; + authStore.setGoogleToken(response.access_token, expiresIn); + resolve(true); + } catch (err) { + error.value = + err instanceof Error + ? err.message + : "Failed to get Google token"; + resolve(false); + } finally { + isSigningIn.value = false; + } + }, + error_callback: (err) => { + console.error("Error during Google token request", err); + error.value = "Google authentication failed"; + isSigningIn.value = false; + resolve(false); + }, + }) + .requestAccessToken(); + }); + }); + }; + const signOut = () => { authStore.clearToken(); - authStore.googleAccessToken = null; + authStore.clearGoogleToken(); }; return { isSigningIn, error, signInWithGoogle, + requestGoogleToken, signOut, }; }; From b356dcd77cdc108d7465885ae81197206072ea55 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:27:29 +0000 Subject: [PATCH 05/22] chore: store google token --- frontend/src/stores/auth.ts | 46 +++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e42bc862..59ebee0b 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -67,8 +67,46 @@ export const useAuthStore = defineStore("auth", () => { isInitializing.value = false; }; - // Google Auth access token - const googleAccessToken = ref(); + // Google Auth access token with persistence + const googleAccessToken = ref( + localStorage.getItem("googleAccessToken"), + ); + const googleAccessTokenExpiry = ref( + localStorage.getItem("googleAccessTokenExpiry") + ? Number(localStorage.getItem("googleAccessTokenExpiry")) + : null, + ); + + // Check if Google token is valid (exists and not expired) + const isGoogleAuthenticated = computed(() => { + if (!googleAccessToken.value) return false; + if ( + googleAccessTokenExpiry.value && + googleAccessTokenExpiry.value < Date.now() + ) { + return false; + } + return true; + }); + + // Set Google token with expiration (expiresIn is in seconds) + const setGoogleToken = (token: string, expiresIn: number = 3600) => { + googleAccessToken.value = token; + // Calculate expiry timestamp (subtract 60 seconds for safety margin) + const expiryTime = Date.now() + (expiresIn - 60) * 1000; + googleAccessTokenExpiry.value = expiryTime; + + localStorage.setItem("googleAccessToken", token); + localStorage.setItem("googleAccessTokenExpiry", String(expiryTime)); + }; + + // Clear Google token + const clearGoogleToken = () => { + googleAccessToken.value = null; + googleAccessTokenExpiry.value = null; + localStorage.removeItem("googleAccessToken"); + localStorage.removeItem("googleAccessTokenExpiry"); + }; return { token, @@ -81,5 +119,9 @@ export const useAuthStore = defineStore("auth", () => { initialize, googleAccessToken, + googleAccessTokenExpiry, + isGoogleAuthenticated, + setGoogleToken, + clearGoogleToken, }; }); From 8106a0c5391375b22831798669ecc27b8c3e60f1 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:58:12 +0000 Subject: [PATCH 06/22] feat: list gmail threads for gmail threads component --- backend/src/router/init.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/router/init.go b/backend/src/router/init.go index 4324114f..6ec20f23 100644 --- a/backend/src/router/init.go +++ b/backend/src/router/init.go @@ -223,6 +223,7 @@ func InitializeRouter() { eventRouter.HandleFunc("", authCoordinator(updateEvent)).Methods("PUT") eventRouter.HandleFunc("/{id:[0-9]+}", authMember(getEvent)).Methods("GET") eventRouter.HandleFunc("/{id:[0-9]+}", authAdmin(deleteEvent)).Methods("DELETE") + eventRouter.HandleFunc("/{id:[0-9]+}/gmail-threads", authMember(getLinkedGmailThreads)).Methods("GET") eventRouter.HandleFunc("/themes", authCoordinator(updateEventThemes)).Methods("PUT") eventRouter.HandleFunc("/packages", authCoordinator(addPackageToEvent)).Methods("POST") eventRouter.HandleFunc("/packages/{id}", authCoordinator(removePackageFromEvent)).Methods("DELETE") From 86a9bacfd5e636a97f801cc4ad9c91bc65b0baf5 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 19:59:25 +0000 Subject: [PATCH 07/22] feat: add gmail threads to communications and sync --- frontend/src/components/Communications.vue | 330 ++++++++++++++++++++- 1 file changed, 328 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Communications.vue b/frontend/src/components/Communications.vue index e238268f..8e004b9d 100644 --- a/frontend/src/components/Communications.vue +++ b/frontend/src/components/Communications.vue @@ -29,6 +29,65 @@ +
+ + + + + + +

+ {{ + currentParticipationGmailThreadIds.length > 0 + ? `${currentParticipationGmailThreadIds.length} Gmail thread(s) linked` + : "Link Gmail threads to this participation" + }} +

+
+
+
+ + + + + + +

Sync Gmail messages to communications

+
+
+
+
+ + + + + + + {{ hideLinked ? "Show linked threads" : "Hide linked threads" }} + + + - +

Connect your Gmail account

- Sign in with Google to view your email messages. + Sign in with Google to view your email threads.

+
- -
+ +
- Showing {{ messages.length }} messages + Showing {{ filteredThreads.length }} threads + + ({{ threads.length - filteredThreads.length }} linked hidden) + (more available)
- - -
-
-
- - {{ getHeaderValue(message, "From") || "Unknown sender" }} - - - {{ formatDate(message.internalDate) }} - + + +
+
+
+ + {{ + getThreadParticipants(thread) || + "Unknown participants" + }} + + + {{ thread.messages.length }} + {{ + thread.messages.length === 1 ? "message" : "messages" + }} + + + {{ formatDate(getLatestMessage(thread).internalDate) }} + +
+
+ {{ getThreadSubject(thread) || "(no subject)" }} +
+
+ {{ getLatestMessage(thread).snippet }} +
+
+
+ +
+ + {{ label }} + +
+ + + + + + + Link to company/speaker + + + + + +
-
- {{ getHeaderValue(message, "Subject") || "(no subject)" }} +
+ + + + + +
+
+
+
+ + {{ getHeaderValue(message, "From") || "Unknown" }} + + + Latest + +
+ + {{ formatDate(message.internalDate, true) }} +
{{ message.snippet }}
-
- - {{ label }} - -
- - +
+
@@ -143,10 +261,10 @@
- + -

No messages found

+

No threads found

{{ searchQuery @@ -206,18 +324,156 @@

+ + + + + + Link Gmail Thread + + Select a company or speaker to link this Gmail thread to their + communications. + + + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+
+
+
+
+ + +
+
+
{{ entity.name }}
+
+ Event {{ entity.participation.event }} +
+
+
+ + Already linked + +
+
+
+ No {{ linkEntityType }}s found +
+
+ Start typing to search for {{ linkEntityType }}s +
+
+ + + + Cancel + + + +
+
From 0eba46bc86ea6388e73eb86decf9ea9edfd9d54f Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:13:31 +0000 Subject: [PATCH 15/22] chore: log error on google auth error --- frontend/src/components/Communications.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Communications.vue b/frontend/src/components/Communications.vue index f6cfbcb9..52327586 100644 --- a/frontend/src/components/Communications.vue +++ b/frontend/src/components/Communications.vue @@ -652,7 +652,7 @@ const handleGmailThreadsSave = async (threadIds: string[]) => { // Gmail sync functionality const authStore = useAuthStore(); const gmailComposable = useGmailMessages(); -const { requestGoogleToken } = useGoogleAuth(); +const { requestGoogleToken, error: googleAuthError } = useGoogleAuth(); const isSyncing = ref(false); /** @@ -706,7 +706,10 @@ const syncGmailMessages = async () => { if (!authStore.isGoogleAuthenticated) { const success = await requestGoogleToken(); if (!success) { - console.error("Failed to authenticate with Google"); + console.error( + "Failed to authenticate with Google: ", + googleAuthError.value, + ); return; } } From db4aed0ed896fd2136339daecfd902bf2fb3e9e5 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:13:40 +0000 Subject: [PATCH 16/22] chore: remove important label --- frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue b/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue index 349ece28..fbcf9f32 100644 --- a/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue +++ b/frontend/src/views/Dashboard/Gmail/GmailMessagesView.vue @@ -682,7 +682,7 @@ const getThreadParticipants = (thread: GmailThread): string => { }; const getThreadLabels = (thread: GmailThread): string[] => { - const displayLabels = ["IMPORTANT", "STARRED", "UNREAD"]; + const displayLabels = ["STARRED", "UNREAD"]; const labels = new Set(); for (const msg of thread.messages) { if (msg.labelIds) { From a262219760889cf6bdb1a54f5eb815d10872bb28 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:15:31 +0000 Subject: [PATCH 17/22] feat: add mail link to navbar --- frontend/src/components/Navbar.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/Navbar.vue b/frontend/src/components/Navbar.vue index 1f685e5c..d465af9b 100644 --- a/frontend/src/components/Navbar.vue +++ b/frontend/src/components/Navbar.vue @@ -7,6 +7,7 @@ import { Settings, Trophy, ChevronDown, + Mail, } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import type { RouteLocationRaw } from "vue-router"; @@ -54,6 +55,7 @@ const navigation: NavigationItem[] = [ { name: "Me", to: { name: "dashboard" } }, { name: "Companies", to: { name: "companies" } }, { name: "Speakers", to: { name: "speakers" } }, + { name: "Gmail", to: { name: "gmail-messages" }, icon: Mail }, { name: "Leaderboard", to: { name: "leaderboard" }, icon: Trophy }, { name: "Settings", to: { name: "settings" }, icon: Settings }, ]; From fcbd63f3f98ea0b39e74204596d642f383c1aaba Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:15:53 +0000 Subject: [PATCH 18/22] chore: filter out more leftover gmail replies parts --- frontend/src/components/Communications.vue | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Communications.vue b/frontend/src/components/Communications.vue index 52327586..d653791b 100644 --- a/frontend/src/components/Communications.vue +++ b/frontend/src/components/Communications.vue @@ -775,8 +775,14 @@ const syncGmailMessages = async () => { } // Clean up the body further - remove excessive quoted content + // First, normalize multi-line quote headers (e.g., "On Fri, 16 Jan 2026 at 09:40, Name <\nemail@example.com> wrote:") + const normalizedBody = body.replace( + /^(On\s+\w{3},\s+\d{1,2}\s+\w{3}\s+\d{4}\s+at\s+\d{1,2}:\d{2},\s+[^<]+)<\s*\n\s*([^>]+@[^>]+)>\s*wrote:/gim, + "$1<$2> wrote:", + ); + // Remove common email quote markers - const lines = body.split("\n"); + const lines = normalizedBody.split("\n"); const cleanedLines: string[] = []; let foundQuoteStart = false; @@ -787,13 +793,18 @@ const syncGmailMessages = async () => { line.match(/^On\s+\w{3},?\s+\w{3}\s+\d{1,2},?\s+\d{4}/i) || // "On Wed, Nov 26, 2025" or "On Wed Nov 26 2025" line.match(/^On\s+\w{3},?\s+\d{1,2}\s+\w{3}/i) || // "On Wed, 17 Dec" (start of quote attribution) line.match(/^On\s+\d{1,2}\s+\w{3}\s+\d{4}/i) || // "On 17 Dec 2025" + line.match( + /^On\s+\w{3},\s+\d{1,2}\s+\w{3}\s+\d{4}\s+at\s+\d{1,2}:\d{2}/i, + ) || // "On Fri, 16 Jan 2026 at 09:40" line.match(/wrote:\s*$/) || // Any line ending with "wrote:" line.match(/^>/) || // Quoted line starting with > line.match(/^-{3,}\s*Original Message\s*-{3,}$/i) || line.match(/^_{3,}$/) || line.match(/^From:.*Sent:.*To:/i) || line.match(/^-{2,}\s*Forwarded message\s*-{2,}$/i) || - line.match(/<[^>]+@[^>]+>\s*wrote:?\s*$/i) // " wrote:" + line.match(/<[^>]+@[^>]+>\s*wrote:?\s*$/i) || // " wrote:" + line.match(/^[^>]*<\s*$/) || // Line ending with just "<" (email split across lines) + line.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+>\s*wrote:?\s*$/i) // "email@example.com> wrote:" (continuation of split line) ) { foundQuoteStart = true; } From 9da7e020796396367b65ff41dc301c2a070e668c Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:16:06 +0000 Subject: [PATCH 19/22] chore: forgot to commit collapsable index --- frontend/src/components/ui/collapsible/index.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 frontend/src/components/ui/collapsible/index.ts diff --git a/frontend/src/components/ui/collapsible/index.ts b/frontend/src/components/ui/collapsible/index.ts new file mode 100644 index 00000000..289bdbbf --- /dev/null +++ b/frontend/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.vue"; +export { default as CollapsibleContent } from "./CollapsibleContent.vue"; +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue"; From 3d0d2dc90b4256fa351bf787f3207b1936838a70 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:16:25 +0000 Subject: [PATCH 20/22] chore: gmailThreadIds --- frontend/src/dto/index.ts | 1 + frontend/src/dto/threads.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/frontend/src/dto/index.ts b/frontend/src/dto/index.ts index 15b5298c..ad0f7acc 100644 --- a/frontend/src/dto/index.ts +++ b/frontend/src/dto/index.ts @@ -2,6 +2,7 @@ export type ObjectID = string; export interface Participation { event: number; + gmailThreadIds?: string[]; } export interface ImplementsParticipationStatus { diff --git a/frontend/src/dto/threads.ts b/frontend/src/dto/threads.ts index b300226b..42205058 100644 --- a/frontend/src/dto/threads.ts +++ b/frontend/src/dto/threads.ts @@ -10,6 +10,7 @@ export interface ThreadWithEntry { comments: ObjectID[]; kind: ThreadKind; status: ThreadStatus; + gmailMessageId?: string; } export enum ThreadKind { @@ -35,4 +36,5 @@ export interface CreateThread { export interface ParticipationCommunications { event: number; communications: ThreadWithEntry[]; + gmailThreadIds?: string[]; } From 735b1b957694bd2b1ac4cca92ec6a5239b001d6f Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:16:43 +0000 Subject: [PATCH 21/22] feat: gmail messages composable --- frontend/src/composables/useGmailMessages.ts | 646 +++++++++++++++++++ frontend/src/composables/useGoogleAuth.ts | 12 +- 2 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 frontend/src/composables/useGmailMessages.ts diff --git a/frontend/src/composables/useGmailMessages.ts b/frontend/src/composables/useGmailMessages.ts new file mode 100644 index 00000000..6fcb23e7 --- /dev/null +++ b/frontend/src/composables/useGmailMessages.ts @@ -0,0 +1,646 @@ +import { useAuthStore } from "@/stores/auth"; +import { ref } from "vue"; + +export interface GmailMessageHeader { + name: string; + value: string; +} + +export interface GmailMessagePart { + partId: string; + mimeType: string; + filename: string; + headers: GmailMessageHeader[]; + body: { + attachmentId?: string; + size: number; + data?: string; + }; + parts?: GmailMessagePart[]; +} + +export interface GmailMessage { + id: string; + threadId: string; + labelIds: string[]; + snippet: string; + historyId: string; + internalDate: string; + payload: { + partId: string; + mimeType: string; + filename: string; + headers: GmailMessageHeader[]; + body: { + size: number; + data?: string; + }; + parts?: GmailMessagePart[]; + }; + sizeEstimate: number; + raw?: string; +} + +export interface GmailMessageListItem { + id: string; + threadId: string; +} + +export interface GmailMessagesListResponse { + messages: GmailMessageListItem[]; + nextPageToken?: string; + resultSizeEstimate: number; +} + +export interface GetMessagesOptions { + maxResults?: number; + pageToken?: string; + q?: string; + labelIds?: string[]; + includeSpamTrash?: boolean; +} + +export interface GetMessageOptions { + format?: "minimal" | "full" | "raw" | "metadata"; + metadataHeaders?: string[]; +} + +export interface GmailThreadListItem { + id: string; + snippet: string; + historyId: string; +} + +export interface GmailThreadsListResponse { + threads: GmailThreadListItem[]; + nextPageToken?: string; + resultSizeEstimate: number; +} + +export interface GmailThread { + id: string; + historyId: string; + messages: GmailMessage[]; +} + +export const useGmailMessages = () => { + const authStore = useAuthStore(); + const isLoading = ref(false); + const error = ref(null); + const needsReauth = ref(false); + + /** + * Check if Google token is valid before making requests + */ + const checkAuth = (): boolean => { + if (!authStore.isGoogleAuthenticated) { + error.value = "Google authentication required"; + needsReauth.value = true; + return false; + } + return true; + }; + + /** + * Handle API response and check for auth errors + */ + const handleAuthError = (response: Response): boolean => { + if (response.status === 401) { + // Token is invalid or expired + authStore.clearGoogleToken(); + error.value = "Google session expired. Please re-authenticate."; + needsReauth.value = true; + return true; + } + return false; + }; + + /** + * List messages in the user's mailbox + */ + const listMessages = async ( + options: GetMessagesOptions = {}, + ): Promise => { + if (!checkAuth()) { + return null; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + + if (options.maxResults) { + params.append("maxResults", options.maxResults.toString()); + } + if (options.pageToken) { + params.append("pageToken", options.pageToken); + } + if (options.q) { + params.append("q", options.q); + } + if (options.labelIds && options.labelIds.length > 0) { + options.labelIds.forEach((labelId) => + params.append("labelIds", labelId), + ); + } + if (options.includeSpamTrash !== undefined) { + params.append("includeSpamTrash", options.includeSpamTrash.toString()); + } + + const queryString = params.toString(); + const url = `https://gmail.googleapis.com/gmail/v1/users/me/messages${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (handleAuthError(response)) { + return null; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gmail API error: ${errorData.error?.message || response.statusText}`, + ); + } + + const result: GmailMessagesListResponse = await response.json(); + return result; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to list messages"; + return null; + } finally { + isLoading.value = false; + } + }; + + /** + * List threads in the user's mailbox + */ + const listThreads = async ( + options: GetMessagesOptions = {}, + ): Promise => { + if (!checkAuth()) { + return null; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + + if (options.maxResults) { + params.append("maxResults", options.maxResults.toString()); + } + if (options.pageToken) { + params.append("pageToken", options.pageToken); + } + if (options.q) { + params.append("q", options.q); + } + if (options.labelIds && options.labelIds.length > 0) { + options.labelIds.forEach((labelId) => + params.append("labelIds", labelId), + ); + } + if (options.includeSpamTrash !== undefined) { + params.append("includeSpamTrash", options.includeSpamTrash.toString()); + } + + const queryString = params.toString(); + const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (handleAuthError(response)) { + return null; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gmail API error: ${errorData.error?.message || response.statusText}`, + ); + } + + const result: GmailThreadsListResponse = await response.json(); + return result; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to list threads"; + return null; + } finally { + isLoading.value = false; + } + }; + + /** + * Get a thread with all its messages + */ + const getThread = async ( + threadId: string, + options: GetMessageOptions = {}, + ): Promise => { + if (!checkAuth()) { + return null; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + if (options.format) { + params.append("format", options.format); + } + if (options.metadataHeaders && options.metadataHeaders.length > 0) { + options.metadataHeaders.forEach((header) => + params.append("metadataHeaders", header), + ); + } + const queryString = params.toString(); + + const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (handleAuthError(response)) { + return null; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gmail API error: ${errorData.error?.message || response.statusText}`, + ); + } + + const result: GmailThread = await response.json(); + return result; + } catch (err) { + error.value = err instanceof Error ? err.message : "Failed to get thread"; + return null; + } finally { + isLoading.value = false; + } + }; + + /** + * Get multiple threads by their IDs (in parallel) + */ + const getThreads = async ( + threadIds: string[], + options: GetMessageOptions = {}, + ): Promise => { + if (!checkAuth()) { + return []; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + if (options.format) { + params.append("format", options.format); + } + if (options.metadataHeaders && options.metadataHeaders.length > 0) { + options.metadataHeaders.forEach((header) => + params.append("metadataHeaders", header), + ); + } + const queryString = params.toString(); + + // Fetch all threads in parallel + const promises = threadIds.map(async (threadId) => { + const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}${queryString ? `?${queryString}` : ""}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (response.status === 401) { + authStore.clearGoogleToken(); + needsReauth.value = true; + return null; + } + + if (!response.ok) { + console.error(`Failed to fetch thread ${threadId}`); + return null; + } + + return response.json() as Promise; + }); + + const results = await Promise.all(promises); + + if (needsReauth.value) { + error.value = "Google session expired. Please re-authenticate."; + return []; + } + + return results.filter((thread): thread is GmailThread => thread !== null); + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to get threads"; + return []; + } finally { + isLoading.value = false; + } + }; + + /** + * Get a specific message by ID + */ + const getMessage = async ( + messageId: string, + options: GetMessageOptions = {}, + ): Promise => { + if (!checkAuth()) { + return null; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + + if (options.format) { + params.append("format", options.format); + } + if (options.metadataHeaders && options.metadataHeaders.length > 0) { + options.metadataHeaders.forEach((header) => + params.append("metadataHeaders", header), + ); + } + + const queryString = params.toString(); + const url = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (handleAuthError(response)) { + return null; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gmail API error: ${errorData.error?.message || response.statusText}`, + ); + } + + const result: GmailMessage = await response.json(); + return result; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to get message"; + return null; + } finally { + isLoading.value = false; + } + }; + + /** + * Get multiple messages by their IDs (in parallel for better performance) + */ + const getMessages = async ( + messageIds: string[], + options: GetMessageOptions = {}, + ): Promise => { + if (!checkAuth()) { + return []; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + if (options.format) { + params.append("format", options.format); + } + if (options.metadataHeaders && options.metadataHeaders.length > 0) { + options.metadataHeaders.forEach((header) => + params.append("metadataHeaders", header), + ); + } + const queryString = params.toString(); + + // Fetch all messages in parallel + const promises = messageIds.map(async (messageId) => { + const url = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}${queryString ? `?${queryString}` : ""}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (response.status === 401) { + // Token is invalid or expired + authStore.clearGoogleToken(); + needsReauth.value = true; + return null; + } + + if (!response.ok) { + console.error(`Failed to fetch message ${messageId}`); + return null; + } + + return response.json() as Promise; + }); + + const results = await Promise.all(promises); + + if (needsReauth.value) { + error.value = "Google session expired. Please re-authenticate."; + return []; + } + + return results.filter((msg): msg is GmailMessage => msg !== null); + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to get messages"; + return []; + } finally { + isLoading.value = false; + } + }; + + /** + * Helper function to extract a header value from a message + */ + const getHeaderValue = ( + message: GmailMessage, + headerName: string, + ): string | undefined => { + return message.payload.headers.find( + (h) => h.name.toLowerCase() === headerName.toLowerCase(), + )?.value; + }; + + /** + * Helper function to decode base64url encoded content + */ + const decodeBase64Url = (data: string): string => { + // Replace base64url characters with base64 characters + const base64 = data.replace(/-/g, "+").replace(/_/g, "/"); + // Decode base64 to binary string + const binaryString = atob(base64); + // Convert binary string to UTF-8 + const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); + return new TextDecoder("utf-8").decode(bytes); + }; + + /** + * Helper function to get the message body content + */ + const getMessageBody = ( + message: GmailMessage, + preferHtml: boolean = true, + ): string | null => { + const findBody = ( + parts: GmailMessagePart[] | undefined, + mimeType: string, + ): string | null => { + if (!parts) return null; + + for (const part of parts) { + if (part.mimeType === mimeType && part.body.data) { + return decodeBase64Url(part.body.data); + } + if (part.parts) { + const found = findBody(part.parts, mimeType); + if (found) return found; + } + } + return null; + }; + + // Check if the message has a simple body + if (message.payload.body.data) { + return decodeBase64Url(message.payload.body.data); + } + + // Search for the preferred content type + if (preferHtml) { + const htmlBody = findBody(message.payload.parts, "text/html"); + if (htmlBody) return htmlBody; + } + + const textBody = findBody(message.payload.parts, "text/plain"); + if (textBody) return textBody; + + if (!preferHtml) { + const htmlBody = findBody(message.payload.parts, "text/html"); + if (htmlBody) return htmlBody; + } + + return null; + }; + + /** + * Get all messages in a Gmail thread by thread ID + */ + const getMessagesByThreadId = async ( + threadId: string, + options: GetMessageOptions = {}, + ): Promise => { + if (!checkAuth()) { + return []; + } + + isLoading.value = true; + error.value = null; + needsReauth.value = false; + + try { + const params = new URLSearchParams(); + if (options.format) { + params.append("format", options.format); + } + const queryString = params.toString(); + + // Use the threads.get endpoint to get all messages in a thread + const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${threadId}${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authStore.googleAccessToken}`, + }, + }); + + if (handleAuthError(response)) { + return []; + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Gmail API error: ${errorData.error?.message || response.statusText}`, + ); + } + + const result = await response.json(); + return result.messages || []; + } catch (err) { + error.value = + err instanceof Error ? err.message : "Failed to get thread messages"; + return []; + } finally { + isLoading.value = false; + } + }; + + return { + isLoading, + error, + needsReauth, + listMessages, + listThreads, + getMessage, + getMessages, + getThread, + getThreads, + getMessagesByThreadId, + getHeaderValue, + decodeBase64Url, + getMessageBody, + }; +}; diff --git a/frontend/src/composables/useGoogleAuth.ts b/frontend/src/composables/useGoogleAuth.ts index c1d57fc8..1b0999e6 100644 --- a/frontend/src/composables/useGoogleAuth.ts +++ b/frontend/src/composables/useGoogleAuth.ts @@ -49,14 +49,20 @@ export const useGoogleAuth = () => { try { const jwt = await generateJwt({ access_token: response.access_token }); authStore.setToken(jwt.data.deck_token); - // Store Google token with expiration (expires_in is in seconds, default 3600) + } catch (err) { + console.error("Failed to generate JWT:", err); + throw new Error("Failed to complete authentication"); + } + + // Store Google token with expiration (expires_in is in seconds, default 3600) + try { const expiresIn = response.expires_in ? Number(response.expires_in) : 3600; authStore.setGoogleToken(response.access_token, expiresIn); } catch (err) { - console.error("Failed to generate JWT:", err); - throw new Error("Failed to complete authentication"); + console.error("Failed to store Google token:", err); + throw new Error("Failed to store Google authentication token"); } }; From 7d3b911a15df34e78f5bbb4eaf8a8728ca504661 Mon Sep 17 00:00:00 2001 From: Lucas Pinto Date: Sun, 25 Jan 2026 20:17:00 +0000 Subject: [PATCH 22/22] build: bump reka --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 489ad0f1..30edec26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.536.0", "pinia": "^3.0.3", - "reka-ui": "^2.4.1", + "reka-ui": "^2.7.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", @@ -5313,9 +5313,9 @@ "license": "MIT" }, "node_modules/reka-ui": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.4.1.tgz", - "integrity": "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz", + "integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/frontend/package.json b/frontend/package.json index 8ce111ac..181f139b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,7 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.536.0", "pinia": "^3.0.3", - "reka-ui": "^2.4.1", + "reka-ui": "^2.7.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6",