diff --git a/db/setup.go b/db/setup.go index d792298..5aa049b 100644 --- a/db/setup.go +++ b/db/setup.go @@ -12,15 +12,14 @@ var ( // Indexes of tables in the database var tableIndexes = map[string][]string{ - "tokens": []string{"user", "user_id"}, "accounts": []string{"name"}, - "emails": []string{"user_id"}, - "drafts": []string{"user_id"}, - "contacts": []string{}, - "threads": []string{"user_id"}, - "labels": []string{}, - "keys": []string{}, - "reservations": []string{}, + "contacts": []string{"owner"}, + "emails": []string{"owner", "label_ids"}, + "keys": []string{"owner", "key_id"}, + "labels": []string{"owner"}, + "reservations": []string{"email", "name"}, + "threads": []string{"owner"}, + "tokens": []string{"owner"}, "attachments": []string{"owner"}, } diff --git a/db/table_emails.go b/db/table_emails.go index 4e0134e..c7430bb 100644 --- a/db/table_emails.go +++ b/db/table_emails.go @@ -52,11 +52,31 @@ func (e *EmailsTable) List( sort []string, offset int, limit int, + label string, ) ([]*models.Email, error) { - // Filter by owner's ID - term := e.GetTable().Filter(map[string]interface{}{ - "owner": owner, - }) + + var term gorethink.Term + + if owner != "" && label != "" { + term = e.GetTable().Filter(func(row gorethink.Term) gorethink.Term { + return gorethink.And( + row.Field("owner").Eq(owner), + row.Field("label_ids").Contains(label), + ) + }) + } + + if owner != "" && label == "" { + term = e.GetTable().Filter(map[string]interface{}{ + "owner": owner, + }) + } + + if owner == "" && label != "" { + term = e.GetTable().Filter(func(row gorethink.Term) gorethink.Term { + return row.Field("label_ids").Contains(label) + }) + } // If sort array has contents, parse them and add to the term if sort != nil && len(sort) > 0 { @@ -102,3 +122,60 @@ func (e *EmailsTable) List( return resp, nil } + +func (e *EmailsTable) GetByLabel(label string) ([]*models.Email, error) { + var result []*models.Email + + cursor, err := e.GetTable().Filter(func(row gorethink.Term) gorethink.Term { + return row.Field("label_ids").Contains(label) + }).GetAll().Run(e.GetSession()) + if err != nil { + return nil, err + } + + err = cursor.All(&result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (e *EmailsTable) CountByLabel(label string) (int, error) { + var result int + + cursor, err := e.GetTable().Filter(func(row gorethink.Term) gorethink.Term { + return row.Field("label_ids").Contains(label) + }).Count().Run(e.GetSession()) + if err != nil { + return 0, err + } + + err = cursor.One(&result) + if err != nil { + return 0, err + } + + return result, nil +} + +func (e *EmailsTable) CountByLabelUnread(label string) (int, error) { + var result int + + cursor, err := e.GetTable().Filter(func(row gorethink.Term) gorethink.Term { + return gorethink.And( + row.Field("label_ids").Contains(label), + row.Field("is_read").Eq(false), + ) + }).Count().Run(e.GetSession()) + if err != nil { + return 0, err + } + + err = cursor.One(&result) + if err != nil { + return 0, err + } + + return result, nil +} diff --git a/db/table_labels.go b/db/table_labels.go new file mode 100644 index 0000000..ff518f5 --- /dev/null +++ b/db/table_labels.go @@ -0,0 +1,159 @@ +package db + +import ( + "time" + + "github.com/dancannon/gorethink" + + "github.com/lavab/api/cache" + "github.com/lavab/api/models" +) + +type LabelsTable struct { + RethinkCRUD + Emails *EmailsTable + Cache cache.Cache + Expires time.Duration +} + +func (l *LabelsTable) Insert(data interface{}) error { + if err := l.RethinkCRUD.Insert(data); err != nil { + return err + } + + label, ok := data.(*models.Token) + if !ok { + return nil + } + + return l.Cache.Set(l.RethinkCRUD.GetTableName()+":"+label.ID, label, l.Expires) +} + +// Update clears all updated keys +func (l *LabelsTable) Update(data interface{}) error { + if err := l.RethinkCRUD.Update(data); err != nil { + return err + } + + return l.Cache.DeleteMask(l.RethinkCRUD.GetTableName() + ":*") +} + +// UpdateID updates the specified label and updates cache +func (l *LabelsTable) UpdateID(id string, data interface{}) error { + if err := l.RethinkCRUD.UpdateID(id, data); err != nil { + return err + } + + label, err := l.GetLabel(id) + if err != nil { + return err + } + + return l.Cache.Set(l.RethinkCRUD.GetTableName()+":"+id, label, l.Expires) +} + +// Delete removes from db and cache using filter +func (l *LabelsTable) Delete(cond interface{}) error { + result, err := l.GetTable().Filter(cond).Delete(gorethink.DeleteOpts{ + ReturnChanges: true, + }).RunWrite(l.GetSession()) + if err != nil { + return err + } + + var ids []interface{} + for _, change := range result.Changes { + ids = append(ids, l.RethinkCRUD.GetTableName()+":"+change.OldValue.(map[string]interface{})["id"].(string)) + } + + return l.Cache.DeleteMulti(ids...) +} + +// DeleteID removes from db and cache using id query +func (l *LabelsTable) DeleteID(id string) error { + label, err := l.GetLabel(id) + if err != nil { + return err + } + + if err := l.RethinkCRUD.DeleteID(l.RethinkCRUD.GetTableName() + ":" + id); err != nil { + return err + } + + l.Cache.Delete(l.RethinkCRUD.GetTableName() + ":" + id) + l.Cache.Delete(l.RethinkCRUD.GetTableName() + ":owner:" + label.Owner) + + return nil +} + +func (l *LabelsTable) GetLabel(id string) (*models.Label, error) { + var result models.Label + + if err := l.Cache.Get(l.RethinkCRUD.GetTableName()+":"+id, &result); err == nil { + return &result, nil + } + + if err := l.FindFetchOne(id, &result); err != nil { + return nil, err + } + + totalCount, err := l.Emails.CountByLabel(result.ID) + if err != nil { + return nil, err + } + + result.EmailsTotal = totalCount + + unreadCount, err := l.Emails.CountByLabelUnread(result.ID) + if err != nil { + return nil, err + } + + result.EmailsUnread = unreadCount + + err = l.Cache.Set(l.RethinkCRUD.GetTableName()+":"+id, result, l.Expires) + if err != nil { + return nil, err + } + + return &result, nil +} + +// GetOwnedBy returns all labels owned by id +func (l *LabelsTable) GetOwnedBy(id string) ([]*models.Label, error) { + var result []*models.Label + + if err := l.Cache.Get(l.RethinkCRUD.GetTableName()+":owner:"+id, &result); err == nil { + return result, nil + } + + err := l.WhereAndFetch(map[string]interface{}{ + "owner": id, + }, &result) + if err != nil { + return nil, err + } + + for i := range result { + totalCount, err := l.Emails.CountByLabel(result[i].ID) + if err != nil { + return nil, err + } + + result[i].EmailsTotal = totalCount + + unreadCount, err := l.Emails.CountByLabelUnread(result[i].ID) + if err != nil { + return nil, err + } + + result[i].EmailsTotal = unreadCount + } + + err = l.Cache.Set(l.RethinkCRUD.GetTableName()+":owner:"+id, result, l.Expires) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/db/table_tokens.go b/db/table_tokens.go index 5703067..468c491 100644 --- a/db/table_tokens.go +++ b/db/table_tokens.go @@ -81,7 +81,7 @@ func (t *TokensTable) DeleteID(id string) error { // FindFetchOne tries cache and then tries using DefaultCRUD's fetch operation func (t *TokensTable) FindFetchOne(id string, value interface{}) error { - if err := t.Cache.Get(id, value); err == nil { + if err := t.Cache.Get(t.RethinkCRUD.GetTableName()+":"+id, value); err == nil { return nil } diff --git a/env/env.go b/env/env.go index ec7444a..a1fa521 100644 --- a/env/env.go +++ b/env/env.go @@ -31,6 +31,8 @@ var ( Reservations *db.ReservationsTable // Emails is the global instance of EmailsTable Emails *db.EmailsTable + // Labels is the global instance of LabelsTable + Labels *db.LabelsTable // Factors contains all currently registered factors Factors map[string]factor.Factor // NATS is the encoded connection to the NATS queue diff --git a/models/email.go b/models/email.go index 03bd9e3..823704a 100644 --- a/models/email.go +++ b/models/email.go @@ -31,4 +31,6 @@ type Email struct { ThreadID string `json:"thread_id" gorethink:"thread_id"` Status string `json:"status" gorethink:"status"` + + IsRead string `json:"is_read" gorethink:"is_read"` } diff --git a/models/label.go b/models/label.go index b36320a..6daa02c 100644 --- a/models/label.go +++ b/models/label.go @@ -16,11 +16,6 @@ type Label struct { // Examples: inbox, trash, spam, drafts, starred, etc. Builtin bool `json:"builtin" gorethink:"builtin"` - // EmailsUnread is the number of unread emails that have a particular label applied. - // Storing this for each label eliminates the need of db lookups for this commonly needed information. - EmailsUnread int `json:"emails_unread" gorethink:"emails_unread"` - - // EmailsTotal is the number of emails that have a particular label applied. - // Storing this for each label eliminates the need of db lookups for this commonly needed information. - EmailsTotal int `json:"emails_total" gorethink:"emails_total"` + EmailsUnread int `json:"emails_unread" gorethink:"-"` + EmailsTotal int `json:"emails_total" gorethink:"-"` } diff --git a/models/token.go b/models/token.go index 90c415a..c1c93f3 100644 --- a/models/token.go +++ b/models/token.go @@ -7,6 +7,8 @@ type Token struct { // Type describes the token's purpose: auth, invite, confirm, upgrade. Type string `json:"type" gorethink:"type"` + + Email string `json:"email,omitempty" gorethink:"email"` } // MakeToken creates a generic token. diff --git a/routes/accounts.go b/routes/accounts.go index bf3ede9..8134cc4 100644 --- a/routes/accounts.go +++ b/routes/accounts.go @@ -183,6 +183,13 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } + // Both username and password are filled, so we can create a new account. + account := &models.Account{ + Resource: models.MakeResource("", input.Username), + Type: "beta", + AltEmail: input.AltEmail, + } + // Check "invited" for token validity if requestType == "invited" { // Fetch the token from the database @@ -216,17 +223,12 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { }) return } + + account.AltEmail = token.Email } // TODO: sanitize user name (i.e. remove caps, periods) - // Both username and password are filled, so we can create a new account. - account := &models.Account{ - Resource: models.MakeResource("", input.Username), - Type: "beta", - AltEmail: input.AltEmail, - } - // Set the password err = account.SetPassword(input.Password) if err != nil { @@ -264,6 +266,41 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } + // Create labels + err = env.Labels.Insert([]*models.Label{ + &models.Label{ + Resource: models.MakeResource(account.ID, "Inbox"), + Builtin: true, + }, + &models.Label{ + Resource: models.MakeResource(account.ID, "Sent"), + Builtin: true, + }, + &models.Label{ + Resource: models.MakeResource(account.ID, "Trash"), + Builtin: true, + }, + &models.Label{ + Resource: models.MakeResource(account.ID, "Spam"), + Builtin: true, + }, + &models.Label{ + Resource: models.MakeResource(account.ID, "Starred"), + Builtin: true, + }, + }) + if err != nil { + utils.JSONResponse(w, 500, &AccountsCreateResponse{ + Success: false, + Message: "Internal server error - AC/CR/03", + }) + + env.Log.WithFields(logrus.Fields{ + "error": err.Error(), + }).Error("Could not insert labels into the database") + return + } + // Send the email if classic and return a response if requestType == "classic" { // TODO: Send emails diff --git a/routes/contacts.go b/routes/contacts.go index 6118540..93e9364 100644 --- a/routes/contacts.go +++ b/routes/contacts.go @@ -37,7 +37,7 @@ func ContactsList(c web.C, w http.ResponseWriter, r *http.Request) { return } - utils.JSONResponse(w, 501, &ContactsListResponse{ + utils.JSONResponse(w, 200, &ContactsListResponse{ Success: true, Contacts: &contacts, }) diff --git a/routes/emails.go b/routes/emails.go index 8f0222b..9d46926 100644 --- a/routes/emails.go +++ b/routes/emails.go @@ -36,6 +36,7 @@ func EmailsList(c web.C, w http.ResponseWriter, r *http.Request) { sortRaw = query.Get("sort") offsetRaw = query.Get("offset") limitRaw = query.Get("limit") + label = query.Get("label") sort []string offset int limit int @@ -80,7 +81,7 @@ func EmailsList(c web.C, w http.ResponseWriter, r *http.Request) { } // Get contacts from the database - emails, err := env.Emails.List(session.Owner, sort, offset, limit) + emails, err := env.Emails.List(session.Owner, sort, offset, limit, label) if err != nil { env.Log.WithFields(logrus.Fields{ "error": err, diff --git a/routes/labels.go b/routes/labels.go index 45d0e97..01518ae 100644 --- a/routes/labels.go +++ b/routes/labels.go @@ -3,62 +3,219 @@ package routes import ( "net/http" + "github.com/Sirupsen/logrus" + "github.com/lavab/api/env" + "github.com/lavab/api/models" "github.com/lavab/api/utils" + "github.com/zenazn/goji/web" ) // LabelsListResponse contains the result of the LabelsList request. type LabelsListResponse struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Labels *[]*models.Label `json:"labels,omitempty"` } -// LabelsList does *something* - TODO -func LabelsList(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &LabelsListResponse{ - Success: false, - Message: "Sorry, not implemented yet", +// LabelsList fetches all labels +func LabelsList(c web.C, w http.ResponseWriter, r *http.Request) { + session := c.Env["token"].(*models.Token) + + labels, err := env.Labels.GetOwnedBy(session.Owner) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to fetch labels") + + utils.JSONResponse(w, 500, &LabelsListResponse{ + Success: false, + Message: "Internal error (code LA/LI/01)", + }) + return + } + + utils.JSONResponse(w, 200, &LabelsListResponse{ + Success: true, + Labels: &labels, }) } +type LabelsCreateRequest struct { + Name string `json:"name"` +} + // LabelsCreateResponse contains the result of the LabelsCreate request. type LabelsCreateResponse struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message"` + Label *models.Label `json:"label,omitempty"` } // LabelsCreate does *something* - TODO -func LabelsCreate(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &LabelsCreateResponse{ - Success: false, - Message: "Sorry, not implemented yet", +func LabelsCreate(c web.C, w http.ResponseWriter, r *http.Request) { + // Decode the request + var input LabelsCreateRequest + err := utils.ParseRequest(r, &input) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to decode a request") + + utils.JSONResponse(w, 400, &LabelsCreateResponse{ + Success: false, + Message: "Invalid input format", + }) + return + } + + // Fetch the current session from the middleware + session := c.Env["token"].(*models.Token) + + // Ensure that the input data isn't empty + if input.Name == "" { + utils.JSONResponse(w, 400, &LabelsCreateResponse{ + Success: false, + Message: "Invalid request", + }) + return + } + + // Create a new label struct + label := &models.Label{ + Resource: models.MakeResource(session.Owner, input.Name), + Builtin: false, + } + + // Insert the label into the database + if err := env.Contacts.Insert(label); err != nil { + utils.JSONResponse(w, 500, &LabelsCreateResponse{ + Success: false, + Message: "internal server error - LA/CR/01", + }) + + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Could not insert a label into the database") + return + } + + utils.JSONResponse(w, 201, &LabelsCreateResponse{ + Success: true, + Label: label, }) } // LabelsGetResponse contains the result of the LabelsGet request. type LabelsGetResponse struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Label *models.Label `json:"label,omitempty"` } // LabelsGet does *something* - TODO -func LabelsGet(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &LabelsGetResponse{ - Success: false, - Message: "Sorry, not implemented yet", +func LabelsGet(c web.C, w http.ResponseWriter, r *http.Request) { + // Get the label from the database + label, err := env.Labels.GetLabel(c.URLParams["id"]) + if err != nil { + utils.JSONResponse(w, 404, &LabelsGetResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + // Fetch the current session from the middleware + session := c.Env["token"].(*models.Token) + + // Check for ownership + if label.Owner != session.Owner { + utils.JSONResponse(w, 404, &LabelsGetResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + // Write the label to the response + utils.JSONResponse(w, 200, &LabelsGetResponse{ + Success: true, + Label: label, }) } +type LabelsUpdateRequest struct { + Name string `json:"name"` +} + // LabelsUpdateResponse contains the result of the LabelsUpdate request. type LabelsUpdateResponse struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Label *models.Label `json:"label,omitempty"` } // LabelsUpdate does *something* - TODO -func LabelsUpdate(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &LabelsUpdateResponse{ - Success: false, - Message: "Sorry, not implemented yet", +func LabelsUpdate(c web.C, w http.ResponseWriter, r *http.Request) { + // Decode the request + var input LabelsUpdateRequest + err := utils.ParseRequest(r, &input) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to decode a request") + + utils.JSONResponse(w, 400, &LabelsUpdateResponse{ + Success: false, + Message: "Invalid input format", + }) + return + } + + // Get the label from the database + label, err := env.Labels.GetLabel(c.URLParams["id"]) + if err != nil { + utils.JSONResponse(w, 404, &LabelsUpdateResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + // Fetch the current session from the middleware + session := c.Env["token"].(*models.Token) + + // Check for ownership + if label.Owner != session.Owner { + utils.JSONResponse(w, 404, &LabelsUpdateResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + if input.Name != "" { + label.Name = input.Name + } + + // Perform the update + err = env.Labels.UpdateID(c.URLParams["id"], input) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + "id": c.URLParams["id"], + }).Error("Unable to update a contact") + + utils.JSONResponse(w, 500, &LabelsUpdateResponse{ + Success: false, + Message: "Internal error (code LA/UP/01)", + }) + return + } + + // Write the contact to the response + utils.JSONResponse(w, 200, &LabelsUpdateResponse{ + Success: true, + Label: label, }) } @@ -69,9 +226,46 @@ type LabelsDeleteResponse struct { } // LabelsDelete does *something* - TODO -func LabelsDelete(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &LabelsDeleteResponse{ - Success: false, - Message: "Sorry, not implemented yet", +func LabelsDelete(c web.C, w http.ResponseWriter, r *http.Request) { + // Get the label from the database + label, err := env.Labels.GetLabel(c.URLParams["id"]) + if err != nil { + utils.JSONResponse(w, 404, &LabelsDeleteResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + // Fetch the current session from the middleware + session := c.Env["token"].(*models.Token) + + // Check for ownership + if label.Owner != session.Owner { + utils.JSONResponse(w, 404, &LabelsDeleteResponse{ + Success: false, + Message: "Label not found", + }) + return + } + + // Perform the deletion + err = env.Labels.DeleteID(c.URLParams["id"]) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + "id": c.URLParams["id"], + }).Error("Unable to delete a label") + + utils.JSONResponse(w, 500, &LabelsDeleteResponse{ + Success: false, + Message: "Internal error (code LA/DE/01)", + }) + return + } + + utils.JSONResponse(w, 200, &LabelsDeleteResponse{ + Success: true, + Message: "Label successfully removed", }) } diff --git a/setup/setup.go b/setup/setup.go index bcf241f..b3d1a7f 100644 --- a/setup/setup.go +++ b/setup/setup.go @@ -154,6 +154,15 @@ func PrepareMux(flags *env.Flags) *web.Mux { "emails", ), } + env.Labels = &db.LabelsTable{ + RethinkCRUD: db.NewCRUDTable( + rethinkSession, + rethinkOpts.Database, + "labels", + ), + Emails: env.Emails, + Cache: redis, + } // NATS queue connection nc, err := nats.Connect(flags.NATSAddress)