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 #!/usr/bin/env python3
from srht.config import cfg from srht.config import cfg, get_origin
from srht.database import db, DbSession from srht.database import db, DbSession
db = DbSession(cfg("todo.sr.ht", "connection-string")) db = DbSession(cfg("todo.sr.ht", "connection-string"))
import todosrht.types import todosrht.types
@ -8,14 +8,15 @@ db.init()
from aiosmtpd.lmtp import SMTP, LMTP from aiosmtpd.lmtp import SMTP, LMTP
from email.utils import parseaddr from email.utils import parseaddr
from grp import getgrnam from grp import getgrnam
from todosrht.access import get_tracker from todosrht.access import get_tracker, get_ticket
from todosrht.types import TicketAccess, Tracker, Ticket, User from todosrht.types import TicketAccess, TicketResolution, Tracker, Ticket, User
from todosrht.tickets import submit_ticket from todosrht.tickets import submit_ticket, add_comment
from srht.validation import Validation from srht.validation import Validation
import asyncio import asyncio
import email import email
import email.policy import email.policy
import os import os
import shlex
import signal import signal
import sys import sys
@ -65,7 +66,11 @@ class MailHandler:
else: else:
# TODO: user groups # TODO: user groups
return None, None 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, async def handle_RCPT(self, server, session,
envelope, address, rcpt_options): envelope, address, rcpt_options):
@ -73,7 +78,11 @@ class MailHandler:
envelope.rcpt_tos.append(address) envelope.rcpt_tos.append(address)
return "250 OK" 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"] subject = mail["Subject"]
valid = Validation({ valid = Validation({
"title": subject, "title": subject,
@ -96,6 +105,39 @@ class MailHandler:
ticket = submit_ticket(tracker, sender, title, desc) ticket = submit_ticket(tracker, sender, title, desc)
print(f"Created ticket {ticket.ref()}") 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): async def handle_DATA(self, server, session, envelope):
try: try:
@ -123,10 +165,6 @@ class MailHandler:
print("Rejected, destination not found") print("Rejected, destination not found")
return "550 The tracker or ticket you requested does not exist." 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 body = None
for part in mail.walk(): for part in mail.walk():
if part.is_multipart(): if part.is_multipart():
@ -138,10 +176,11 @@ class MailHandler:
break break
if isinstance(dest, Tracker): if isinstance(dest, Tracker):
await self.handle_tracker_message(dest, sender, mail, body) return await self.handle_tracker_message(
return "250 Message accepted for delivery" dest, sender, access, mail, body)
elif isinstance(dest, Ticket): elif isinstance(dest, Ticket):
pass # TODO return await self.handle_ticket_message(
dest, sender, access, mail, body)
else: else:
assert False assert False

View File

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

View File

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

View File

@ -62,9 +62,14 @@ class Ticket(Base):
secondary="ticket_assignee", secondary="ticket_assignee",
foreign_keys="[TicketAssignee.ticket_id,TicketAssignee.assignee_id]") foreign_keys="[TicketAssignee.ticket_id,TicketAssignee.assignee_id]")
def ref(self, short=False): def ref(self, short=False, email=False):
if short: if short:
return "#" + str(self.scoped_id) return "#" + str(self.scoped_id)
if email:
return "{}/{}/{}".format(
self.tracker.owner.canonical_name,
self.tracker.name,
self.scoped_id)
return "{}/{}#{}".format( return "{}/{}#{}".format(
self.tracker.owner.canonical_name, self.tracker.owner.canonical_name,
self.tracker.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 from jinja2.utils import unicode_urlencode
def tracker_url(tracker): def tracker_url(tracker):
@ -12,10 +12,15 @@ def tracker_labels_url(tracker):
name=tracker.name) name=tracker.name)
def ticket_url(ticket, comment=None): def ticket_url(ticket, comment=None):
ticket_url = url_for("ticket.ticket_GET", if has_app_context():
owner=ticket.tracker.owner.canonical_name, ticket_url = url_for("ticket.ticket_GET",
name=ticket.tracker.name, owner=ticket.tracker.owner.canonical_name,
ticket_id=ticket.scoped_id) 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: if comment:
ticket_url += "#comment-" + str(comment.id) ticket_url += "#comment-" + str(comment.id)