Add support for replying to tickets via email

This commit is contained in:
Drew DeVault 2019-04-29 18:26:36 -04:00
parent 3df816c9f8
commit 060eea1c1a
5 changed files with 75 additions and 21 deletions

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
from srht.config import cfg
from srht.config import cfg, get_origin
from srht.database import db, DbSession
db = DbSession(cfg("todo.sr.ht", "connection-string"))
import todosrht.types
@ -8,14 +8,15 @@ db.init()
from aiosmtpd.lmtp import SMTP, LMTP
from email.utils import parseaddr
from grp import getgrnam
from todosrht.access import get_tracker
from todosrht.types import TicketAccess, Tracker, Ticket, User
from todosrht.tickets import submit_ticket
from todosrht.access import get_tracker, get_ticket
from todosrht.types import TicketAccess, TicketResolution, Tracker, Ticket, User
from todosrht.tickets import submit_ticket, add_comment
from srht.validation import Validation
import asyncio
import email
import email.policy
import os
import shlex
import signal
import sys
@ -65,7 +66,11 @@ class MailHandler:
else:
# TODO: user groups
return None, None
return get_tracker(owner, tracker_name, user=sender)
tracker, access = get_tracker(owner, tracker_name, user=sender)
if not ticket_id:
return tracker, access
ticket, access = get_ticket(tracker, ticket_id, user=sender)
return ticket, access
async def handle_RCPT(self, server, session,
envelope, address, rcpt_options):
@ -73,7 +78,11 @@ class MailHandler:
envelope.rcpt_tos.append(address)
return "250 OK"
async def handle_tracker_message(self, tracker, sender, mail, body):
async def handle_tracker_message(self, tracker, sender, access, mail, body):
if not TicketAccess.submit in access:
print("Rejected, insufficient permissions")
return "550 You do not have permission to post on this tracker."
subject = mail["Subject"]
valid = Validation({
"title": subject,
@ -96,6 +105,39 @@ class MailHandler:
ticket = submit_ticket(tracker, sender, title, desc)
print(f"Created ticket {ticket.ref()}")
return "250 Message accepted for delivery"
async def handle_ticket_message(self, ticket, sender, access, mail, body):
required_access = TicketAccess.comment
last_line = body.splitlines()[-1]
resolution = None
resolve = reopen = False
cmds = ["!resolve", "!reopen", "!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()
required_access = TicketAccess.triage
if cmd[0] == "!resolve" and len(cmd) == 2:
resolve = True
resolution = TicketResolution[cmd[1].lower()]
elif cmd[0] == "!reopen":
reopen = True
# TODO: Remaining commands
if not required_access in access:
print(f"Rejected, {sender.canonical_name} has insufficient " +
f"permissions (have {access}, want {required_access})")
return "550 You do not have permission to post on this tracker."
if not body or 3 > len(body) > 16384:
print("Rejected, invalid comment length")
return "550 Comment must be between 3 and 16384 characters."
comment = add_comment(sender, ticket, text=body,
resolution=resolution, resolve=resolve, reopen=reopen)
print(f"Added comment to {comment.ticket.ref()}")
return "250 Message accepted for delivery"
async def handle_DATA(self, server, session, envelope):
try:
@ -123,10 +165,6 @@ class MailHandler:
print("Rejected, destination not found")
return "550 The tracker or ticket you requested does not exist."
if not TicketAccess.submit in access:
print("Rejected, insufficient permissions")
return "550 You do not have permission to post on this tracker."
body = None
for part in mail.walk():
if part.is_multipart():
@ -138,10 +176,11 @@ class MailHandler:
break
if isinstance(dest, Tracker):
await self.handle_tracker_message(dest, sender, mail, body)
return "250 Message accepted for delivery"
return await self.handle_tracker_message(
dest, sender, access, mail, body)
elif isinstance(dest, Ticket):
pass # TODO
return await self.handle_ticket_message(
dest, sender, access, mail, body)
else:
assert False

View File

@ -45,13 +45,13 @@ def get_tracker(owner, name, with_for_update=False, user=None):
# TODO: org trackers
return None, None
def get_ticket(tracker, ticket_id):
def get_ticket(tracker, ticket_id, user=None):
ticket = (Ticket.query
.filter(Ticket.scoped_id == ticket_id)
.filter(Ticket.tracker_id == tracker.id)).one_or_none()
if not ticket:
return None, None
access = get_access(tracker, ticket)
access = get_access(tracker, ticket, user=user)
if not TicketAccess.browse in access:
return None, None
return ticket, access

View File

@ -13,6 +13,7 @@ from sqlalchemy import func, or_, and_
smtp_user = cfg("mail", "smtp-user", default=None)
smtp_from = cfg("mail", "smtp-from", default=None)
notify_from = cfg("todo.sr.ht", "notify-from", default=smtp_from)
posting_domain = cfg("todo.sr.ht::mail", "posting-domain")
StatusChange = namedtuple("StatusChange", [
"old_status",
@ -110,6 +111,7 @@ def _send_comment_notification(subscription, ticket, user, comment, resolution):
subject = "Re: {}: {}".format(ticket.ref(), ticket.title)
headers = {
"From": "~{} <{}>".format(user.username, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}
@ -169,6 +171,7 @@ def _send_mention_notification(sub, submitter, text, ticket, comment=None):
subject = "{}: {}".format(ticket.ref(), ticket.title)
headers = {
"From": "~{} <{}>".format(submitter.username, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}
@ -284,6 +287,7 @@ def notify_assignee(subscription, ticket, assigner, assignee):
subject = "{}: {}".format(ticket.ref(), ticket.title)
headers = {
"From": "~{} <{}>".format(assigner.username, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}
@ -361,6 +365,7 @@ def _send_new_ticket_notification(subscription, ticket):
subject = f"{ticket.ref()}: {ticket.title}"
headers = {
"From": "~{} <{}>".format(ticket.submitter.username, notify_from),
"Reply-To": f"{ticket.ref()} <{ticket.ref(email=True)}@{posting_domain}>",
"Sender": smtp_user,
}

View File

@ -62,9 +62,14 @@ class Ticket(Base):
secondary="ticket_assignee",
foreign_keys="[TicketAssignee.ticket_id,TicketAssignee.assignee_id]")
def ref(self, short=False):
def ref(self, short=False, email=False):
if short:
return "#" + str(self.scoped_id)
if email:
return "{}/{}/{}".format(
self.tracker.owner.canonical_name,
self.tracker.name,
self.scoped_id)
return "{}/{}#{}".format(
self.tracker.owner.canonical_name,
self.tracker.name,

View File

@ -1,4 +1,4 @@
from flask import url_for
from flask import has_app_context, url_for
from jinja2.utils import unicode_urlencode
def tracker_url(tracker):
@ -12,10 +12,15 @@ def tracker_labels_url(tracker):
name=tracker.name)
def ticket_url(ticket, comment=None):
ticket_url = url_for("ticket.ticket_GET",
owner=ticket.tracker.owner.canonical_name,
name=ticket.tracker.name,
ticket_id=ticket.scoped_id)
if has_app_context():
ticket_url = url_for("ticket.ticket_GET",
owner=ticket.tracker.owner.canonical_name,
name=ticket.tracker.name,
ticket_id=ticket.scoped_id)
else:
ticket_url = (f"/{ticket.tracker.owner.canonical_name}" +
f"/{ticket.tracker.name}" +
f"/{ticket.scoped_id}")
if comment:
ticket_url += "#comment-" + str(comment.id)