diff --git a/todosrht-lmtp b/todosrht-lmtp index 18781eb..1e3b6ed 100755 --- a/todosrht-lmtp +++ b/todosrht-lmtp @@ -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 diff --git a/todosrht/access.py b/todosrht/access.py index fc478ad..90dac32 100644 --- a/todosrht/access.py +++ b/todosrht/access.py @@ -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 diff --git a/todosrht/tickets.py b/todosrht/tickets.py index ecc2e53..fa3ae3a 100644 --- a/todosrht/tickets.py +++ b/todosrht/tickets.py @@ -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, } diff --git a/todosrht/types/ticket.py b/todosrht/types/ticket.py index 683aef3..c10f4c8 100644 --- a/todosrht/types/ticket.py +++ b/todosrht/types/ticket.py @@ -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, diff --git a/todosrht/urls.py b/todosrht/urls.py index 1dbed41..4cc13d9 100644 --- a/todosrht/urls.py +++ b/todosrht/urls.py @@ -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)