386 lines
15 KiB
Python
386 lines
15 KiB
Python
import email
|
|
import email.policy
|
|
import mailbox
|
|
import pygit2
|
|
import re
|
|
import smtplib
|
|
import subprocess
|
|
import sys
|
|
import hashlib
|
|
from email.utils import make_msgid, parseaddr
|
|
from email.message import EmailMessage
|
|
from flask import Blueprint, render_template, abort, request, url_for, session
|
|
from flask import redirect
|
|
from gitsrht.git import Repository as GitRepository, commit_time, diffstat
|
|
from gitsrht.git import get_log
|
|
from gitsrht.access import get_repo_or_redir
|
|
from srht.config import cfg, cfgi, cfgb
|
|
from srht.oauth import loginrequired, current_user
|
|
from srht.validation import Validation
|
|
from tempfile import NamedTemporaryFile
|
|
from textwrap import TextWrapper
|
|
|
|
mail = Blueprint('mail', __name__)
|
|
|
|
smtp_host = cfg("mail", "smtp-host", default=None)
|
|
smtp_port = cfgi("mail", "smtp-port", default=None)
|
|
smtp_user = cfg("mail", "smtp-user", default=None)
|
|
smtp_password = cfg("mail", "smtp-password", default=None)
|
|
smtp_from = cfg("mail", "smtp-from", default=None)
|
|
outgoing_domain = cfg("git.sr.ht", "outgoing-domain")
|
|
|
|
def render_send_email_start(owner, repo, git_repo, selected_branch,
|
|
ncommits=8, **kwargs):
|
|
branches = [(
|
|
branch,
|
|
git_repo.branches[branch],
|
|
git_repo.get(git_repo.branches[branch].target)
|
|
) for branch
|
|
in git_repo.raw_listall_branches(pygit2.GIT_BRANCH_LOCAL)]
|
|
branches = sorted(branches,
|
|
key=lambda b: (b[0].decode() == selected_branch, commit_time(b[2])),
|
|
reverse=True)
|
|
|
|
commits = dict()
|
|
for branch in branches[:2]:
|
|
commits[branch[0]] = get_log(git_repo,
|
|
branch[2], commits_per_page=ncommits)
|
|
|
|
return render_template("send-email.html",
|
|
view="send-email", owner=owner, repo=repo,
|
|
selected_branch=selected_branch, branches=branches,
|
|
commits=commits, **kwargs)
|
|
|
|
@mail.route("/<owner>/<repo>/send-email")
|
|
@loginrequired
|
|
def send_email_start(owner, repo):
|
|
owner, repo = get_repo_or_redir(owner, repo)
|
|
with GitRepository(repo.path) as git_repo:
|
|
ncommits = int(request.args.get("commits", default=8))
|
|
if ncommits > 32:
|
|
ncommits = 32
|
|
if ncommits < 8:
|
|
ncommits = 8
|
|
selected_branch = request.args.get("branch", default=None)
|
|
|
|
return render_send_email_start(owner, repo, git_repo, selected_branch,
|
|
ncommits)
|
|
|
|
@mail.route("/<owner>/<repo>/send-email/end", methods=["POST"])
|
|
@loginrequired
|
|
def send_email_end(owner, repo):
|
|
owner, repo = get_repo_or_redir(owner, repo)
|
|
with GitRepository(repo.path) as git_repo:
|
|
valid = Validation(request)
|
|
branch = valid.require("branch")
|
|
if not branch in git_repo.branches:
|
|
valid.error(f"Branch {branch} not found", field="branch")
|
|
commit = valid.require(f"commit-{branch}")
|
|
if not valid.ok:
|
|
return render_send_email_start(owner, repo, git_repo, branch,
|
|
**valid.kwargs)
|
|
|
|
branch = git_repo.branches[branch]
|
|
tip = git_repo.get(branch.target)
|
|
start = git_repo.get(commit)
|
|
|
|
log = get_log(git_repo, tip, until=start)
|
|
diffs = list()
|
|
for commit in log:
|
|
try:
|
|
parent = git_repo.revparse_single(commit.oid.hex + "^")
|
|
diff = git_repo.diff(parent, commit)
|
|
except KeyError:
|
|
parent = None
|
|
diff = commit.tree.diff_to_tree(swap=True)
|
|
diff.find_similar(pygit2.GIT_DIFF_FIND_RENAMES)
|
|
diffs.append(diff)
|
|
|
|
return render_template("send-email-end.html",
|
|
view="send-email", owner=owner, repo=repo,
|
|
commits=log, start=start, diffs=diffs,
|
|
diffstat=diffstat)
|
|
|
|
def wrap_each_line(text):
|
|
# Account for TextWrapper ignoring newlines (see Python issue #1859)
|
|
wrapper = TextWrapper(
|
|
expand_tabs=False,
|
|
replace_whitespace=False,
|
|
width=72,
|
|
drop_whitespace=True,
|
|
break_long_words=False)
|
|
|
|
short_lines = []
|
|
for long_line in text.splitlines():
|
|
if len(long_line) == 0 or long_line.isspace():
|
|
# Bypass TextWrapper to ensure a line is still inserted.
|
|
short_lines.append('')
|
|
else:
|
|
for short_line in wrapper.wrap(long_line):
|
|
short_lines.append(short_line)
|
|
# Replace the original newline indicators.
|
|
return '\n'.join(short_lines)
|
|
|
|
commentary_re = re.compile(r"""
|
|
---\n
|
|
(?P<context>
|
|
(\ .*\ +\|\ +\d+\ [-+]+\n)+
|
|
\ \d+\ files?\ changed,.*\n
|
|
\n
|
|
diff\ --git
|
|
)
|
|
""", re.MULTILINE | re.VERBOSE)
|
|
|
|
def prepare_patchset(repo, git_repo, cover_letter=None, extra_headers=False,
|
|
to=None, cc=None):
|
|
with NamedTemporaryFile() as ntf:
|
|
valid = Validation(request)
|
|
start_commit = valid.require("start_commit")
|
|
end_commit = valid.require("end_commit")
|
|
version = valid.require("version")
|
|
cover_letter_subject = valid.optional("cover_letter_subject")
|
|
if cover_letter is None:
|
|
cover_letter = valid.optional("cover_letter")
|
|
if not valid.ok:
|
|
return None
|
|
version = int(version)
|
|
|
|
args = [
|
|
"git",
|
|
"--git-dir", repo.path,
|
|
"-c", f"user.name={current_user.canonical_name}",
|
|
"-c", f"user.email={current_user.username}@{outgoing_domain}",
|
|
"format-patch",
|
|
f"--from={current_user.canonical_name} <{current_user.username}@{outgoing_domain}>",
|
|
f"--subject-prefix=PATCH {repo.name}",
|
|
"--stdout",
|
|
]
|
|
if cover_letter:
|
|
args += ["--cover-letter"]
|
|
if version != 1:
|
|
args += ["-v", str(version)]
|
|
|
|
start_rev = git_repo.get(start_commit)
|
|
if not start_rev:
|
|
abort(404)
|
|
if start_rev.parent_ids:
|
|
args += [f"{start_commit}^..{end_commit}"]
|
|
else:
|
|
args += ["--root", end_commit]
|
|
print(args)
|
|
p = subprocess.run(args, timeout=30,
|
|
stdout=subprocess.PIPE, stderr=sys.stderr)
|
|
if p.returncode != 0:
|
|
abort(400) # TODO: Something more useful, I suppose.
|
|
|
|
ntf.write(p.stdout)
|
|
ntf.flush()
|
|
|
|
# By default mailbox.mbox creates email.Message objects. We want the
|
|
# more modern email.EmailMessage class which handles things like header
|
|
# continuation lines better. For this reason we need to explicitly
|
|
# specify a policy via a factory.
|
|
policy = email.policy.default
|
|
factory = lambda f: email.message_from_binary_file(f, policy=policy)
|
|
mbox = mailbox.mbox(ntf.name, factory=factory)
|
|
emails = list(mbox)
|
|
|
|
# git-format-patch doesn't set the charset attribute of the
|
|
# Content-Type header field. The Python stdlib assumes ASCII and chokes
|
|
# on UTF-8.
|
|
for msg in emails:
|
|
# replace_header doesn't allow setting params, so we have to unset
|
|
# the header field and re-add it
|
|
t = msg.get_content_type()
|
|
del msg["Content-Type"]
|
|
msg.add_header("Content-Type", t, charset="utf-8")
|
|
|
|
if cover_letter:
|
|
subject = emails[0]["Subject"]
|
|
del emails[0]["Subject"]
|
|
emails[0]["Subject"] = (subject
|
|
.replace("*** SUBJECT HERE ***", cover_letter_subject))
|
|
body = emails[0].get_content()
|
|
cover_letter = wrap_each_line(cover_letter)
|
|
body = body.replace("*** BLURB HERE ***", cover_letter)
|
|
emails[0].set_content(body)
|
|
|
|
for i, msg in enumerate(emails[(1 if cover_letter else 0):]):
|
|
commentary = valid.optional(f"commentary_{i}")
|
|
if not commentary:
|
|
commentary = session.get(f"commentary_{i}")
|
|
if not commentary:
|
|
continue
|
|
commentary = wrap_each_line(commentary)
|
|
body = msg.get_content()
|
|
body = commentary_re.sub(r"---\n" + commentary.replace(
|
|
"\\", r"\\") + r"\n\n\g<context>", body, count=1)
|
|
msg.set_content(body)
|
|
|
|
if extra_headers:
|
|
msgid = make_msgid().split("@")
|
|
for i, msg in enumerate(emails):
|
|
msg["Message-ID"] = f"{msgid[0]}-{i}@{msgid[1]}"
|
|
msg["X-Mailer"] = "git.sr.ht"
|
|
msg["Reply-to"] = (f"{current_user.canonical_name} " +
|
|
f"<{current_user.email}>")
|
|
if i != 0:
|
|
msg["In-Reply-To"] = f"{msgid[0]}-{0}@{msgid[1]}"
|
|
if to:
|
|
msg["To"] = to
|
|
if cc:
|
|
msg["Cc"] = cc
|
|
|
|
return emails
|
|
|
|
@mail.route("/<owner>/<repo>/send-email/review", methods=["POST"])
|
|
@loginrequired
|
|
def send_email_review(owner, repo):
|
|
owner, repo = get_repo_or_redir(owner, repo)
|
|
with GitRepository(repo.path) as git_repo:
|
|
valid = Validation(request)
|
|
start_commit = valid.require("start_commit")
|
|
end_commit = valid.require("end_commit")
|
|
cover_letter = valid.optional("cover_letter")
|
|
cover_letter_subject = valid.optional("cover_letter_subject")
|
|
version = valid.require("version")
|
|
if cover_letter and not cover_letter_subject:
|
|
valid.error("Cover letter subject is required.",
|
|
field="cover_letter_subject")
|
|
if cover_letter_subject and not cover_letter:
|
|
valid.error("Cover letter body is required.", field="cover_letter")
|
|
|
|
default_branch = git_repo.default_branch()
|
|
tip = git_repo.get(default_branch.target)
|
|
readme = None
|
|
if "README.md" in tip.tree:
|
|
readme = "README.md"
|
|
elif "README" in tip.tree:
|
|
readme = "README"
|
|
|
|
emails = prepare_patchset(repo, git_repo)
|
|
start = git_repo.get(start_commit)
|
|
tip = git_repo.get(end_commit)
|
|
if not emails or not valid.ok:
|
|
log = get_log(git_repo, tip, until=start)
|
|
diffs = list()
|
|
for commit in log:
|
|
try:
|
|
parent = git_repo.revparse_single(commit.oid.hex + "^")
|
|
diff = git_repo.diff(parent, commit)
|
|
except KeyError:
|
|
parent = None
|
|
diff = commit.tree.diff_to_tree(swap=True)
|
|
diff.find_similar(pygit2.GIT_DIFF_FIND_RENAMES)
|
|
diffs.append(diff)
|
|
|
|
return render_template("send-email-end.html",
|
|
view="send-email", owner=owner, repo=repo,
|
|
commits=log, start=start, diffs=diffs,
|
|
diffstat=diffstat, **valid.kwargs)
|
|
|
|
version = int(version)
|
|
for i, email in enumerate(emails):
|
|
comm = valid.optional(f"commentary_{i}")
|
|
if comm:
|
|
session[f"commentary_{i}"] = comm
|
|
|
|
session["cover_letter"] = cover_letter
|
|
return render_template("send-email-review.html",
|
|
view="send-email", owner=owner, repo=repo,
|
|
readme=readme, emails=emails,
|
|
start=start,
|
|
end=tip,
|
|
cover_letter=bool(cover_letter),
|
|
cover_letter_subject=cover_letter_subject,
|
|
version=version)
|
|
|
|
@mail.route("/<owner>/<repo>/send-email/send", methods=["POST"])
|
|
@loginrequired
|
|
def send_email_send(owner, repo):
|
|
owner, repo = get_repo_or_redir(owner, repo)
|
|
with GitRepository(repo.path) as git_repo:
|
|
valid = Validation(request)
|
|
start_commit = valid.require("start_commit")
|
|
end_commit = valid.require("end_commit")
|
|
cover_letter_subject = valid.optional("cover_letter_subject")
|
|
|
|
to = valid.require("patchset_to", friendly_name="To")
|
|
cc = valid.optional("patchset_cc")
|
|
recipients = list()
|
|
|
|
if to:
|
|
to_recipients = [parseaddr(r)[1] for r in to.split(",")]
|
|
valid.expect('' not in to_recipients,
|
|
"Invalid recipient.", field="patchset_to")
|
|
recipients += to_recipients
|
|
if cc:
|
|
cc_recipients = [parseaddr(r)[1] for r in cc.split(",")]
|
|
valid.expect('' not in cc_recipients,
|
|
"Invalid recipient.", field="patchset_cc")
|
|
recipients += cc_recipients
|
|
|
|
if not valid.ok:
|
|
cover_letter = session.get("cover_letter")
|
|
emails = prepare_patchset(repo, git_repo, cover_letter=cover_letter)
|
|
|
|
default_branch = git_repo.default_branch()
|
|
tip = git_repo.get(default_branch.target)
|
|
readme = None
|
|
if "README.md" in tip.tree:
|
|
readme = "README.md"
|
|
elif "README" in tip.tree:
|
|
readme = "README"
|
|
|
|
return render_template("send-email-review.html",
|
|
view="send-email", owner=owner, repo=repo,
|
|
readme=readme, emails=emails,
|
|
start=git_repo.get(start_commit),
|
|
end=git_repo.get(end_commit),
|
|
cover_letter=bool(cover_letter),
|
|
**valid.kwargs)
|
|
|
|
cover_letter = session.pop("cover_letter", None)
|
|
emails = prepare_patchset(repo, git_repo,
|
|
cover_letter=cover_letter, extra_headers=True,
|
|
to=to, cc=cc)
|
|
if not emails:
|
|
abort(400) # Should work by this point
|
|
|
|
# git-format-patch doesn't encode messages, this is done by
|
|
# git-send-email. Since we're parsing the message Python doesn't do it
|
|
# automatically for us, it keeps the unencoded message as-is. Re-create
|
|
# the message with the same header and body to fix that.
|
|
# TODO: remove cte_type once [1] is merged
|
|
# [1]: https://github.com/python/cpython/pull/8303
|
|
policy = email.policy.SMTP.clone(cte_type="7bit")
|
|
for i, msg in enumerate(emails):
|
|
encoded = EmailMessage(policy=policy)
|
|
for (k, v) in msg.items():
|
|
encoded.add_header(k, v)
|
|
encoded.set_content(msg.get_content())
|
|
emails[i] = encoded
|
|
|
|
# TODO: Send emails asyncronously
|
|
smtp = smtplib.SMTP(smtp_host, smtp_port)
|
|
smtp.ehlo()
|
|
if smtp_user and smtp_password:
|
|
smtp.starttls()
|
|
smtp.login(smtp_user, smtp_password)
|
|
print("Sending to recipients", recipients)
|
|
for i, msg in enumerate(emails):
|
|
session.pop("commentary_{i}", None)
|
|
smtp.send_message(msg, smtp_from, recipients)
|
|
smtp.quit()
|
|
|
|
# TODO: If we're connected to a lists.sr.ht address, link to their URL
|
|
# in the archives.
|
|
session["message"] = "Your patchset has been sent."
|
|
return redirect(url_for('repo.summary',
|
|
owner=repo.owner, repo=repo.name))
|
|
|
|
@mail.app_template_filter('hash')
|
|
def to_hash(value):
|
|
hashed_value = hashlib.sha256(value.encode() if isinstance(value, str) else value)
|
|
return hashed_value.hexdigest()
|