#!/usr/bin/python3 # Inkbunny Sendmail 0.1.0 # Copyright 2025 JustLurking # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU 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 General Public License # for more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . # About # This program will read an email containing single plain text message then # connect to Inkbunny using the session cookie in the PHPSESSID environment # variable and attempt to post the message to the user in the From field # and in the thread identified by the In-Reply-To field. # # Not all email messages can be sent via ib-sendmail. Rather than trying # to convert feature rich emails and guess at the user's intent the program # simply refuses to post an email where there is any ambiguity. The following # conditions must be met for an email to be posted by ib-sendmail: # # 1. Before any connection is made to the internet the message must have: # a. one text/plain section # b. zero or more multipart/* sections # c. one recipient (either as an argument or in a To: header if -t is given) # d. zero Cc: or Bcc: headers # e. one sender (either in a From: header or given as an argument to -f) # f. Both must have only a local part with no @ in the addresses. # g. If there is an In-Reply-To it must be numeric and singular. # h. There must be a subject header containing non-whitespace characters # # 2. Once a connection has been made to Inkbunny: # a. The sender must be the logged in user. # b. The recipient must be the other user in the thread if an In-Reply-To # header is set. # Changelog # 2025-01-18 JustLurking: Initial Release. import argparse import bs4 import email import email.policy import logging import os import re import requests import sys # Variables used throughout the program. # Used for identifying the program. program_file = "ib-sendmail" program_name = "Inkbunny Sendmail" version = "0.1.0" bug_reports = "https://Inkbunny.net/JustLurking/" homepage = "https://inkbunny.net/submissionsviewall.php?mode=pool&pool_id=98759" # Used for logging. log = logging.getLogger(__name__) # Used to download pages. base_url = "https://inkbunny.net/privatemessages.php" cookie = None cookies = requests.cookies.RequestsCookieJar() # Used to validate input. re_valid_email = re.compile("^[0-9a-zA-Z]+$") re_valid_message_id = re.compile("^?$") # Used to detect errors. re_error = re.compile('error$') re_footer = re.compile("\\bfooter\\b") def download(url, **kwargs): """ Utility wrapper for boiler plate around downloading and parsing pages. """ global cookies resp = requests.get(url, cookies=cookies, params=kwargs) for (i, step) in enumerate(resp.history): log.debug("[%d] Downloaded: %s", i, step.url) log.debug("[Final] Downloaded: %s", resp.url) resp.raise_for_status() return bs4.BeautifulSoup(resp.content, features="lxml") def get_logged_in_user(tag): """ Get the logged-in user's name using the supplied HTML. """ nav = tag.find(class_="userdetailsnavigation") if nav is None: log.critical("Unable to find user details on page.") exit(1) widget = nav.find(class_="widget_userNameSmall") if widget is None: log.critical("Unable to find user details on page.") exit(1) user = widget.get_text().strip() log.info("Logged in as %s.", user) return user def get_other_user(tag): """ Gets the other user's name using the supplied HTML. """ reply = tag.find(id="reply") if reply is None: log.critical("Unable to find other user details on page.") exit(1) widget = reply.find(class_="widget_userNameSmall") if widget is None: log.critical("Unable to find other user details on page.") exit(1) user = widget.get_text().strip() log.info("Recipient is %s.", user) return user def value(page, name): """ Gets the value of an input field in the supplied HTML. """ tag = page.find("input", attrs={"name": name}) return tag["value"] if tag is not None else None def report_issues(*issues): """ Report any issues to the user and exit with a failure status if any are encountered. """ abort = False for (found_issue, *msg) in issues: if found_issue: log.error(*msg) abort = True if abort: exit(1) def report_site_issue(tag): """ Report any issues to the user and exit with a failure status if any are encountered. """ if tag.find('title').text != "Error | Inkbunny, the Furry Art Community": return msg = None for content in tag.find_all(class_='content'): if content.find_parent(id='usernavigation') is not None: continue if content.find_parent(class_=re_footer) is not None: continue msg = [ line.strip() for line in content.text.splitlines() ] if msg is None: print("An unknown error occurred.") else: print('\n'.join(line for line in msg if line != "")) exit(1) # Main Program starts here. # Configure logging. log.addHandler(logging.StreamHandler()) log.setLevel(logging.INFO) # Handle arguments. arg_parser = argparse.ArgumentParser( prog = program_file, description = "".join(( "Post a private message to inkbunny using an email as the source." )), epilog = "".join(( "Report bugs to: "+bug_reports+"\n", program_name + " home page: <"+homepage+">\n" )), formatter_class = argparse.RawDescriptionHelpFormatter ) arg_parser.add_argument( "-t", action = "store_true", help = "Read message for recipients. To:, Cc:, and Bcc: lines will be scanned for recipient addresses. The Bcc: line will be deleted before transmission." ) arg_parser.add_argument( "-f", nargs = 1, help = "name Sets the name of the ''from'' person (i.e., the envelope sender of the mail)." ) arg_parser.add_argument( "-s", "--session", nargs = 1, help = "the session id to send with requests" ) arg_parser.add_argument( "-q", "--quiet", action = "count", default = 2, help = "decrease verbosity" ) arg_parser.add_argument( "-v", "--verbose", action = "count", default = 0, help = "increase verbosity" ) arg_parser.add_argument( "--save-final-response", const = "ib-sendmail-debug.html", nargs = '?' ) arg_parser.add_argument( "-V", "--version", action = "store_true" ) arg_parser.add_argument( "addresses", nargs = '*' ) args = arg_parser.parse_args() if args.version: print( " ".join((program_name, version)), "Copyright (C) 2025 JustLurking", "License GPLv3+: GNU GPL version 3 or later "+ "", "", "This is free software: you are free to change and redistribute it.", "There is NO WARRANTY, to the extent permitted by law.", sep="\n" ) exit(0) log.setLevel(max(1, min(5, args.quiet-args.verbose))*10) if args.session is not None: cookie = args.session # Set the Session Cookie from the environment. if cookie is None: cookie = os.getenv("PHPSESSID") if cookie is None: log.error("Please use the -s argument or set the PHPSESSID environment variable to set the session id.") exit(1) cookies.set("PHPSESSID", cookie, domain="inkbunny.net", path="/") # Parse the email. parser = email.parser.BytesParser(policy = email.policy.SMTP) message = parser.parse(sys.stdin.buffer) # 1. Check mail. # 1.a. one text/plain section and 1.b. zero or more multipart/* sections. found_plain_text_part = False found_single_plain_text_part = True found_no_other_part = True for part in message.walk(): if part.is_multipart(): continue if part.get_content_type() == "text/plain": if found_plain_text_part: found_single_plain_text_part = False found_plain_text_part = True else: found_no_other_part = False # 1.c one To: header if -t given. to_addresses = args.addresses if args.t and "To" in message: to_addresses.append(*message.get_all("To", [])) found_one_to_address = len(to_addresses) == 1 # 1.d. zero Cc: or Bcc: headers found_no_cc_or_bcc_addresses = message.get_all("Cc") is None and message.get_all("Bcc") is None # 1.e. a from header if -f is not given from_addresses = message.get_all("From", []) if args.f is not None: from_addresses.append(args.f) found_one_from_address = len(from_addresses) == 1 # 1.f. Both must have only a local part. found_valid_to_address = found_one_to_address and re_valid_email.match(to_addresses[0]) is not None found_valid_from_address = found_one_from_address and re_valid_email.match(from_addresses[0]) is not None # 1.g. If there is an In-Reply-To it must be numeric and singular. in_reply_to = message.get_all("In-Reply-To", ()) found_in_reply_to = in_reply_to != () found_single_in_reply_to = len(in_reply_to) == 1 in_reply_to_match = re_valid_message_id.match(in_reply_to[0]) found_valid_in_reply_to = in_reply_to_match is not None if in_reply_to_match is not None: in_reply_to = (in_reply_to_match.group(1),) # h. There must be a subject header containing non-whitespace characters found_valid_subject = message["Subject"].strip() != "" # Report and abort if any errors have been detected. report_issues( (not found_plain_text_part, "Email has no text/plain part."), (not found_single_plain_text_part, "Email should have a single text/plain part."), (not found_no_cc_or_bcc_addresses, "Email should not have CC or BCC recipients."), (not found_no_other_part, "Email should not have non-text/plain parts."), (not found_one_from_address, "Email should have one sender."), (not found_one_to_address, "Email should have one recipient."), (found_in_reply_to and not found_single_in_reply_to, "Email should be in reply to no more than one message."), (not found_valid_in_reply_to, "Email has invalid In-Reply-To header."), (not found_valid_from_address, "Email has invalid sender."), (not found_valid_to_address, "Email has invalid recipient."), (not found_valid_subject, "Email has invalid recipient."), (not found_plain_text_part, "Email has no text/plain part.") ) # 2. Fetch the message submission page. resp = requests.get( "https://inkbunny.net/privatemessageview.php", cookies = cookies, params = {'private_message_id': in_reply_to[0]} if found_in_reply_to else {} ) if args.save_final_response is not None: with open(args.save_final_response, "wb") as f: f.write(resp.content) resp.raise_for_status() page = bs4.BeautifulSoup(resp.content, features="lxml") report_site_issue(page) user = get_logged_in_user(page) # 2.a. The From must match the logged in user. found_matching_sender = user.lower() == from_addresses[0].lower() # 2.b. The To must match the other user if an In-Reply-To is given. found_matching_recipient = True if found_in_reply_to: other_user = get_other_user(page) found_matching_recipient = other_user.lower() == to_addresses[0].lower() # Report and abort if any errors have been detected. report_issues( (not found_matching_sender, "Email has invalid sender."), (not found_matching_recipient, "Email has invalid recipient.") ) # Extract fields from the message submission page. fields = {}; if found_in_reply_to: # This is a reply fields["to_user_id"] = value(page, "to_user_id") fields["private_message_id"] = value(page, "private_message_id") else: # This is a new message fields["to_username"] = to_addresses[0] fields["token"] = value(page, "token") fields["from_user_id"] = value(page, "from_user_id") fields["subject"] = message["Subject"] fields["comment"] = message.get_content() report_issues(*( (True, "Unable to extract the value of the %s field from page.", k) for (k, v) in fields.items() if v is None )) # Send message. resp = requests.post( "https://inkbunny.net/privatemessageview_process.php", cookies = cookies, data = fields ) if args.save_final_response is not None: with open(args.save_final_response, "wb") as f: f.write(resp.content) resp.raise_for_status() page = bs4.BeautifulSoup(resp.content, features="lxml") report_site_issue(page) # Detect and report non-site errors. exit_status = 0 for error in page.find_all(id=re_error): print(error.text) exit_status = 1 if exit_status == 0: log.info("Message sent."); exit(exit_status)