Add support for replying to tickets via email
This commit is contained in:
parent
3df816c9f8
commit
060eea1c1a
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue