Welcome to Inkbunny...
Allowed ratings
To view member-only content, create an account. ( Hide )
Inkbunny Maildir Fetch 0.1.0
« older newer »
JustLurking
JustLurking's Gallery (69)

Inkbunny Sendmail 0.1.0

Inkbunny Status Reporter 0.1.1
ib-sendmail.txt
Keywords inkbunny 1268, python 1013, program 221, code 133, utility 20
#!/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 <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.

# 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&a...

# 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<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)

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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
page
1
page
2
page
3
page
4
page
5
page
6
page
7
page
8
page
9
page
10
page
11
page
12
page
13
page
14
page
15
page
16
page
17
page
18
page
19
page
20
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
next
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
previous
page
 
 
page
1
page
2
page
3
page
4
page
5
page
6
page
7
page
8
page
9
page
10
page
11
page
12
page
13
page
14
page
15
page
16
page
17
page
18
page
19
page
20
First in pool
Last in pool
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.

Keywords
inkbunny 1,268, python 1,013, program 221, code 133, utility 20
Details
Type: Writing - Document
Published: 2 months, 1 week ago
Rating: General

MD5 Hash for Page 1... Show Find Identical Posts [?]
Stats
49 views
1 favorite
2 comments

BBCode Tags Show [?]
 
GreenReaper
1 month, 2 weeks ago
Thanks for writing this, I've belatedly added it to the relevant section.
JustLurking
1 month, 2 weeks ago
Thanks for adding it to the page.  I'm glad it is wiki-worthy.  :)
New Comment:
Move reply box to top
Log in or create an account to comment.