aboutsummaryrefslogtreecommitdiffstats

import threading
from os import getenv
from queue import Queue

from cherrypy.process import plugins
import cherrypy

import re

from imap_tools import MailBox, MailMessage, MailMessageFlags

from mariadb import Connection, Cursor

from .imap_pool import MailBoxPool, PoolEmpty

from .db import DBPoolManager
from .types import Site, Bug

from .smtpplugin import SmtpPlugin

class ImapPlugin(plugins.SimplePlugin):
	def __init__(self,bus,dbpool:DBPoolManager,site:Site):
		plugins.SimplePlugin.__init__(self,bus)
		self.dbpool=dbpool
		self.site=site
		# block: get configuration variables from env
		self.imap_server=getenv("ACIT_IMAP_SERVER")
		self.imap_user=getenv("ACIT_IMAP_USER")
		self.imap_pass=getenv("ACIT_IMAP_PASS")
		self.imap_port=int(getenv("ACIT_IMAP_PORT",993))

		if not ( self.imap_server and self.imap_user and self.imap_pass):
			raise ValueError("Missing ACIT_IMAP_SERVER, ACIT_IMAP_USER, or ACIT_IMAP_PASS")
		# end block

		self.mlog("IMAP config: %s @ %s : %d"%(self.imap_user,self.imap_server,self.imap_port))

		# block: make storage attributes
		self.mailin_thread=None
		self.stopping=threading.Event()

		self.mailout_queue=Queue()
		self.mailbox_per_thread={}
		# end block

		self.mbpool=MailBoxPool(
			host=self.imap_server,port=self.imap_port,username=self.imap_user,password=self.imap_pass,
			connection_n=int(getenv("ACIT_IMAP_POOL_SIZE",4))
			)
		
		self.smtp=SmtpPlugin()

		self.index_lock=threading.Lock()
	
	def start(self):
		#self.mlog("Starting email-related loops, doing setup") # no need to log for quick stuff like this
		# block: prepare threads
		self.stopping.clear()
		self.mailin_thread=threading.Thread(target=self.imap_loop_controller)
		# end block

		# block: fetch configuration around email address usage from env
		self.uses_aliases=getenv("ACIT_MAIL_USES_ALIASES",False)

		name,domain=self.imap_user.rsplit("@",1)
		self.emaildomain=getenv("ACIT_MAIL_DOMAIN",domain)
		self.emailname=getenv("ACIT_MAIL_NAME",name)

		if self.uses_aliases: # if we use aliases, we match project#bug@example.com
			self.addr_format="{proj}#{bug}@"+self.emaildomain
			self.addr_regex="[^@#]*(#[0-9]*)?@"+ self.emaildomain.replace(".","\\.")

		else: # if we use plus addresses, we match name+project#bug@example.com
			self.addr_format=self.emailname+"+{proj}#{bug}@"+self.emaildomain
			self.addr_regex=self.emailname+r"+[^@#]*(#[0-9]*)?@"+self.emaildomain.replace(".","\\.")
		# end block

		self.smtp.domain=self.emaildomain

		self.msgids_sorted_not_indexed=[]

		self.mlog("Starting mailbox pool. This can take a while.")
		self.mbpool.open()
		pool=self.mbpool.get_pool_size()
		if not pool:
			self.mlog("Failed to put anything in the pool (%d)."%pool)
			self.mlog("Errors:")
			import traceback
			for e in self.mbpool.errors:
				tb=traceback.format_exception(e)
				self.mlog(repr(e),tb)

			self.mlog("\n\nNOTE!!! THIS WILL MAKE ACIT UNUSABLE, BECAUSE IT WON'T BE ABLE TO INTERACT WITH EMAIL.\n"
				 " Please check your IMAP configuration.\n")

		if not self.dbpool.poolStartedEvent.is_set():
			cherrypy.engine.subscribe("db-started",self.update_index)
		else:
			self.update_index()

	
		self.mlog("Starting threads")
		self.mailin_thread.start()
		self.smtp.start()

		self.mlog("Done")
	
	def update_index(self,folders:list[str,int]=None):
		"Note folder is a list of tuples consisting of trackername, bugid"
		with self.dbpool.get_connection() as conn, conn.cursor() as cur, self.get_MailBox() as mb, self.index_lock:
			cur.execute("CREATE TABLE IF NOT EXISTS msgindex ("
				"tracker VARCHAR(80),"
				"bugid INT,"
				"messageid TINYTEXT UNIQUE"
				")"
				)

			if not folders:
				self.mlog("Full-updating message index...")
				cur.execute("SELECT tracker,bugid FROM bugs")
				folders=[ (tracker, bugid) for tracker,bugid in cur ]

			for tracker,bugid in folders:
				try:
					#self.mlog("Updating index for %s/%d"%(tracker,bugid))
					mb.folder.set(self.get_bug_folder(mb,tracker,bugid))
					for msg in mb.fetch():
						if "message-id" in msg.headers:
							msgid=msg.headers["message-id"]
							if msgid in self.msgids_sorted_not_indexed:
								self.msgids_sorted_not_indexed.remove(msgid)
							cur.execute("REPLACE INTO msgindex VALUES (?,?,?)",(tracker,bugid,msgid[:255]))

				except Exception as e:
					self.mlog("Error while indexing mailbox of %s/%d: %s"%(tracker,bugid,e))

			conn.commit()
			self.mlog("Updated message index")
	
	def find_in_reply_to(self,messageid):
		with self.dbpool.get_connection() as conn, conn.cursor() as cur:
			cur.execute("SELECT tracker,bugid FROM msgindex WHERE messageid=? LIMIT 1",(messageid,))
			return cur.fetchone()
	
	def format_emailaddr(self,project=None,bugid=None,subject=None,headers={}):
		from urllib.parse import quote as quote
		email=self.addr_format.format(proj=project, bug=bugid)
		email=email.replace("None#","#")
		email=email.replace("#None",'')
		email=email.replace("+None",'')
		if subject:
			email+='?subject='
			email+=quote(subject, safe='')
		for key,value in headers.items():
			email+='?%s=%s'%(key,quote(value,safe=''))

		return email


	def get_full_projectname(self,proj):
		"returns results from ``self.site.findtrackers()`` without modification"
		return self.site.findtrackers(proj)


	def stripInfoFromMailAddr(self,address:str):
		"matches bugnumber and projectname from email address"
		addr=address.removesuffix("@"+self.emaildomain)

		if not self.uses_aliases:
			if not '+' in addr:
				return (None,None)

			addr=addr.removeprefix(self.emailname)
			addr=addr.removeprefix("+")

		
		if '#' in addr:
			proj,bug=addr.rsplit("#",1)
		else:
			proj=addr
			bug=None

		return (proj,bug)

	def ensurefolder(self,mailbox:MailBox,*path): 
		"makes sure a folder exists on the mailserver"
		# assuming from rfc3501 section 7.2.2:
		# > All children of a top-level hierarchy node MUST use
      # > the same separator character.
		# hoping the whole mailserver uses the same delimiter
		delim=mailbox.folder.list('')[0].delim
		fname=delim.join([ str(i) for i in path])
		if not mailbox.folder.exists(fname):
			mailbox.folder.create(fname)
			mailbox.folder.subscribe(fname,True)
		return fname
	
	def get_bug_folder(self,mailbox:MailBox,proj,bug=None):
		"helper to format the path to a bug's folder and ensure it's existence"
		path=["bugs"]
		path.extend(proj.split('/'))
		if bug: path.append(str(bug))
		return self.ensurefolder(mailbox,*path)
	
	def get_MailBox(self):
		"get a new mailbox connection from the pool"
		return self.mbpool.get_box()

	def imap_magic(self):
		if self.stopping.wait(5): # if not stopping, this just times out after x seconds, so this is a nice timer
			return
		refreshable={}
		with self.get_MailBox() as mailbox:
			mailbox.folder.set("INBOX")

			if mailbox.folder.status()["MESSAGES"]>0:

				for msg in mailbox.fetch():
					try:
						target=self.handle_email(mailbox,msg)

						if "message-id" in msg.headers: self.msgids_sorted_not_indexed.append(msg.headers["message-id"])

						if target:
							proj,bug=target
							refreshable.setdefault(proj,[]).append(bug)

					except Exception:
						import traceback
						self.mlog("Error processing email: ",traceback.format_exc())
						self.mlog("Message-ID:",msg.headers["message-id"] if "message-id" in msg.headers else "None")
						self.mlog("Moving mail to INBOX/Errors.")
						if "message-id" in msg.headers:
							self.mail_error(msg=msg,notice="There was an error processing your email.\nPlease contact the instance administrator.")
						self.move_errored_mail(mailbox,msg)

		# block: update all webpages that received new mail
		for proj,bugs in refreshable.items():
			# project page needs to be regenerated too (counters)
			cherrypy.engine.publish("regen",proj,None)
			for bug in bugs:
				cherrypy.engine.publish("regen",proj,bug)

		# end block

		if refreshable:
			self.update_index([proj,bug] for bug in bugs for proj,bugs in  refreshable.items())
	
	def get_newest_from_mailbox(self,mailbox,proj,bug):
		folder=mailbox.folder.get()
		mailbox.folder.set(self.get_bug_folder(mailbox,proj,bug),True)
		for m in mailbox.fetch(limit=1,reverse=True):
			break
		if folder:
			mailbox.folder.set(folder)
		return m
	

	def handle_email(self,mailbox:MailBox,msg:MailMessage):
		if "message-id" in msg.headers:
			msgid=msg.headers["message-id"]
			skip=False
			if msgid in self.msgids_sorted_not_indexed:
				skip=True
			else:
				with self.dbpool.get_connection() as conn, conn.cursor() as cur:
					cur.execute("SELECT messageid FROM msgindex WHERE messageid=? LIMIT 1",(msgid))
					if cur.fetchone():
						skip=True
			if skip:
				self.mlog("An email with messageid %s already exists, skipping"%msgid)
				mailbox.flag([msg.uid],MailMessageFlags.SEEN,value=False) # mark as unread
				mailbox.move([msg.uid],self.ensurefolder(mailbox,"INBOX","duplicates"))
				return

		self.mlog("Processing email with subject '%s'"%msg.subject)

		
		if "x-acit-delete-when-sender" in msg.headers and "sender" in msg.headers:
			if msg.headers["x-acit-delete-when-sender"]==msg.headers["sender"]:
				self.mlog("Header requested deletion, deleting email")
				mailbox.delete([msg.uid])
				return

		if "x-acit-is-outgoing" in msg.headers and "sender" in msg.headers:
			if msg.headers["x-acit-is-outgoing"]==msg.headers["sender"]:
				self.mlog("Header indicated this is Sent mail, moving")
				mailbox.move([msg.uid],self.ensurefolder(mailbox,"Sent"))
				return


		proj=None
		bug=None
		for addr in msg.to + msg.cc + msg.bcc + msg.reply_to:
			if re.fullmatch(self.addr_regex,addr):
				newproj,newbug=self.stripInfoFromMailAddr(addr)
				if newproj and not proj or proj==newproj: # if we can set proj, set it.
					proj=newproj
					if newbug and not bug: # if we can set a bugid, set it too.
						# the reason this block is below the project block, is to ensure we don't set a bugid with an unrelated proj
						bug=newbug

				if newproj.startswith("$token$="):
					proj=newproj
					break # ensure no further things get set, we wanna use the token


		if msg.subject.startswith("SUBSCRIBE"):
			from_=self.format_emailaddr(project=proj,bugid=bug)

			if not proj:
				self.smtp.sendmail(
					replyto=msg,
					from_=from_,
					body="Could not subscribe you, please specify a project and optionally a bug in the target address"
					)
				self.mlog("")
				mailbox.delete([msg.uid])
				return

			self.mlog("Subscribing",msg.from_,"to",proj,bug)
			with self.dbpool.get_connection() as conn, conn.cursor() as cur:
				cur.execute("INSERT INTO subscribers (tracker,bugid,email) VALUES (?,?,?)",(proj,bug,msg.from_))

			if not bug:
				bug="all bugs"
			else:
				bug="bug "+bug

			self.smtp.sendmail(
				replyto=msg,
				from_=from_,
				body="Subscribed to "+bug+" in tracker "+proj+"\nTo unsubscribe, send an email with subjectline `UNSUBSCRIBE` to "+from_
				)
			mailbox.delete([msg.uid])

			return

		if msg.subject.startswith("UNSUBSCRIBE"):
			self.mlog("Unsubscribing",msg.from_,"from",proj,bug)
			with self.dbpool.get_connection() as conn, conn.cursor() as cur:
				cur.execute("DELETE FROM subscribers WHERE tracker=? AND bugid=? AND email=?)",(proj,bug,msg.from_))
			mailbox.delete([msg.uid])
			return
		
		# block: handle secure email which uses $token$ as projectname
		if proj and proj.startswith('$token$='):
			token=proj.removeprefix('$token$=')
			self.mlog("This email is sent to a secure token, resolving (matching against %s)."%token)
			with self.dbpool.get_connection() as conn, conn.cursor() as cur:
				cur.execute("SELECT email,tracker,bugid,usedin FROM tokens WHERE token=? AND usedin IS NULL LIMIT 1",(token,))
				data=cur.fetchone()
				if data and data[0]==msg.from_: # sender matches, or token was used before
					self.mlog("Resolved to",data)
					proj=data[1]
					bug=data[2]
					try:
						self.secure_process_commands(mailbox,msg,conn,cur,proj,bug)
					except Exception as e:
						self.mlog("Error. IDK.",traceback=True)
						self.mail_error("Error processing commands:\n "+repr(e)+"\nPlease contact a site admin.")

					# set token used state
					cur.execute("UPDATE tokens SET usedin=? WHERE token=?",((msg.headers["message-id"] if "message-id" else "nomessageid"),token))
					conn.commit()
				else:
					self.mlog("Couldn't resolve.")
		# end block

		# block: handle in-reply-to headers
		if "in-reply-to" in msg.headers:
			self.mlog("Using In-Reply-To header to figure out meta")
			replyid=msg.headers["in-reply-to"]
			data=self.find_in_reply_to(replyid)
			if data:
				proj,bug=data

			elif proj and bug:
				pass # just ignore this email if we already found a project and bug

			elif "RETRYING" in msg.flags:
				self.mail_error(msg,"No such email: "+msg.headers["in-reply-to"])
				self.move_errored_mail(mailbox,msg)

			else: # try again later, maybe we need to index first or handle some other email first
				mailbox.flag([msg.uid],"RETRYING")
				self.mlog("Message-ID not in index, trying again next round")
				return
		# end block

		# block: make sure a project was specified
		if not proj:
			self.mlog("No project specified.")
			self.mail_error(msg,"Please specify a project by mailing to:\n "+\
					("" if self.uses_aliases else self.emailname+"+")+"PROJECT@"+self.emaildomain+\
					"\nwhere PROJECT is the name of your target project")
			self.move_errored_mail(mailbox,msg)
			return
		# end block

		# block: make sure project exists
		proj_matches=self.get_full_projectname(proj)
		if not proj_matches:
			self.mlog("Received email for nonexistent project %s"%proj)
			self.mail_error(msg,notice="Project '%s' doesn't exist"%proj)
			self.move_errored_mail(mailbox,msg)
			return
		# end block

		# block: make sure only 1 project matches
		if len(proj_matches)>1:
			self.mlog("Conficting projectname. Sending projectlist.")
			self.mail_error(msg,notice="Multiple projects found to match your query. Please specify. Options:\n%s"%"\n".join(proj_matches))
			self.move_errored_mail(mailbox,msg)
			return

		proj=proj_matches[0]
		# end block

		# block: make new bug if needed
		if not bug:
			if re.match(r"^\[PATCH.*\]",msg.subject):
				bugtype="PATCH"
			elif re.match(r"^\[DISCUSSION.*\]",msg.subject):
				bugtype="DISCUS"
			else:
				bugtype="BUG"
			bugobj=self.site.newbug(proj,bugtype=bugtype)
			bugobj.subject=msg.subject[:1024]
			bugobj.description=\
				'No description written.\nFirst email in thread:\n\n'+msg.text[:65535]

			if bugtype=="PATCH":
				bugobj.description=bugobj.description[:bugobj.description.find("\n---\n")]

			self.mlog("Assigned new bugnr %d to '%s'"%(bugobj.bugid,msg.subject))
			bug=bugobj.bugid
		else:
			bugobj=self.site.getbug(proj,bug)
		# end block

		# parse bug id to make sure it's an int
		try:
			bug=int(bug)
		except ValueError as e:
			self.mlog("Error decoding value to int:",e,traceback=True)
			self.mail_error(msg,notice="Exception while trying to convert bug number to integer",exception=e)
			self.move_errored_mail(mailbox,msg)
			return
		# end block
		
		# block: move mail to folder specific for this project/bug
		self.mlog("Email '%s' into %s/%d"%(msg.subject,proj,bug))
		try:
			path=self.get_bug_folder(mailbox,proj,bug)
			mailbox.move([msg.uid], path)

			if not msg.from_ in bugobj.subscribers:
				bugobj.addsubscriber(msg.from_)

			if msg.subject.startswith("SECURE"):
				self.secure_generate_token(mailbox,msg,proj,bug)
				mailbox.delete([msg.uid])
				return (proj,bug)

			if not "in-reply-to" in msg.headers:
				replyto=self.get_newest_from_mailbox(mailbox,proj,bug)


			replyto=self.get_newest_from_mailbox(mailbox,proj,bug)
			self.fwd_to_list(msg,bugobj,replyto=replyto)
		except Exception:
			self.mlog("Error processing email '%s' for %s/%d"%(msg.subject,proj,bug),traceback=True)
			self.move_errored_mail(mailbox,msg)
		# end block
		
		return (proj,bug)
	
	def imap_loop_controller(self):
		"Responsible for running imap_magic() repeatedly, and handling its errors."
		threading.current_thread().setName("IMAPrunner")
		self.mlog("IMAP monitor thread started.")
		while not self.stopping.is_set():
			try:
				self.imap_magic()
			except PoolEmpty:
				self.mlog("IMAP pool empty, unable to continue.")
				break
			except Exception:
				import traceback
				exc=traceback.format_exc()
				self.mlog("Exception occured:\n%s"%exc)
				self.mlog("!! this may lead to emails not appearing or appearing later !!")

		self.mlog("IMAP monitor thread stopped.")
	
	def mail_error(self,msg:MailMessage,notice:str=None,exception:Exception=None):
		from_=self.format_emailaddr()
		self.mlog("Sending error report.")

		body="An error occured while processing your email with subject:\n"
		body+="> "+msg.subject
		if notice:
			body+="\n\nThe mail worker reports:\n> "+notice.replace("\n","\n> ")
		if exception:
			body+="\n\nThe following error was included:\n> "+repr(exception).replace("\n","\n> ")
		body+="\n\nIf you think this is a bug, please report it (mail to acit@bugs.vosjedev.net).\n"
		body+="Make sure to attach both this email and the email that triggered this bug."

		self.smtp.sendmail(replyto=msg,from_=from_,body=body)

	def move_errored_mail(self,mailbox:MailBox,msg:MailMessage,):
		target=self.ensurefolder(mailbox,"INBOX","Errors")
		return mailbox.move(msg.uid,target)
	

	def email_postprocessor_fix_acit_a_thousand_times(self,email,realacitfrom=None):
		if not realacitfrom:
			realacitfrom=self.format_emailaddr()

		acit_re_added=False
		for header in "to", "cc":
			if not header in email:
				continue
			cur=email[header]
			new=""
			while True:
				match=re.search(self.addr_regex+"(, )?",cur)
				if match:
					#self.mlog("Removing",match.group(),"from",header)
					new+=match.string[:match.start()]
					cur=match.string[match.end():]
					if not acit_re_added:
						#self.mlog("Readding",realacitfrom,"instead")
						new+=realacitfrom
						acit_re_added=True
					#self.mlog(header,"= ",new,"<<",cur)
				else:
					break

			if new:
				del email[header]
				email[header]=new

		if not ( realacitfrom in email["to"] or realacitfrom in email.get("cc","") ):
			cc=( email["cc"]+", " if email["cc"] else "" )
			del email["cc"]
			email["Cc"]=cc+realacitfrom
			

	def fwd_to_list(self,msg:MailMessage,bug:Bug,replyto:MailMessage=None,bcc:list=None):
		import random, string
		if not bcc:
			tracker=self.site.gettracker(bug.tracker)
			bcc=bug.subscribers + tracker.subscribers

		self.mlog("Bcc:",bcc)

		securereplytos={}
		with self.dbpool.get_connection() as conn, conn.cursor() as cur:
			for email in bcc:
				try:
					cur.execute("SELECT email FROM permissiontable WHERE email=? AND (tracker=? OR tracker IS NULL) AND (bugid=? OR bugid IS NULL) LIMIT 1",(email,bug.tracker,bug.bugid))
					if cur.fetchone():
						token=''.join(( random.choice(string.ascii_letters) for i in range(40)))
						cur.execute("UPDATE tokens SET usedin='@@UNUSED@@' WHERE email=? AND tracker=? AND bugid=? AND usedin IS NULL",(email,bug.tracker,bug.bugid))

						cur.execute("INSERT INTO tokens (email,token,tracker,bugid) VALUES (?,?,?,?)",(msg.from_,token,bug.tracker,bug.bugid))
						securereplytos[email]=token
						self.mlog("Token",token,"generated for",email)

				except Exception:
					self.mlog("Error generating a secure email token for",email,traceback=True)

			conn.commit()

		for email in securereplytos.keys():
			self.mlog("Removing",email,"from bcc")
			while email in bcc:
				bcc.remove(email)

		self.mlog("Bcc 2:",bcc)

		from_=self.format_emailaddr(bug.tracker,bug.bugid)

		kwargs={
			"tosend":msg,
			"replyto":replyto,
			}

		if bcc:
			self.smtp.sendmail(
				from_=from_,
				bcc=bcc,
				postprocessor=lambda msg: self.email_postprocessor_fix_acit_a_thousand_times(msg, from_),
				smtp_to=bcc,
				**kwargs
				)

		self.mlog("Sending %d extra emails due to tokens"%len(securereplytos))
		for email,token in securereplytos.items():
			#self.mlog("Sending extra email to %s bc of token"%email)
			from_=self.format_emailaddr(project='$token$='+token)
			self.smtp.sendmail(
				to=email,
				from_=from_,
				postprocessor=lambda msg: self.email_postprocessor_fix_acit_a_thousand_times(msg, from_),
				smtp_to=email,
				**kwargs
				)

	def secure_generate_token(self,mailbox:MailBox,msg:MailMessage,proj:str,bugid:int):
		self.mlog("Generating a secure token for",msg.from_)
		import random, string
		token=''.join(( random.choice(string.ascii_letters) for i in range(40)))
		with self.dbpool.get_connection() as conn, conn.cursor() as cur:
			cur.execute("SELECT email FROM permissiontable WHERE email=? AND (tracker=? OR tracker IS NULL) AND (bugid=? OR bugid IS NULL)",(msg.from_,proj,bugid))
			if not cur.fetchall():
				self.mail_error(msg,notice="You do not have permission to do that here.")
				return

			cur.execute("INSERT INTO tokens (email,token,tracker,bugid) VALUES (?,?,?,?)",(msg.from_,token,proj,bugid))
			conn.commit()

		from_=self.format_emailaddr(project='$token$='+token)

		headers={}
		self.smtp.format_reply_headers(replyto=self.get_newest_from_mailbox(mailbox,proj,bugid), tosend=headers)

		bugobj=self.site.getbug(proj,bugid)

		self.smtp.sendmail(
				from_=from_,
				to=msg.from_,
				subject="Re: "+bugobj.subject+" [SECURED]",
				save=False,
				body="Please reply to this email to use your token, or use the email address:\n "+from_,
				extraheaders=headers
				)
	
	
	def secure_process_commands(self,mailbox:MailBox,msg:MailMessage,conn:Connection,cur:Cursor,proj:str,bugid:int):
		from .emailcommands import findcommands
		from .types import BUGSTATUS, BUGTYPES

		commandmatches=findcommands(msg.text)

		bug=self.site.getbug(tracker=proj,bugid=bugid)

		for match in commandmatches:
			text=match.group()
			text=text.replace("\r\n","\n") # dos2unix so we can forget about \r
			text=text.lstrip('\n')
			text=text.rstrip('\n')

			self.mlog(text)

			if text.startswith('SUBJECT '):
				bug.subject=text.removeprefix("SUBJECT ")
				self.mlog("Subject changed to:",bug.subject)
			elif text.startswith("STATUS ") or text.startswith("TYPE "):
				key,data=text.split(sep=' ',maxsplit=1)
				possibles={
						"STATUS":BUGSTATUS,
						"TYPE":BUGTYPES
					}
				if not data in possibles[key]: # no need to errorcheck this, the check above implies key can only be STATUS or TYPE
					continue
				
				self.mlog("Set",key,"to",data)
				if key=="STATUS":
					bug.status=data
				elif key=="TYPE":
					bug.type=data

			elif text.startswith("DESCRIPTION"):
				self.mlog("Description command")
				cmdline,text=text.split('\n',1)
				text=text.removesuffix("\nDESCRIPTION END")
				m=re.match(r'DESCRIPTION @([0-9]*)(:[0-9])?',cmdline)
				start=None
				end=None
				self.mlog(m)
				if m:
					try:
						start=int(m[1])
						if m[1]:
							end=int(m[2].lstrip(":"))
						else:
							end=start+1
					except ValueError as e:
						self.mlog("Error converting description range to int:",repr(e))
						continue

				replacement=text.split("\n")
				lines=bug.description.split("\n")
				self.mlog("Replacing: ",repr("\n".join(lines[start:end])),"with",repr("\n".join(replacement)))
				lines[start:end]=replacement
				bug.description="\n".join(lines)

			elif text.startswith("PERMIT "):
				account=text.removeprefix("PERMIT ")
				cur.execute("INSERT INTO permissiontable VALUES (?,?,?)",(account,proj,bugid))
				conn.commit()
			elif text.startswith("UNPERMIT "):
				account=text.removeprefix("UNPERMIT ")
				cur.execute("DELETE FROM permissiontable WHERE email=? AND tracker=? AND bugid=?)",(account,proj,bugid))
				conn.commit()

			else:
				self.mlog("Not yet implemented command: '%s'"%text)

	
	def stop(self):
		"Sets stopping signal, waits for all threads to stop"
		self.mlog("Stopping. This can take a while.")
		self.stopping.set()
		for thread in ( self.mailin_thread, ):
			if thread.is_alive() and not thread==threading.current_thread():
				self.mlog("Waiting for thread: %s"%thread.name)
				thread.join()
		self.mlog("Stopping SMTP worker")
		self.smtp.stop()
		self.mlog("Closing IMAP pool")
		self.mbpool.close()
		self.mlog("Stopped")
	
	def mlog(self,*msg,**kwargs):
		import traceback
		function=traceback.extract_stack(limit=2)[0].name
		cherrypy.log(context="%s>>MAIL:%s"%(threading.current_thread().name, function), msg=" ".join([str(i) for i in msg]),**kwargs)