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 1271, python 981, program 220, code 130, 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
page
21
page
22
page
23
page
24
page
25
page
26
page
27
page
28
page
29
page
30
page
31
page
32
page
33
page
34
page
35
page
36
page
37
page
38
page
39
page
40
page
41
page
42
page
43
page
44
page
45
page
46
page
47
page
48
page
49
page
50
page
51
page
52
page
53
page
54
page
55
page
56
page
57
page
58
page
59
page
60
page
61
page
62
page
63
page
64
page
65
page
66
page
67
page
68
page
69
page
70
page
71
page
72
page
73
page
74
page
75
page
76
page
77
page
78
page
79
page
80
page
81
page
82
page
83
page
84
page
85
page
86
page
87
page
88
page
89
page
90
page
91
page
92
page
93
page
94
page
95
page
96
page
97
page
98
page
99
page
100
page
101
page
102
page
103
page
104
page
105
page
106
page
107
page
108
page
109
page
110
page
111
page
112
page
113
page
114
page
115
page
116
page
117
page
118
page
119
page
120
page
121
page
122
page
123
page
124
page
125
page
126
page
127
page
128
page
129
page
130
page
131
page
132
page
133
page
134
page
135
page
136
page
137
page
138
page
139
page
140
page
141
page
142
page
143
page
144
page
145
page
146
page
147
page
148
page
149
page
150
page
151
page
152
page
153
page
154
page
155
page
156
page
157
page
158
page
159
page
160
page
161
page
162
page
163
page
164
page
165
page
166
page
167
page
168
page
169
page
170
page
171
page
172
page
173
page
174
page
175
page
176
page
177
page
178
page
179
page
180
page
181
page
182
page
183
page
184
page
185
page
186
page
187
page
188
page
189
page
190
page
191
page
192
page
193
page
194
page
195
page
196
page
197
page
198
page
199
page
200
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
page
21
page
22
page
23
page
24
page
25
page
26
page
27
page
28
page
29
page
30
page
31
page
32
page
33
page
34
page
35
page
36
page
37
page
38
page
39
page
40
page
41
page
42
page
43
page
44
page
45
page
46
page
47
page
48
page
49
page
50
page
51
page
52
page
53
page
54
page
55
page
56
page
57
page
58
page
59
page
60
page
61
page
62
page
63
page
64
page
65
page
66
page
67
page
68
page
69
page
70
page
71
page
72
page
73
page
74
page
75
page
76
page
77
page
78
page
79
page
80
page
81
page
82
page
83
page
84
page
85
page
86
page
87
page
88
page
89
page
90
page
91
page
92
page
93
page
94
page
95
page
96
page
97
page
98
page
99
page
100
page
101
page
102
page
103
page
104
page
105
page
106
page
107
page
108
page
109
page
110
page
111
page
112
page
113
page
114
page
115
page
116
page
117
page
118
page
119
page
120
page
121
page
122
page
123
page
124
page
125
page
126
page
127
page
128
page
129
page
130
page
131
page
132
page
133
page
134
page
135
page
136
page
137
page
138
page
139
page
140
page
141
page
142
page
143
page
144
page
145
page
146
page
147
page
148
page
149
page
150
page
151
page
152
page
153
page
154
page
155
page
156
page
157
page
158
page
159
page
160
page
161
page
162
page
163
page
164
page
165
page
166
page
167
page
168
page
169
page
170
page
171
page
172
page
173
page
174
page
175
page
176
page
177
page
178
page
179
page
180
page
181
page
182
page
183
page
184
page
185
page
186
page
187
page
188
page
189
page
190
page
191
page
192
page
193
page
194
page
195
page
196
page
197
page
198
page
199
page
200
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,271, python 981, program 220, code 130, utility 20
Details
Type: Writing - Document
Published: 17 hrs, 4 mins ago
Rating: General

MD5 Hash for Page 1... Show Find Identical Posts [?]
Stats
23 views
0 favorites
0 comments

BBCode Tags Show [?]
 
New Comment:
Move reply box to top
Log in or create an account to comment.