aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorQuestion Box Service <qbox@andrewyu.org>2023-04-07 10:09:27 +0200
committerQuestion Box Service <qbox@andrewyu.org>2023-04-07 10:09:27 +0200
commit75de78518235d3cab6e875e44839b1e81787a897 (patch)
treeb1d72ecb25b7c88f0700fc51707972f99f79ae6a
parent9477fc1612e8c54e20c647785ffc0ce2c504a53e (diff)
downloadqbox-75de78518235d3cab6e875e44839b1e81787a897.tar.gz
qbox-75de78518235d3cab6e875e44839b1e81787a897.zip
Add email functionality
-rwxr-xr-x[-rw-r--r--]app.py270
-rw-r--r--templates/home.html3
2 files changed, 230 insertions, 43 deletions
diff --git a/app.py b/app.py
index 688772a..b9e8bbe 100644..100755
--- a/app.py
+++ b/app.py
@@ -1,18 +1,20 @@
+#!/usr/bin/env python3
+#
# qbox - anonymous question board thingy
#
# Copyright (c) 2022 Ferass EL HAFIDI
# Copyright (c) 2022, 2023 Andrew Yu <andrew@andrewyu.org>
-#
+#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
-#
+#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
-#
+#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
@@ -25,16 +27,31 @@ from email.mime.text import MIMEText
from subprocess import Popen, PIPE
-
from html import escape
import time, os
import json
+import re
+import mailbox
+
+MAPPING = {
+ "andrew": (
+ "andrew@andrewyu.org",
+ "andrew.json",
+ 'https://www.andrewyu.org/',
+ 'Andrew Yu (Hypfzhqiik)'
+ ),
+ "hypfzwmiik": (
+ "box2@andrewyu.org",
+ "hypfzwmiik.json",
+ 'https://users.andrewyu.org/~hypfzwmiik/',
+ 'Hypfzwmiik'
+ ),
+}
-mapping = {"andrew": ("andrew@andrewyu.org", "andrew.json", "<a href=\"https://www.andrewyu.org/\">Andrew Yu (Hypfzhqiik)</a>"), "hypfzwmiik": ("box2@andrewyu.org", "hypfzwmiik.json", "Hypfzqmiik")} # not gonna spam their mailbox during testing
-def ldb(user):
+def load_database(user):
try:
- db_file = open("%s" % mapping[user][1], "r+")
+ db_file = open("%s" % MAPPING[user][1], "r+")
except FileNotFoundError:
db = []
else:
@@ -43,60 +60,231 @@ def ldb(user):
db_file.close()
return db
+
app = Flask(__name__)
-def append_question(user, text, ts):
- db = ldb(user)
- db.append({"q": text, "a": None, "ts": ts})
- with open(mapping[user][1], "w") as db_file:
- json.dump(db, db_file, indent=4)
-def gpq(db):
- gd = ""
+def generate_user_list_from_mapping(mapping):
+ generated_user_list = ""
+ for username, usertuple in mapping.items():
+ generated_user_list += "<li><a href=\"/"
+ generated_user_list += username
+ generated_user_list += "\">"
+ generated_user_list += usertuple[3]
+ generated_user_list += "</a> [Email: <a href=\"mailto:"
+ generated_user_list += usertuple[0]
+ generated_user_list += "\">"
+ generated_user_list += usertuple[0]
+ generated_user_list += "</a>, homepage: <a href=\""
+ generated_user_list += usertuple[2]
+ generated_user_list += "\">"
+ generated_user_list += usertuple[2]
+ generated_user_list += "</a>]</li>\n"
+ return generated_user_list
+
+
+def generate_past_questions_from_database(db):
+ generated_questions_html_listing = ""
for qs in reversed(db):
- if not qs["a"]: continue
- gd += "<hr />"
- gd += "<div class=\"single-past-question\">"
- gd += "<pre class=\"past-question-question\">"
- gd += escape(qs["q"]) # questions are not trusted and must be escaped
- gd += "</pre>"
- gd += "<span class=\"past-question-answer\">"
- gd += qs["a"] # answers are trusted and may include HTML
- gd += "</span>"
- gd += "</div>"
- return gd
-
-# I think, therefore I am
-@app.route('/<user>', methods=['GET', 'POST'])
+ if not qs["a"]:
+ continue
+ generated_questions_html_listing += "<hr />"
+ generated_questions_html_listing += '<div class="single-past-question">'
+ generated_questions_html_listing += '<pre class="past-question-question">'
+ generated_questions_html_listing += escape(qs["q"]) # questions are not trusted and must be escaped
+ generated_questions_html_listing += "</pre>"
+ generated_questions_html_listing += '<span class="past-question-answer">'
+ generated_questions_html_listing += qs["a"] # answers are trusted and may include HTML
+ generated_questions_html_listing += "</span>"
+ generated_questions_html_listing += "</div>"
+ return generated_questions_html_listing
+
+def dump_database(user, db):
+ #with open(MAPPING[user][1], "w") as db_file:
+ # I've replaced this so that if the write fails it doesn't corrupt the JSON file
+ fn = MAPPING[user][1]
+ with open(fn + '.tmp', 'w') as db_file:
+ json.dump(db, db_file, indent=4)
+ os.replace(fn + '.tmp', fn)
+
+re_named_address = re.compile("(.*) <(.*)\\@(.*)>")
+re_unnamed_address = re.compile("^[<](.*)\\@(.*)[>]$")
+
+
+def parse_address(s): # Returns name, user, host.
+ attempt_named_address = re_named_address.match(s)
+ if attempt_named_address:
+ return (
+ attempt_named_address.group(1),
+ attempt_named_address.group(2),
+ attempt_named_address.group(3),
+ )
+
+ attempt_unnamed_address = re_unnamed_address.match(s)
+ if attempt_unnamed_address:
+ return (
+ None,
+ attempt_unnamed_address.group(1),
+ attempt_unnamed_address.group(2),
+ )
+
+ return None # No results, invalid address.
+
+
+@app.route("/<user>", methods=["GET", "POST"])
def qboard(user):
- if user not in mapping:
+ if user not in MAPPING:
return render_template("unknown_user.html", faulty_username=user)
- elif request.method == 'GET':
+ elif request.method == "GET":
global db
- db = ldb(user)
- return Response(open("templates/qboard.html", "r").read().replace("{{username}}", mapping[user][2]).replace("{{pq}}", gpq(db)), mimetype='text/html')
- elif request.method == 'POST':
+ db = load_database(user)
+
+ # EMAIL STUFF IS TO BE ADDED HERE
+ # Why not in a separate email_stuff() function?
+ # Because lazy
+ mbox = mailbox.Maildir('/home/qbox/Mail/Inbox')
+ for msg in mbox:
+ if msg.get_subdir() != "new": continue
+ msg.set_subdir("cur") # apparently it's not doing so
+ mbox.flush()
+ parsed_address = parse_address(msg["From"])
+ from_address = parsed_address[1] + "@" + parsed_address[2]
+ if from_address != MAPPING[user][0]: continue
+ if 'In-Reply-To' not in msg.keys() or not msg['In-Reply-To']:
+ ts = str(time.time())
+ newmsg = MIMEText(
+ f"Hello {MAPPING[user][3]},\n\nI cannot understand this unsolicited message. If you are trying to reply to a message, be sure to use the ``reply'' feature in your email client to indicate that you are reply to that specific notification of mine. If something sounds wrong, contact your server administrator.\n\nQuestion Box System"
+ )
+ newmsg["From"] = "qbox@andrewyu.org"
+ newmsg["To"] = msg["From"]
+ if msg["Subject"].startswith("Re: "):
+ newmsg["Subject"] = msg["Subject"]
+ else:
+ newmsg["Subject"] = "Re: " + msg["Subject"]
+ if "Message-ID" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-ID"]
+ elif "Message-Id" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-Id"]
+ newmsg["Message-Id"] = "<qbox-system-%s@andrewyu.org>" % ts
+ p = Popen(["/usr/sbin/sendmail", "-t", "-oi"], stdin=PIPE)
+ p.communicate(newmsg.as_bytes())
+ return # This return was missing
+
+ print(msg["In-Reply-To"])
+ reply_identifier = parse_address(str(msg['In-Reply-To']))[1] # Should be ts
+ for question in reversed(db):
+ if reply_identifier == "qbox-" + question["ts"]:
+ break
+ else:
+ ts = str(time.time())
+ newmsg = MIMEText(
+ f"Hello {MAPPING[user][3]},\n\nYou sent me a message, which was supposedly a reply to one of my notifications. However, I do not recognize your In-Reply-To header as one of my Message-IDs, so I don't know how to handle your message. Maybe you could check if you are using your email client's ``reply'' feature correctly, or if the database has been modified to remove the notified post?\n\nThings are getting weird. Contact your server administrator if confused.\n\nQuestion Box System"
+ )
+ newmsg["From"] = "qbox@andrewyu.org"
+ newmsg["To"] = msg["From"]
+ if msg["Subject"].startswith("Re: "):
+ newmsg["Subject"] = msg["Subject"]
+ else:
+ newmsg["Subject"] = "Re: " + msg["Subject"]
+ if "Message-ID" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-ID"]
+ elif "Message-Id" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-Id"]
+ newmsg["Message-Id"] = "<qbox-system-%s@andrewyu.org>" % ts
+ p = Popen(["/usr/sbin/sendmail", "-t", "-oi"], stdin=PIPE)
+ p.communicate(newmsg.as_bytes())
+ return
+
+ for part in msg.walk():
+ if part.get_content_type() == "text/plain":
+ break
+ else:
+ ts = str(time.time())
+ newmsg = MIMEText(
+ f"Hello {MAPPING[user][3]},\n\nYour reply was in an incorrect format. Please ensure that it includes at least one subpart of MIME type ``text/plain''.\n\nQuestion Box System"
+ )
+ newmsg["From"] = "qbox@andrewyu.org"
+ newmsg["To"] = msg["From"]
+ if msg["Subject"].startswith("Re: "):
+ newmsg["Subject"] = msg["Subject"]
+ else:
+ newmsg["Subject"] = "Re: " + msg["Subject"]
+ if "Message-ID" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-ID"]
+ elif "Message-Id" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-Id"]
+ newmsg["Message-Id"] = "<qbox-system-%s@andrewyu.org>" % ts
+ p = Popen(["/usr/sbin/sendmail", "-t", "-oi"], stdin=PIPE)
+ p.communicate(newmsg.as_bytes())
+ return
+
+ recieved_message_text_plain = part.get_payload()
+
+ db.remove(question)
+ question["a"] = recieved_message_text_plain
+ db.append(question)
+ dump_database(user, db)
+
+ ts = str(time.time())
+ newmsg = MIMEText(
+ f"Hello {MAPPING[user][3]},\n\nI have received your message and I added it to the question board.\n\nQuestion Box System"
+ )
+ newmsg["From"] = "qbox@andrewyu.org"
+ newmsg["To"] = msg["From"]
+ if msg["Subject"].startswith("Re: "):
+ newmsg["Subject"] = msg["Subject"]
+ else:
+ newmsg["Subject"] = "Re: " + msg["Subject"]
+ if "Message-ID" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-ID"]
+ elif "Message-Id" in msg.keys():
+ newmsg["In-Reply-To"] = msg["Message-Id"]
+ newmsg["Message-Id"] = "<qbox-system-%s@andrewyu.org>" % ts
+ p = Popen(["/usr/sbin/sendmail", "-t", "-oi"], stdin=PIPE)
+ p.communicate(newmsg.as_bytes())
+
+ return Response(
+ open("templates/qboard.html", "r")
+ .read()
+ .replace("{{username}}", "<a href=\"%s\">%s</a>" % (MAPPING[user][2], MAPPING[user][3]))
+ .replace("{{pq}}", generate_past_questions_from_database(db)),
+ mimetype="text/html",
+ )
+ elif request.method == "POST":
ts = str(time.time())
if "text" in request.form and request.form["text"].strip():
text = request.form["text"]
- append_question(user, text, ts)
+ db = load_database(user)
+ db.append({"q": text, "a": None, "ts": ts})
+ dump_database(user, db)
print(text + "\a")
- msg = MIMEText(f"The following message was received in the question box at {ts}. Please reply to this IN PLAIN TEXT EMAIL; you may handwrite HTML in your reply.\n\n{text}")
+ msg = MIMEText(
+ f"Hello {MAPPING[user][3]},\n\nThe following message was received in your ({user}'s) question box at {ts}. Please reply to this in plain text email, as in the MIME type must be ``text/plain''; you may handwrite HTML in your reply. Remember to remove any quoted text if your email client adds these automatically. Attachments will be ignored.\n\n{text}\n\nQuestion Box System"
+ )
msg["From"] = "qbox@andrewyu.org"
- msg["To"] = mapping[user][0]
+ msg["To"] = MAPPING[user][0]
msg["Subject"] = "Question Box Message"
msg["Message-Id"] = "<qbox-%s@andrewyu.org>" % ts
p = Popen(["/usr/sbin/sendmail", "-t", "-oi"], stdin=PIPE)
p.communicate(msg.as_bytes())
else:
- return Response("Empty submissions are forbidden.", mimetype='text/plain')
- return Response("Submission successful.", mimetype='text/plain')
+ return Response("Empty submissions are forbidden.", mimetype="text/plain")
+ return Response(
+ "Submission successful.\n\nPlease press the ``back'' button of your browser or otherwise return to the previous page.",
+ mimetype="text/plain",
+ )
return "Invalid request.", 400
-@app.route('/', methods=['GET'])
+
+@app.route("/", methods=["GET"])
def index():
- return render_template('home.html')
+ return Response(
+ open("templates/home.html", "r")
+ .read()
+ .replace("{{userlist}}", generate_user_list_from_mapping(MAPPING)),
+ mimetype="text/html",
+ )
+
if __name__ == "__main__":
app.run(port=5728)
-
diff --git a/templates/home.html b/templates/home.html
index 3792bb4..bba7e4e 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -12,8 +12,7 @@
<strong style="color: red; font-size: 300%;">Site under maintainance — very unstable!!!</strong>
<ul>
- <li><a href="/andrew">Andrew/Hypfzhqiik</a></li>
- <li><a href="/hypfzwmiik">Hypfzwmiik</a></li>
+ {{userlist}}
</ul>
<div id="footer">