#!/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("^([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 != ""))
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)