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
}
"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!,

View File

@ -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

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 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"