# 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 <https://www.gnu.org/licenses/>.
# 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.
# Used to validate input. re_valid_email = re.compile("^[0-9a-zA-Z]+$") re_valid_message_id = re.compile("^<?([0-9]+)>?$")
# 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 != ""))
# 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<https://inkbunny.net/JustLurking>", "License GPLv3+: GNU GPL version 3 or later "+ "<https://gnu.org/licenses/gpl.html>", "", "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)
# 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
# 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.") )
# 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]
A program which posts plain-text emails to Inkbunny as private messages. Supports threading via an optional 'In-Reply-To' header.
Be careful what automation you setup with this. No one likes a spammer least of all the site admins you will piss off if you misuse this script.
The script is intended to be called from a mail client that can be configured to use a sendmail-compatible mail submission agent (MSA) but it can be run from the command line as so:
"
export PHPSESSID='my-session-id' ib-sendmail -t <<EOF To: JustLurking From: MyUserName In-Reply-To: <55512345> Subject: This message was posted with ib-sendmail
I'm a ib-sendmail user.
EOF
The script doesn't do any login of its own because I felt trying to automate the login process would not endear me to the site admins. I trust that users who want to run this script will know how to extract the session cookie from their browser.
There is no official API for sending messages on inkbunny so this script may break in the future as the site is updated. There's not much that can be done about that.
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.
GreenReaper, Salmy, keito, I believe these scripts fit in with the spirit of the other scripts found in the Apps, Scripts and Mods page and don't violate the ToS, please remove them if you disagree. I will be happy to make any modifications you require to these scripts if there is something in them that concerns you.
This program is uploaded in the hopes that it may be useful to others. No guarantee is made that this program is correct and free from faults. Never run code on your system that you haven't first read and understood.