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
This commit is contained in:
Adnan Maolood 2022-05-02 18:19:20 -04:00 committed by Drew DeVault
parent 75a118a77d
commit 10d9d29edb
3 changed files with 299 additions and 54 deletions

View File

@ -735,14 +735,31 @@ input SubmitTicketInput {
externalUrl: String externalUrl: String
} }
"For internal use only." # For internal use only.
input SubmitEmailInput { input SubmitTicketEmailInput {
subject: String! subject: String!
body: String body: String
senderId: Int! senderId: Int!
messageId: String! 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, You may omit any fields to leave them unchanged. To remove the ticket body,
set it to null. set it to null.
@ -887,9 +904,13 @@ type Mutation {
submitTicket(trackerId: Int!, submitTicket(trackerId: Int!,
input: SubmitTicketInput!): Ticket! @access(scope: TICKETS, kind: RW) input: SubmitTicketInput!): Ticket! @access(scope: TICKETS, kind: RW)
"Creates a new ticket from an incoming email. (For internal use only)" # Creates a new ticket from an incoming email. (For internal use only)
submitEmail(trackerId: Int!, submitTicketEmail(trackerId: Int!,
input: SubmitEmailInput!): Ticket! @internal 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" "Updates a ticket's subject or body"
updateTicket(trackerId: Int!, ticketId: Int!, updateTicket(trackerId: Int!, ticketId: Int!,

View File

@ -835,7 +835,7 @@ func (r *mutationResolver) SubmitTicket(ctx context.Context, trackerID int, inpu
return &ticket, nil 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 := valid.New(ctx)
validation.Expect(len(input.Subject) <= 2048, validation.Expect(len(input.Subject) <= 2048,
"Ticket subject must be fewer than to 2049 characters."). "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 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) { func (r *mutationResolver) UpdateTicket(ctx context.Context, trackerID int, ticketID int, input map[string]interface{}) (*model.Ticket, error) {
if _, ok := input["import"]; ok { if _, ok := input["import"]; ok {
panic(fmt.Errorf("not implemented")) // TODO panic(fmt.Errorf("not implemented")) // TODO

View File

@ -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 TicketAccess, TicketResolution, Tracker, Ticket, User
from todosrht.types import Label, TicketLabel, TicketSubscription, Event, EventType, ParticipantType from todosrht.types import Label, TicketLabel, TicketSubscription, Event, EventType, ParticipantType
from todosrht.tickets import add_comment, get_participant_for_email from todosrht.tickets import add_comment, get_participant_for_email
from todosrht.webhooks import UserWebhook, TrackerWebhook, TicketWebhook
from srht.validation import Validation from srht.validation import Validation
import asyncio import asyncio
import email import email
@ -150,8 +149,8 @@ class MailHandler:
} }
resp = exec_gql("todo.sr.ht", """ resp = exec_gql("todo.sr.ht", """
mutation SubmitEmail($trackerId: Int!, $input: SubmitEmailInput!) { mutation SubmitTicketEmail($trackerId: Int!, $input: SubmitTicketEmailInput!) {
submitEmail(trackerId: $trackerId, input: $input) { submitTicketEmail(trackerId: $trackerId, input: $input) {
id id
} }
} }
@ -161,11 +160,8 @@ class MailHandler:
print("Rejecting email due to validation errors") print("Rejecting email due to validation errors")
return "550 " + ", ".join([e.message for e in valid.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()}") print(f"Created ticket {ticket.ref()}")
return "250 Message accepted for delivery" return "250 Message accepted for delivery"
@ -173,22 +169,34 @@ class MailHandler:
required_access = TicketAccess.comment required_access = TicketAccess.comment
last_line = body.splitlines()[-1] last_line = body.splitlines()[-1]
resolution = None # Need to commit in case a new participant was created
resolve = reopen = False db.session.commit()
valid = Validation({})
input = {
"text": body,
"senderId": sender.id,
}
cmds = ["!resolve", "!resolved", "!reopen"] cmds = ["!resolve", "!resolved", "!reopen"]
if sender.participant_type == ParticipantType.user: if sender.participant_type == ParticipantType.user:
# TODO: This should be possible via ACLs later # TODO: This should be possible via ACLs later
cmds += ["!assign", "!label", "!unlabel"] cmds += ["!assign", "!label", "!unlabel"]
if any(last_line.startswith(cmd) for cmd in cmds): if any(last_line.startswith(cmd) for cmd in cmds):
cmd = shlex.split(last_line) 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 required_access = TicketAccess.triage
if cmd[0] in ["!resolve", "!resolved"] and len(cmd) == 2: if cmd[0] in ["!resolve", "!resolved"] and len(cmd) == 2:
resolve = True input["cmd"] = "RESOLVE"
resolution = TicketResolution[cmd[1].lower()] input["resolution"] = cmd[1].upper()
elif cmd[0] == "!reopen": elif cmd[0] == "!reopen":
reopen = True input["cmd"] = "REOPEN"
elif cmd[0] == "!label" or cmd[0] == "!unlabel": elif cmd[0] == "!label" or cmd[0] == "!unlabel":
if cmd[0] == "!label":
input["cmd"] = "LABEL"
else:
input["cmd"] = "UNLABEL"
labels = Label.query.filter( labels = Label.query.filter(
Label.name.in_(cmd[1:]), Label.name.in_(cmd[1:]),
Label.tracker_id == ticket.tracker_id).all() Label.tracker_id == ticket.tracker_id).all()
@ -199,33 +207,7 @@ class MailHandler:
print(f"Rejected, {sender.name} has insufficient " + print(f"Rejected, {sender.name} has insufficient " +
f"permissions (have {access}, want triage)") f"permissions (have {access}, want triage)")
return "550 You do not have permission to triage on this tracker." return "550 You do not have permission to triage on this tracker."
for label in labels: input["labelIds"] = [label.id 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)
# TODO: Remaining commands # TODO: Remaining commands
if not required_access in access: if not required_access in access:
@ -237,14 +219,18 @@ class MailHandler:
print("Rejected, invalid comment length") print("Rejected, invalid comment length")
return "550 Comment must be between 3 and 16384 characters." return "550 Comment must be between 3 and 16384 characters."
event = add_comment(sender, ticket, text=body, resp = exec_gql("todo.sr.ht", """
resolution=resolution, resolve=resolve, reopen=reopen, from_email=True) mutation SubmitCommentEmail($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentEmailInput!) {
TicketWebhook.deliver(TicketWebhook.Events.event_create, submitCommentEmail(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
event.to_dict(), id
TicketWebhook.Subscription.ticket_id == ticket.id) }
TrackerWebhook.deliver(TrackerWebhook.Events.event_create, }
event.to_dict(), """, user=ticket.tracker.owner, valid=valid, trackerId=ticket.tracker_id, ticketId=ticket.scoped_id, input=input)
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
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()}") print(f"Added comment to {ticket.ref()}")
return "250 Message accepted for delivery" return "250 Message accepted for delivery"