From 10d9d29edbbc99acd4ceed8480408e9ed91e0e51 Mon Sep 17 00:00:00 2001 From: Adnan Maolood Date: Mon, 2 May 2022 18:19:20 -0400 Subject: [PATCH] todosrht-lmtp: Use GraphQL for comment submission Use GraphQL to submit comments from incoming emails so that GraphQL-native webhooks are delivered. Also use comments to document internal GraphQL mutations instead of docstrings. References: https://todo.sr.ht/~sircmpwn/sr.ht/315 --- api/graph/schema.graphqls | 31 ++++- api/graph/schema.resolvers.go | 240 +++++++++++++++++++++++++++++++++- todosrht-lmtp | 82 +++++------- 3 files changed, 299 insertions(+), 54 deletions(-) diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index b599e27..68da0d3 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -735,14 +735,31 @@ input SubmitTicketInput { externalUrl: String } -"For internal use only." -input SubmitEmailInput { +# For internal use only. +input SubmitTicketEmailInput { subject: String! body: String senderId: Int! messageId: String! } +# For internal use only. +enum EmailCmd { + RESOLVE + REOPEN + LABEL + UNLABEL +} + +# For internal use only. +input SubmitCommentEmailInput { + text: String! + senderId: Int! + cmd: EmailCmd + resolution: TicketResolution + labelIds: [Int!] +} + """ You may omit any fields to leave them unchanged. To remove the ticket body, set it to null. @@ -887,9 +904,13 @@ type Mutation { submitTicket(trackerId: Int!, input: SubmitTicketInput!): Ticket! @access(scope: TICKETS, kind: RW) - "Creates a new ticket from an incoming email. (For internal use only)" - submitEmail(trackerId: Int!, - input: SubmitEmailInput!): Ticket! @internal + # Creates a new ticket from an incoming email. (For internal use only) + submitTicketEmail(trackerId: Int!, + input: SubmitTicketEmailInput!): Ticket! @internal + + # Creates a new comment from an incoming email. (For internal use only) + submitCommentEmail(trackerId: Int!, ticketId: Int!, + input: SubmitCommentEmailInput!): Event! @internal "Updates a ticket's subject or body" updateTicket(trackerId: Int!, ticketId: Int!, diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 7237187..bc9e95e 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -835,7 +835,7 @@ func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, inpu return &ticket, nil } -func (r *mutationResolver) SubmitEmail(ctx context.Context, trackerID int, input model.SubmitEmailInput) (*model.Ticket, error) { +func (r *mutationResolver) SubmitTicketEmail(ctx context.Context, trackerID int, input model.SubmitTicketEmailInput) (*model.Ticket, error) { validation := valid.New(ctx) validation.Expect(len(input.Subject) <= 2048, "Ticket subject must be fewer than to 2049 characters."). @@ -946,6 +946,244 @@ func (r *mutationResolver) SubmitEmail(ctx context.Context, trackerID int, input return &ticket, nil } +func (r *mutationResolver) SubmitCommentEmail(ctx context.Context, trackerID int, ticketID int, input model.SubmitCommentEmailInput) (*model.Event, error) { + tracker, err := loaders.ForContext(ctx).TrackersByID.Load(trackerID) + if err != nil { + return nil, err + } else if tracker == nil { + return nil, fmt.Errorf("No tracker by ID %d found for this user", trackerID) + } + if !tracker.CanComment() { + return nil, fmt.Errorf("Access denied") + } + + ticket, err := loaders.ForContext(ctx). + TicketsByTrackerID.Load([2]int{trackerID, ticketID}) + if err != nil { + return nil, err + } else if ticket == nil { + return nil, errors.New("No such ticket") + } + + owner, err := loaders.ForContext(ctx).UsersByID.Load(tracker.OwnerID) + if err != nil { + panic(err) + } + + updateTicket := sq.Update("ticket"). + PlaceholderFormat(sq.Dollar) + insertEvent := sq.Insert("event"). + PlaceholderFormat(sq.Dollar). + Columns("created", "event_type", + "ticket_id", "participant_id", "comment_id", + "old_status", "new_status", "old_resolution", "new_resolution") + + var ( + oldStatus *int + _oldStatus int + newStatus *int + _newStatus int + oldResolution *int + _oldResolution int + newResolution *int + _newResolution int + eventType uint = model.EVENT_COMMENT + ) + + if input.Cmd != nil { + switch *input.Cmd { + case model.EmailCmdResolve: + if input.Resolution == nil { + return nil, errors.New("Resolution is required when cmd is RESOLVE") + } + eventType |= model.EVENT_STATUS_CHANGE + oldStatus = &_oldStatus + newStatus = &_newStatus + oldResolution = &_oldResolution + newResolution = &_newResolution + *oldStatus = ticket.Status().ToInt() + *oldResolution = ticket.Resolution().ToInt() + *newStatus = model.STATUS_RESOLVED + *newResolution = input.Resolution.ToInt() + updateTicket = updateTicket. + Set("status", *newStatus). + Set("resolution", *newResolution) + case model.EmailCmdReopen: + eventType |= model.EVENT_STATUS_CHANGE + oldStatus = &_oldStatus + newStatus = &_newStatus + oldResolution = &_oldResolution + newResolution = &_newResolution + *oldStatus = ticket.Status().ToInt() + *oldResolution = ticket.Resolution().ToInt() + *newStatus = model.STATUS_REPORTED + *newResolution = model.RESOLVED_UNRESOLVED + updateTicket = updateTicket. + Set("status", *newStatus). + Set("resolution", *newResolution) + } + } + + var event model.Event + columns := database.Columns(ctx, &event) + if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { + _, err := updateTicket. + Set(`updated`, sq.Expr(`now() at time zone 'utc'`)). + Set(`comment_count`, sq.Expr(`comment_count + 1`)). + Where(`ticket.id = ?`, ticket.PKID). + RunWith(tx). + ExecContext(ctx) + if err != nil { + return err + } + + row := tx.QueryRowContext(ctx, ` + INSERT INTO ticket_comment ( + created, updated, submitter_id, ticket_id, text + ) VALUES ( + NOW() at time zone 'utc', + NOW() at time zone 'utc', + $1, $2, $3 + ) RETURNING id; + `, input.SenderID, ticket.PKID, input.Text) + + var commentID int + if err := row.Scan(&commentID); err != nil { + return err + } + + if input.Cmd != nil { + switch *input.Cmd { + case model.EmailCmdLabel: + for _, labelID := range input.LabelIds { + var event model.Event + insertEvent := sq.Insert("event"). + PlaceholderFormat(sq.Dollar). + Columns("created", "event_type", "ticket_id", + "participant_id", "label_id"). + Values(sq.Expr("now() at time zone 'utc'"), + model.EVENT_LABEL_ADDED, ticket.PKID, input.SenderID, labelID) + + _, err := tx.ExecContext(ctx, ` + INSERT INTO ticket_label ( + created, ticket_id, label_id, user_id + ) VALUES ( + NOW() at time zone 'utc', + $1, $2, + (SELECT user_id FROM participant WHERE id = $3) + )`, ticket.PKID, labelID, input.SenderID) + if err, ok := err.(*pq.Error); ok && + err.Code == "23505" && // unique_violation + err.Constraint == "idx_label_ticket_unique" { + return errors.New("This label is already assigned to this ticket.") + } else if err != nil { + return err + } + + row := insertEvent. + Suffix(`RETURNING ` + strings.Join(columns, ", ")). + RunWith(tx). + QueryRowContext(ctx) + if err := row.Scan(database.Scan(ctx, &event)...); err != nil { + return err + } + + builder := NewEventBuilder(ctx, tx, input.SenderID, model.EVENT_LABEL_ADDED). + WithTicket(tracker, ticket) + builder.InsertNotifications(event.ID, nil) + _, err = tx.ExecContext(ctx, `DROP TABLE event_participant;`) + if err != nil { + return err + } + } + case model.EmailCmdUnlabel: + for _, labelID := range input.LabelIds { + var event model.Event + insertEvent := sq.Insert("event"). + PlaceholderFormat(sq.Dollar). + Columns("created", "event_type", "ticket_id", + "participant_id", "label_id"). + Values(sq.Expr("now() at time zone 'utc'"), + model.EVENT_LABEL_REMOVED, ticket.PKID, input.SenderID, labelID) + + { + row := tx.QueryRowContext(ctx, ` + DELETE FROM ticket_label + WHERE ticket_id = $1 AND label_id = $2 + RETURNING 1`, + ticket.PKID, labelID) + var success bool + if err := row.Scan(&success); err != nil { + if err == sql.ErrNoRows { + return errors.New("This label is not assigned to this ticket.") + } + return err + } + } + + row := insertEvent. + Suffix(`RETURNING ` + strings.Join(columns, ", ")). + RunWith(tx). + QueryRowContext(ctx) + if err := row.Scan(database.Scan(ctx, &event)...); err != nil { + return err + } + + builder := NewEventBuilder(ctx, tx, input.SenderID, model.EVENT_LABEL_REMOVED). + WithTicket(tracker, ticket) + builder.InsertNotifications(event.ID, nil) + _, err = tx.ExecContext(ctx, `DROP TABLE event_participant;`) + if err != nil { + return err + } + } + } + } + + builder := NewEventBuilder(ctx, tx, input.SenderID, eventType). + WithTicket(tracker, ticket) + + mentions := ScanMentions(ctx, tracker, ticket, input.Text) + builder.AddMentions(&mentions) + builder.InsertSubscriptions() + + eventRow := insertEvent.Values(sq.Expr("now() at time zone 'utc'"), + eventType, ticket.PKID, input.SenderID, commentID, + oldStatus, newStatus, oldResolution, newResolution). + Suffix(`RETURNING ` + strings.Join(columns, ", ")). + RunWith(tx). + QueryRowContext(ctx) + if err := eventRow.Scan(database.Scan(ctx, &event)...); err != nil { + return err + } + builder.InsertNotifications(event.ID, &commentID) + + conf := config.ForContext(ctx) + origin := config.GetOrigin(conf, "todo.sr.ht", true) + details := SubmitCommentDetails{ + Root: origin, + TicketURL: fmt.Sprintf("/%s/%s/%d", + owner.CanonicalName(), tracker.Name, ticket.ID), + EventID: event.ID, + Comment: input.Text, + StatusUpdated: newStatus != nil, + } + if details.StatusUpdated { + details.Status = model.TicketStatusFromInt(*newStatus).String() + details.Resolution = model.TicketResolutionFromInt(*newResolution).String() + } + subject := fmt.Sprintf("Re: %s: %s", ticket.Ref(), ticket.Subject) + builder.SendEmails(subject, submitCommentTemplate, &details) + return nil + }); err != nil { + return nil, err + } + webhooks.DeliverLegacyEventCreate(ctx, tracker, ticket, &event) + webhooks.DeliverTrackerEventCreated(ctx, ticket.TrackerID, &event) + webhooks.DeliverTicketEventCreated(ctx, ticket.PKID, &event) + return &event, nil +} + func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, ticketID int, input map[string]interface{}) (*model.Ticket, error) { if _, ok := input["import"]; ok { panic(fmt.Errorf("not implemented")) // TODO diff --git a/todosrht-lmtp b/todosrht-lmtp index 210bb89..f468647 100755 --- a/todosrht-lmtp +++ b/todosrht-lmtp @@ -13,7 +13,6 @@ from todosrht.access import get_tracker, get_ticket from todosrht.types import TicketAccess, TicketResolution, Tracker, Ticket, User from todosrht.types import Label, TicketLabel, TicketSubscription, Event, EventType, ParticipantType from todosrht.tickets import add_comment, get_participant_for_email -from todosrht.webhooks import UserWebhook, TrackerWebhook, TicketWebhook from srht.validation import Validation import asyncio import email @@ -150,8 +149,8 @@ class MailHandler: } resp = exec_gql("todo.sr.ht", """ - mutation SubmitEmail($trackerId: Int!, $input: SubmitEmailInput!) { - submitEmail(trackerId: $trackerId, input: $input) { + mutation SubmitTicketEmail($trackerId: Int!, $input: SubmitTicketEmailInput!) { + submitTicketEmail(trackerId: $trackerId, input: $input) { id } } @@ -161,11 +160,8 @@ class MailHandler: print("Rejecting email due to validation errors") return "550 " + ", ".join([e.message for e in valid.errors]) - ticket, _ = get_ticket(tracker, resp["submitEmail"]["id"], user=sender.user) + ticket, _ = get_ticket(tracker, resp["submitTicketEmail"]["id"], user=sender.user) - TrackerWebhook.deliver(TrackerWebhook.Events.ticket_create, - ticket.to_dict(), - TrackerWebhook.Subscription.tracker_id == tracker.id) print(f"Created ticket {ticket.ref()}") return "250 Message accepted for delivery" @@ -173,22 +169,34 @@ class MailHandler: required_access = TicketAccess.comment last_line = body.splitlines()[-1] - resolution = None - resolve = reopen = False + # Need to commit in case a new participant was created + db.session.commit() + + valid = Validation({}) + + input = { + "text": body, + "senderId": sender.id, + } + cmds = ["!resolve", "!resolved", "!reopen"] if sender.participant_type == ParticipantType.user: # TODO: This should be possible via ACLs later cmds += ["!assign", "!label", "!unlabel"] if any(last_line.startswith(cmd) for cmd in cmds): cmd = shlex.split(last_line) - body = body[:-len(last_line)-1].rstrip() + input["text"] = body.rstrip()[:-len(last_line)-1].rstrip() required_access = TicketAccess.triage if cmd[0] in ["!resolve", "!resolved"] and len(cmd) == 2: - resolve = True - resolution = TicketResolution[cmd[1].lower()] + input["cmd"] = "RESOLVE" + input["resolution"] = cmd[1].upper() elif cmd[0] == "!reopen": - reopen = True + input["cmd"] = "REOPEN" elif cmd[0] == "!label" or cmd[0] == "!unlabel": + if cmd[0] == "!label": + input["cmd"] = "LABEL" + else: + input["cmd"] = "UNLABEL" labels = Label.query.filter( Label.name.in_(cmd[1:]), Label.tracker_id == ticket.tracker_id).all() @@ -199,33 +207,7 @@ class MailHandler: print(f"Rejected, {sender.name} has insufficient " + f"permissions (have {access}, want triage)") return "550 You do not have permission to triage on this tracker." - for label in labels: - ticket_label = (TicketLabel.query - .filter(TicketLabel.label_id == label.id) - .filter(TicketLabel.ticket_id == ticket.id)).first() - event = Event() - event.participant_id = sender.id - event.ticket_id = ticket.id - event.label_id = label.id - if not ticket_label and cmd[0] == "!label": - # TODO: only supported for user participants - ticket_label = TicketLabel() - ticket_label.ticket_id = ticket.id - ticket_label.label_id = label.id - ticket_label.user_id = sender.id - db.session.add(ticket_label) - event.event_type = EventType.label_added - elif ticket_label and cmd[0] == "!unlabel": - db.session.delete(ticket_label) - event.event_type = EventType.label_removed - db.session.add(event) - db.session.commit() - TicketWebhook.deliver(TicketWebhook.Events.event_create, - event.to_dict(), - TicketWebhook.Subscription.ticket_id == ticket.id) - TrackerWebhook.deliver(TrackerWebhook.Events.event_create, - event.to_dict(), - TrackerWebhook.Subscription.tracker_id == ticket.tracker_id) + input["labelIds"] = [label.id for label in labels] # TODO: Remaining commands if not required_access in access: @@ -237,14 +219,18 @@ class MailHandler: print("Rejected, invalid comment length") return "550 Comment must be between 3 and 16384 characters." - event = add_comment(sender, ticket, text=body, - resolution=resolution, resolve=resolve, reopen=reopen, from_email=True) - TicketWebhook.deliver(TicketWebhook.Events.event_create, - event.to_dict(), - TicketWebhook.Subscription.ticket_id == ticket.id) - TrackerWebhook.deliver(TrackerWebhook.Events.event_create, - event.to_dict(), - TrackerWebhook.Subscription.tracker_id == ticket.tracker_id) + resp = exec_gql("todo.sr.ht", """ + mutation SubmitCommentEmail($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentEmailInput!) { + submitCommentEmail(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + id + } + } + """, user=ticket.tracker.owner, valid=valid, trackerId=ticket.tracker_id, ticketId=ticket.scoped_id, input=input) + + if not valid.ok: + print("Rejecting email due to validation errors") + return "550 " + ", ".join([e.message for e in valid.errors]) + print(f"Added comment to {ticket.ref()}") return "250 Message accepted for delivery"