433 lines
23 KiB
Python
433 lines
23 KiB
Python
|
import time
|
||
|
import threading
|
||
|
import shared
|
||
|
import hashlib
|
||
|
import random
|
||
|
from struct import unpack, pack
|
||
|
import sys
|
||
|
import string
|
||
|
from subprocess import call # used when the API must execute an outside program
|
||
|
from pyelliptic.openssl import OpenSSL
|
||
|
|
||
|
import highlevelcrypto
|
||
|
from addresses import *
|
||
|
import helper_generic
|
||
|
import helper_bitcoin
|
||
|
import helper_inbox
|
||
|
import helper_sent
|
||
|
from helper_sql import *
|
||
|
import tr
|
||
|
from debug import logger
|
||
|
|
||
|
|
||
|
class objectProcessor(threading.Thread):
|
||
|
"""
|
||
|
The objectProcessor thread, of which there is only one, receives network
|
||
|
objecs (msg, broadcast, pubkey, getpubkey) from the receiveDataThreads.
|
||
|
"""
|
||
|
def __init__(self):
|
||
|
threading.Thread.__init__(self)
|
||
|
|
||
|
def run(self):
|
||
|
while True:
|
||
|
data = shared.objectProcessorQueue.get()
|
||
|
|
||
|
remoteCommand = data[4:16]
|
||
|
if remoteCommand == 'msg\x00\x00\x00\x00\x00\x00\x00\x00\x00':
|
||
|
self.processmsg(data)
|
||
|
|
||
|
def processmsg(self, data):
|
||
|
"""
|
||
|
We know that the POW and time are correct as they were checked by the
|
||
|
receiveDataThread.
|
||
|
"""
|
||
|
readPosition = 8
|
||
|
embeddedTime, = unpack('>I', data[readPosition:readPosition + 4])
|
||
|
|
||
|
# This section is used for the transition from 32 bit time to 64 bit
|
||
|
# time in the protocol.
|
||
|
if embeddedTime == 0:
|
||
|
embeddedTime, = unpack('>Q', data[readPosition:readPosition + 8])
|
||
|
readPosition += 8
|
||
|
else:
|
||
|
readPosition += 4
|
||
|
streamNumberAsClaimedByMsg, streamNumberAsClaimedByMsgLength = decodeVarint(
|
||
|
data[readPosition:readPosition + 9])
|
||
|
readPosition += streamNumberAsClaimedByMsgLength
|
||
|
inventoryHash = calculateInventoryHash(data)
|
||
|
initialDecryptionSuccessful = False
|
||
|
# Let's check whether this is a message acknowledgement bound for us.
|
||
|
if data[readPosition:] in shared.ackdataForWhichImWatching:
|
||
|
with shared.printLock:
|
||
|
print 'This msg IS an acknowledgement bound for me.'
|
||
|
|
||
|
del shared.ackdataForWhichImWatching[data[readPosition:]]
|
||
|
sqlExecute('UPDATE sent SET status=? WHERE ackdata=?',
|
||
|
'ackreceived', data[readPosition:])
|
||
|
shared.UISignalQueue.put(('updateSentItemStatusByAckdata', (data[readPosition:], tr.translateText("MainWindow",'Acknowledgement of the message received. %1').arg(unicode(
|
||
|
time.strftime(shared.config.get('bitmessagesettings', 'timeformat'), time.localtime(int(time.time()))), 'utf-8')))))
|
||
|
return
|
||
|
else:
|
||
|
with shared.printLock:
|
||
|
print 'This was NOT an acknowledgement bound for me.'
|
||
|
# print 'shared.ackdataForWhichImWatching', shared.ackdataForWhichImWatching
|
||
|
|
||
|
|
||
|
# This is not an acknowledgement bound for me. See if it is a message
|
||
|
# bound for me by trying to decrypt it with my private keys.
|
||
|
for key, cryptorObject in shared.myECCryptorObjects.items():
|
||
|
try:
|
||
|
decryptedData = cryptorObject.decrypt(
|
||
|
data[readPosition:])
|
||
|
toRipe = key # This is the RIPE hash of my pubkeys. We need this below to compare to the destination_ripe included in the encrypted data.
|
||
|
initialDecryptionSuccessful = True
|
||
|
with shared.printLock:
|
||
|
print 'EC decryption successful using key associated with ripe hash:', key.encode('hex')
|
||
|
break
|
||
|
except Exception as err:
|
||
|
pass
|
||
|
# print 'cryptorObject.decrypt Exception:', err
|
||
|
if not initialDecryptionSuccessful:
|
||
|
# This is not a message bound for me.
|
||
|
with shared.printLock:
|
||
|
print 'Length of time program spent failing to decrypt this message:', time.time() - self.messageProcessingStartTime, 'seconds.'
|
||
|
|
||
|
else:
|
||
|
# This is a message bound for me.
|
||
|
toAddress = shared.myAddressesByHash[
|
||
|
toRipe] # Look up my address based on the RIPE hash.
|
||
|
readPosition = 0
|
||
|
messageVersion, messageVersionLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += messageVersionLength
|
||
|
if messageVersion != 1:
|
||
|
print 'Cannot understand message versions other than one. Ignoring message.'
|
||
|
return
|
||
|
sendersAddressVersionNumber, sendersAddressVersionNumberLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += sendersAddressVersionNumberLength
|
||
|
if sendersAddressVersionNumber == 0:
|
||
|
print 'Cannot understand sendersAddressVersionNumber = 0. Ignoring message.'
|
||
|
return
|
||
|
if sendersAddressVersionNumber > 4:
|
||
|
print 'Sender\'s address version number', sendersAddressVersionNumber, 'not yet supported. Ignoring message.'
|
||
|
return
|
||
|
if len(decryptedData) < 170:
|
||
|
print 'Length of the unencrypted data is unreasonably short. Sanity check failed. Ignoring message.'
|
||
|
return
|
||
|
sendersStreamNumber, sendersStreamNumberLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
if sendersStreamNumber == 0:
|
||
|
print 'sender\'s stream number is 0. Ignoring message.'
|
||
|
return
|
||
|
readPosition += sendersStreamNumberLength
|
||
|
behaviorBitfield = decryptedData[readPosition:readPosition + 4]
|
||
|
readPosition += 4
|
||
|
pubSigningKey = '\x04' + decryptedData[
|
||
|
readPosition:readPosition + 64]
|
||
|
readPosition += 64
|
||
|
pubEncryptionKey = '\x04' + decryptedData[
|
||
|
readPosition:readPosition + 64]
|
||
|
readPosition += 64
|
||
|
if sendersAddressVersionNumber >= 3:
|
||
|
requiredAverageProofOfWorkNonceTrialsPerByte, varintLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += varintLength
|
||
|
print 'sender\'s requiredAverageProofOfWorkNonceTrialsPerByte is', requiredAverageProofOfWorkNonceTrialsPerByte
|
||
|
requiredPayloadLengthExtraBytes, varintLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += varintLength
|
||
|
print 'sender\'s requiredPayloadLengthExtraBytes is', requiredPayloadLengthExtraBytes
|
||
|
endOfThePublicKeyPosition = readPosition # needed for when we store the pubkey in our database of pubkeys for later use.
|
||
|
if toRipe != decryptedData[readPosition:readPosition + 20]:
|
||
|
with shared.printLock:
|
||
|
print 'The original sender of this message did not send it to you. Someone is attempting a Surreptitious Forwarding Attack.'
|
||
|
print 'See: http://world.std.com/~dtd/sign_encrypt/sign_encrypt7.html'
|
||
|
print 'your toRipe:', toRipe.encode('hex')
|
||
|
print 'embedded destination toRipe:', decryptedData[readPosition:readPosition + 20].encode('hex')
|
||
|
|
||
|
return
|
||
|
readPosition += 20
|
||
|
messageEncodingType, messageEncodingTypeLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += messageEncodingTypeLength
|
||
|
messageLength, messageLengthLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += messageLengthLength
|
||
|
message = decryptedData[readPosition:readPosition + messageLength]
|
||
|
# print 'First 150 characters of message:', repr(message[:150])
|
||
|
readPosition += messageLength
|
||
|
ackLength, ackLengthLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += ackLengthLength
|
||
|
ackData = decryptedData[readPosition:readPosition + ackLength]
|
||
|
readPosition += ackLength
|
||
|
positionOfBottomOfAckData = readPosition # needed to mark the end of what is covered by the signature
|
||
|
signatureLength, signatureLengthLength = decodeVarint(
|
||
|
decryptedData[readPosition:readPosition + 10])
|
||
|
readPosition += signatureLengthLength
|
||
|
signature = decryptedData[
|
||
|
readPosition:readPosition + signatureLength]
|
||
|
try:
|
||
|
if not highlevelcrypto.verify(decryptedData[:positionOfBottomOfAckData], signature, pubSigningKey.encode('hex')):
|
||
|
print 'ECDSA verify failed'
|
||
|
return
|
||
|
print 'ECDSA verify passed'
|
||
|
except Exception as err:
|
||
|
print 'ECDSA verify failed', err
|
||
|
return
|
||
|
with shared.printLock:
|
||
|
print 'As a matter of intellectual curiosity, here is the Bitcoin address associated with the keys owned by the other person:', helper_bitcoin.calculateBitcoinAddressFromPubkey(pubSigningKey), ' ..and here is the testnet address:', helper_bitcoin.calculateTestnetAddressFromPubkey(pubSigningKey), '. The other person must take their private signing key from Bitmessage and import it into Bitcoin (or a service like Blockchain.info) for it to be of any use. Do not use this unless you know what you are doing.'
|
||
|
|
||
|
# calculate the fromRipe.
|
||
|
sha = hashlib.new('sha512')
|
||
|
sha.update(pubSigningKey + pubEncryptionKey)
|
||
|
ripe = hashlib.new('ripemd160')
|
||
|
ripe.update(sha.digest())
|
||
|
fromAddress = encodeAddress(
|
||
|
sendersAddressVersionNumber, sendersStreamNumber, ripe.digest())
|
||
|
# Let's store the public key in case we want to reply to this
|
||
|
# person.
|
||
|
if sendersAddressVersionNumber <= 3:
|
||
|
sqlExecute(
|
||
|
'''INSERT INTO pubkeys VALUES (?,?,?,?,?)''',
|
||
|
ripe.digest(),
|
||
|
sendersAddressVersionNumber,
|
||
|
'\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF' + '\xFF\xFF\xFF\xFF' + decryptedData[messageVersionLength:endOfThePublicKeyPosition],
|
||
|
int(time.time()),
|
||
|
'yes')
|
||
|
# This will check to see whether we happen to be awaiting this
|
||
|
# pubkey in order to send a message. If we are, it will do the POW
|
||
|
# and send it.
|
||
|
self.possibleNewPubkey(ripe=ripe.digest())
|
||
|
elif sendersAddressVersionNumber >= 4:
|
||
|
sqlExecute(
|
||
|
'''INSERT INTO pubkeys VALUES (?,?,?,?,?)''',
|
||
|
ripe.digest(),
|
||
|
sendersAddressVersionNumber,
|
||
|
'\x00\x00\x00\x00\x00\x00\x00\x01' + decryptedData[messageVersionLength:endOfThePublicKeyPosition],
|
||
|
int(time.time()),
|
||
|
'yes')
|
||
|
# This will check to see whether we happen to be awaiting this
|
||
|
# pubkey in order to send a message. If we are, it will do the POW
|
||
|
# and send it.
|
||
|
self.possibleNewPubkey(address = fromAddress)
|
||
|
# If this message is bound for one of my version 3 addresses (or
|
||
|
# higher), then we must check to make sure it meets our demanded
|
||
|
# proof of work requirement.
|
||
|
if decodeAddress(toAddress)[1] >= 3: # If the toAddress version number is 3 or higher:
|
||
|
if not shared.isAddressInMyAddressBookSubscriptionsListOrWhitelist(fromAddress): # If I'm not friendly with this person:
|
||
|
requiredNonceTrialsPerByte = shared.config.getint(
|
||
|
toAddress, 'noncetrialsperbyte')
|
||
|
requiredPayloadLengthExtraBytes = shared.config.getint(
|
||
|
toAddress, 'payloadlengthextrabytes')
|
||
|
if not self.isProofOfWorkSufficient(data, requiredNonceTrialsPerByte, requiredPayloadLengthExtraBytes):
|
||
|
print 'Proof of work in msg message insufficient only because it does not meet our higher requirement.'
|
||
|
return
|
||
|
blockMessage = False # Gets set to True if the user shouldn't see the message according to black or white lists.
|
||
|
if shared.config.get('bitmessagesettings', 'blackwhitelist') == 'black': # If we are using a blacklist
|
||
|
queryreturn = sqlQuery(
|
||
|
'''SELECT label FROM blacklist where address=? and enabled='1' ''',
|
||
|
fromAddress)
|
||
|
if queryreturn != []:
|
||
|
with shared.printLock:
|
||
|
print 'Message ignored because address is in blacklist.'
|
||
|
|
||
|
blockMessage = True
|
||
|
else: # We're using a whitelist
|
||
|
queryreturn = sqlQuery(
|
||
|
'''SELECT label FROM whitelist where address=? and enabled='1' ''',
|
||
|
fromAddress)
|
||
|
if queryreturn == []:
|
||
|
print 'Message ignored because address not in whitelist.'
|
||
|
blockMessage = True
|
||
|
if not blockMessage:
|
||
|
toLabel = shared.config.get(toAddress, 'label')
|
||
|
if toLabel == '':
|
||
|
toLabel = toAddress
|
||
|
|
||
|
if messageEncodingType == 2:
|
||
|
subject, body = self.decodeType2Message(message)
|
||
|
elif messageEncodingType == 1:
|
||
|
body = message
|
||
|
subject = ''
|
||
|
elif messageEncodingType == 0:
|
||
|
print 'messageEncodingType == 0. Doing nothing with the message. They probably just sent it so that we would store their public key or send their ack data for them.'
|
||
|
else:
|
||
|
body = 'Unknown encoding type.\n\n' + repr(message)
|
||
|
subject = ''
|
||
|
if messageEncodingType != 0:
|
||
|
t = (inventoryHash, toAddress, fromAddress, subject, int(
|
||
|
time.time()), body, 'inbox', messageEncodingType, 0)
|
||
|
helper_inbox.insert(t)
|
||
|
|
||
|
shared.UISignalQueue.put(('displayNewInboxMessage', (
|
||
|
inventoryHash, toAddress, fromAddress, subject, body)))
|
||
|
|
||
|
# If we are behaving as an API then we might need to run an
|
||
|
# outside command to let some program know that a new message
|
||
|
# has arrived.
|
||
|
if shared.safeConfigGetBoolean('bitmessagesettings', 'apienabled'):
|
||
|
try:
|
||
|
apiNotifyPath = shared.config.get(
|
||
|
'bitmessagesettings', 'apinotifypath')
|
||
|
except:
|
||
|
apiNotifyPath = ''
|
||
|
if apiNotifyPath != '':
|
||
|
call([apiNotifyPath, "newMessage"])
|
||
|
|
||
|
# Let us now check and see whether our receiving address is
|
||
|
# behaving as a mailing list
|
||
|
if shared.safeConfigGetBoolean(toAddress, 'mailinglist'):
|
||
|
try:
|
||
|
mailingListName = shared.config.get(
|
||
|
toAddress, 'mailinglistname')
|
||
|
except:
|
||
|
mailingListName = ''
|
||
|
# Let us send out this message as a broadcast
|
||
|
subject = self.addMailingListNameToSubject(
|
||
|
subject, mailingListName)
|
||
|
# Let us now send this message out as a broadcast
|
||
|
message = time.strftime("%a, %Y-%m-%d %H:%M:%S UTC", time.gmtime(
|
||
|
)) + ' Message ostensibly from ' + fromAddress + ':\n\n' + body
|
||
|
fromAddress = toAddress # The fromAddress for the broadcast that we are about to send is the toAddress (my address) for the msg message we are currently processing.
|
||
|
ackdata = OpenSSL.rand(
|
||
|
32) # We don't actually need the ackdata for acknowledgement since this is a broadcast message but we can use it to update the user interface when the POW is done generating.
|
||
|
toAddress = '[Broadcast subscribers]'
|
||
|
ripe = ''
|
||
|
|
||
|
t = ('', toAddress, ripe, fromAddress, subject, message, ackdata, int(
|
||
|
time.time()), 'broadcastqueued', 1, 1, 'sent', 2)
|
||
|
helper_sent.insert(t)
|
||
|
|
||
|
shared.UISignalQueue.put(('displayNewSentMessage', (
|
||
|
toAddress, '[Broadcast subscribers]', fromAddress, subject, message, ackdata)))
|
||
|
shared.workerQueue.put(('sendbroadcast', ''))
|
||
|
|
||
|
if self.isAckDataValid(ackData):
|
||
|
print 'ackData is valid. Will process it.'
|
||
|
#self.ackDataThatWeHaveYetToSend.append(
|
||
|
# ackData) # When we have processed all data, the processData function will pop the ackData out and process it as if it is a message received from our peer.
|
||
|
shared.objectProcessorQueue.put(ackData)
|
||
|
# Display timing data
|
||
|
timeRequiredToAttemptToDecryptMessage = time.time(
|
||
|
) - self.messageProcessingStartTime
|
||
|
shared.successfullyDecryptMessageTimings.append(
|
||
|
timeRequiredToAttemptToDecryptMessage)
|
||
|
sum = 0
|
||
|
for item in shared.successfullyDecryptMessageTimings:
|
||
|
sum += item
|
||
|
with shared.printLock:
|
||
|
print 'Time to decrypt this message successfully:', timeRequiredToAttemptToDecryptMessage
|
||
|
print 'Average time for all message decryption successes since startup:', sum / len(shared.successfullyDecryptMessageTimings)
|
||
|
|
||
|
# We have inserted a pubkey into our pubkey table which we received from a
|
||
|
# pubkey, msg, or broadcast message. It might be one that we have been
|
||
|
# waiting for. Let's check.
|
||
|
def possibleNewPubkey(self, ripe=None, address=None):
|
||
|
# For address versions <= 3, we wait on a key with the correct ripe hash
|
||
|
if ripe != None:
|
||
|
if ripe in shared.neededPubkeys:
|
||
|
print 'We have been awaiting the arrival of this pubkey.'
|
||
|
del shared.neededPubkeys[ripe]
|
||
|
sqlExecute(
|
||
|
'''UPDATE sent SET status='doingmsgpow' WHERE toripe=? AND (status='awaitingpubkey' or status='doingpubkeypow') and folder='sent' ''',
|
||
|
ripe)
|
||
|
shared.workerQueue.put(('sendmessage', ''))
|
||
|
else:
|
||
|
with shared.printLock:
|
||
|
print 'We don\'t need this pub key. We didn\'t ask for it. Pubkey hash:', ripe.encode('hex')
|
||
|
# For address versions >= 4, we wait on a pubkey with the correct tag.
|
||
|
# Let us create the tag from the address and see if we were waiting
|
||
|
# for it.
|
||
|
elif address != None:
|
||
|
status, addressVersion, streamNumber, ripe = decodeAddress(address)
|
||
|
tag = hashlib.sha512(hashlib.sha512(encodeVarint(
|
||
|
addressVersion) + encodeVarint(streamNumber) + ripe).digest()).digest()[32:]
|
||
|
if tag in shared.neededPubkeys:
|
||
|
print 'We have been awaiting the arrival of this pubkey.'
|
||
|
del shared.neededPubkeys[tag]
|
||
|
sqlExecute(
|
||
|
'''UPDATE sent SET status='doingmsgpow' WHERE toripe=? AND (status='awaitingpubkey' or status='doingpubkeypow') and folder='sent' ''',
|
||
|
ripe)
|
||
|
shared.workerQueue.put(('sendmessage', ''))
|
||
|
|
||
|
def isAckDataValid(self, ackData):
|
||
|
if len(ackData) < 24:
|
||
|
print 'The length of ackData is unreasonably short. Not sending ackData.'
|
||
|
return False
|
||
|
if ackData[0:4] != '\xe9\xbe\xb4\xd9':
|
||
|
print 'Ackdata magic bytes were wrong. Not sending ackData.'
|
||
|
return False
|
||
|
ackDataPayloadLength, = unpack('>L', ackData[16:20])
|
||
|
if len(ackData) - 24 != ackDataPayloadLength:
|
||
|
print 'ackData payload length doesn\'t match the payload length specified in the header. Not sending ackdata.'
|
||
|
return False
|
||
|
if ackData[4:16] != 'getpubkey\x00\x00\x00' and ackData[4:16] != 'pubkey\x00\x00\x00\x00\x00\x00' and ackData[4:16] != 'msg\x00\x00\x00\x00\x00\x00\x00\x00\x00' and ackData[4:16] != 'broadcast\x00\x00\x00':
|
||
|
return False
|
||
|
readPosition = 24 # bypass the network header
|
||
|
if not shared.isProofOfWorkSufficient(ackData[readPosition:readPosition+10]):
|
||
|
print 'Proof of work in msg message insufficient.'
|
||
|
return
|
||
|
|
||
|
readPosition += 8 # bypass the POW nonce
|
||
|
embeddedTime, = unpack('>I', data[readPosition:readPosition + 4])
|
||
|
|
||
|
# This section is used for the transition from 32 bit time to 64 bit
|
||
|
# time in the protocol.
|
||
|
if embeddedTime == 0:
|
||
|
embeddedTime, = unpack('>Q', data[readPosition:readPosition + 8])
|
||
|
readPosition += 8
|
||
|
else:
|
||
|
readPosition += 4
|
||
|
|
||
|
if embeddedTime > int(time.time()) + 10800:
|
||
|
print 'The time in the msg message is too new. Ignoring it. Time:', embeddedTime
|
||
|
return
|
||
|
if embeddedTime < int(time.time()) - shared.maximumAgeOfAnObjectThatIAmWillingToAccept:
|
||
|
print 'The time in the msg message is too old. Ignoring it. Time:', embeddedTime
|
||
|
return
|
||
|
streamNumberAsClaimedByMsg, streamNumberAsClaimedByMsgLength = decodeVarint(
|
||
|
data[readPosition:readPosition + 9])
|
||
|
if not streamNumberAsClaimedByMsg in shared.streamsInWhichIAmParticipating:
|
||
|
print 'The stream number encoded in this msg (' + str(streamNumberAsClaimedByMsg) + ') message does not match a stream number on which it was received. Ignoring it.'
|
||
|
return
|
||
|
readPosition += streamNumberAsClaimedByMsgLength
|
||
|
self.inventoryHash = calculateInventoryHash(data)
|
||
|
shared.numberOfInventoryLookupsPerformed += 1
|
||
|
shared.inventoryLock.acquire()
|
||
|
if self.inventoryHash in shared.inventory:
|
||
|
print 'We have already received this msg message. Ignoring.'
|
||
|
shared.inventoryLock.release()
|
||
|
return
|
||
|
elif shared.isInSqlInventory(self.inventoryHash):
|
||
|
print 'We have already received this msg message (it is stored on disk in the SQL inventory). Ignoring it.'
|
||
|
shared.inventoryLock.release()
|
||
|
return
|
||
|
##################
|
||
|
return True
|
||
|
|
||
|
def decodeType2Message(self, message):
|
||
|
bodyPositionIndex = string.find(message, '\nBody:')
|
||
|
if bodyPositionIndex > 1:
|
||
|
subject = message[8:bodyPositionIndex]
|
||
|
# Only save and show the first 500 characters of the subject.
|
||
|
# Any more is probably an attack.
|
||
|
subject = subject[:500]
|
||
|
body = message[bodyPositionIndex + 6:]
|
||
|
else:
|
||
|
subject = ''
|
||
|
body = message
|
||
|
# Throw away any extra lines (headers) after the subject.
|
||
|
if subject:
|
||
|
subject = subject.splitlines()[0]
|
||
|
return subject, body
|
||
|
|
||
|
def addMailingListNameToSubject(self, subject, mailingListName):
|
||
|
subject = subject.strip()
|
||
|
if subject[:3] == 'Re:' or subject[:3] == 'RE:':
|
||
|
subject = subject[3:].strip()
|
||
|
if '[' + mailingListName + ']' in subject:
|
||
|
return subject
|
||
|
else:
|
||
|
return '[' + mailingListName + '] ' + subject
|