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
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue