diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56209be..aae6ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: "Check mypy" run: | mypy --non-interactive --install-types -c 'import tabulate' - mypy --strict jiramail/*.py + find jiramail -type f -name '*.py' -a \! -name '*_tab.py' | xargs -r mypy --strict - name: "Check pylint" run: pylint --disable=R --disable=W0603,W0621,W0718 --disable=C0103,C0114,C0115,C0116,C0301,C0415,C3001 jiramail/*.py diff --git a/jiramail/__init__.py b/jiramail/__init__.py index e3b0800..4d14869 100644 --- a/jiramail/__init__.py +++ b/jiramail/__init__.py @@ -79,6 +79,8 @@ def __init__(self, path: str): logger.debug("openning the mailbox `%s' ...", path) self.mbox = mailbox.mbox(path) + self.path = os.path.abspath(os.path.expanduser(path)) + self.n_msgs = 0 self.msgid = {} for key in self.mbox.iterkeys(): @@ -86,12 +88,20 @@ def __init__(self, path: str): if "Message-Id" in mail: msg_id = mail.get("Message-Id") self.msgid[msg_id] = True + self.n_msgs += 1 logger.info("mailbox is ready") def get_message(self, key: str) -> mailbox.mboxMessage: return self.mbox.get_message(key) + def del_message(self, key: str) -> None: + mail = self.get_message(key) + msg_id = mail.get("Message-Id") + + self.mbox.remove(key) + del self.msgid[msg_id] + def update_message(self, key: str, mail: email.message.Message) -> None: self.mbox.update([(key, mail)]) @@ -105,6 +115,9 @@ def append(self, mail: email.message.Message) -> None: def iterkeys(self) -> Iterator[Any]: return self.mbox.iterkeys() + def sync(self) -> None: + self.mbox.flush() + def close(self) -> None: self.mbox.close() diff --git a/jiramail/auth.py b/jiramail/auth.py new file mode 100644 index 0000000..5fa88b4 --- /dev/null +++ b/jiramail/auth.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023 Alexey Gladkov + +__author__ = 'Alexey Gladkov ' + +import base64 +import binascii +import hashlib +import hmac +import os +import random +import time + +from typing import Callable, Tuple, Any + +import jiramail + +logger = jiramail.logger + +def cram_md5(user: str, password: str, interact: Callable[[Any, str], str], data: Any) -> Tuple[bool, str]: + pid = os.getpid() + now = time.time_ns() + rnd = random.randrange(2**32 - 1) + shared = f"<{pid}.{now}.{rnd}@jiramail>" + + line = interact(data, base64.b64encode(shared.encode()).decode()) + + try: + buf = base64.standard_b64decode(line).decode() + except binascii.Error: + return (False, "couldn't decode your credentials") + + fields = buf.split(" ") + + if len(fields) != 2: + return (False, "wrong number of fields in the token") + + hexdigest = hmac.new(password.encode(), + shared.encode(), + hashlib.md5).hexdigest() + + if hmac.compare_digest(user, fields[0]) and hmac.compare_digest(hexdigest, fields[1]): + return (True, "authentication successful") + + return (False, "authenticate failure") diff --git a/jiramail/command.py b/jiramail/command.py index 1237b16..67f1346 100644 --- a/jiramail/command.py +++ b/jiramail/command.py @@ -38,6 +38,11 @@ def cmd_smtp(cmdargs: argparse.Namespace) -> int: return jiramail.smtp.main(cmdargs) +def cmd_imap(cmdargs: argparse.Namespace) -> int: + import jiramail.imap + return jiramail.imap.main(cmdargs) + + def add_common_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("-v", "--verbose", dest="verbose", action='count', default=0, @@ -184,6 +189,19 @@ def setup_parser() -> argparse.ArgumentParser: help="path to mailbox to store a reply messages with the status of command execution.") add_common_arguments(sp4) + # jiramail imap + sp5_description = """\ +imap server +""" + sp5 = subparsers.add_parser("imap", + description=sp3_description, + help=sp5_description, + epilog=epilog, + add_help=False) + sp5.set_defaults(func=cmd_imap) + add_common_arguments(sp5) + + return parser diff --git a/jiramail/imap.py b/jiramail/imap.py new file mode 100644 index 0000000..e0d35db --- /dev/null +++ b/jiramail/imap.py @@ -0,0 +1,766 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023 Alexey Gladkov + +__author__ = 'Alexey Gladkov ' + +import argparse +import email.policy +import mailbox +import os +import os.path +import re +import socket +import socketserver + +from typing import Generator, Callable, Optional, Pattern, Dict, List, Set, Any + +import jiramail +import jiramail.auth as auth +import jiramail.imap_proto.parser as imap_proto +import jiramail.mbox +import jiramail.subs + +CRLF = '\r\n' +imap_policy = email.policy.default.clone(linesep=CRLF) + +logger = jiramail.logger + + +class ImapResponse: + def __init__(self, tag: str, status: str, message: str): + self.tag = tag + self.status = status + self.message = message + + def __str__(self) -> str: + parts = [] + + if self.tag: + parts.extend([self.tag, " "]) + + if self.status: + parts.extend([self.status, " "]) + + parts.append(self.message) + parts.append(CRLF) + + return "".join(parts) + + +class Context(Dict[str, Any]): + def send(self, ans: ImapResponse) -> None: + msg = str(ans) + logger.debug("SEND: %s: %s", self["addr"], msg.encode()) + self["wfile"].write(msg.encode()) + + def recv_line(self) -> Any: + line = self["rfile"].readline() + logger.debug("RECV: %s: %s", self["addr"], line) + return line.decode() + + def send_result(self, message: str) -> None: + self.send(ImapResponse("*", "", message)) + + def resp_ok(self, message: str) -> ImapResponse: + return ImapResponse(self["tag"], "OK", message) + + def resp_no(self, message: str) -> ImapResponse: + return ImapResponse(self["tag"], "NO", message) + + def resp_bad(self, message: str) -> ImapResponse: + return ImapResponse(self["tag"], "BAD", message) + + +class CommonResponse: + def __init__(self, name: str, data: Any): + self.name = name + self.data = data + + def __str__(self) -> str: + return f"{self.name} {self.data}" + + +class NumberResponse(CommonResponse): + pass + + +class StringResponse(CommonResponse): + def __str__(self) -> str: + s = [self.name, " "] + if "\n" in self.data: + s.extend(['{', str(len(self.data)), '}', CRLF, self.data]) + else: + s.extend(['"', re.sub(r'([\\"])',r'\\\1', self.data), '"']) + return "".join(s) + + +class ListResponse(CommonResponse): + def __str__(self) -> str: + s = [] + if self.name: + s.extend([self.name, " "]) + s.append('(') + for i,v in enumerate(self.data): + if i: + s.extend([" ", str(v)]) + else: + s.append(str(v)) + s.append(')') + return "".join(s) + + def append(self, val: Any) -> None: + self.data.append(val) + + +class MailFlags(Set[str]): + # https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.2 + @staticmethod + def supported() -> List[str]: + return ["Seen", "Deleted", "Flagged", "Answered", "Recent"] + + def __init__(self, data: str): + self |= set(["Recent"]) + for char in data.upper(): + if char == "R": self |= set(["Seen"]) # pylint: disable=multiple-statements + elif char == "D": self |= set(["Deleted"]) # pylint: disable=multiple-statements + elif char == "F": self |= set(["Flagged"]) # pylint: disable=multiple-statements + elif char == "A": self |= set(["Answered"]) # pylint: disable=multiple-statements + elif char == "O": self -= set(["Recent"]) # pylint: disable=multiple-statements + + def __str__(self) -> str: + s = [] + if "Seen" in self: s.append("R") # pylint: disable=multiple-statements + if "Deleted" in self: s.append("D") # pylint: disable=multiple-statements + if "Flagged" in self: s.append("F") # pylint: disable=multiple-statements + if "Answered" in self: s.append("A") # pylint: disable=multiple-statements + if "Recent" not in self: s.append("O") # pylint: disable=multiple-statements + return "".join(s) + + def set(self, other: List[str]) -> None: + self &= set() + self |= set(other) + + +def reset(ctx: Context) -> None: + if "mbox" in ctx: + ctx["mbox"].close() + del ctx["mbox"] + + ctx["from"] = "" + ctx["to"] = [] + ctx["authorized"] = False + ctx["subscribed"] = set() + ctx["deleted"] = set() + ctx["select"] = "" + ctx["tag"] = "" + + +def authorized(ctx: Context) -> bool: + if "user" in ctx and "password" in ctx: + return ctx["authorized"] is True + return True + + +def wildcard2re(wildcard: str, delim: Optional[str] = None) -> Pattern[str]: + wildcard = wildcard.replace("*", "(?:.*?)") + if delim is None: + wildcard = wildcard.replace("%", "(?:.*?)") + else: + wildcard = wildcard.replace("%", "(?:(?:[^{re.escape(delim)}])*?)") + return re.compile(wildcard, re.I) + + +def sequence(node: imap_proto.Node, maxnum: int) -> Generator[int, None, None]: + for seq in node["value"]: + if seq["value"]["begin"] == '*': + seq["value"]["begin"] = maxnum + + if seq["value"]["end"] == '*': + seq["value"]["end"] = maxnum + + if seq["value"]["begin"] < seq["value"]["end"]: + it = range(seq["value"]["begin"], seq["value"]["end"]+1) + elif seq["value"]["begin"] > seq["value"]["end"]: + it = range(seq["value"]["end"], seq["value"]["begin"]+1) + else: + it = range(seq["value"]["end"], seq["value"]["begin"]+1) + + for i in it: + yield i + + +def get_mail_headers(mail: mailbox.mboxMessage, + filter_only: List[str], + filter_not: List[str]) -> str: + ans: List[str] = [] + for header in mail.keys(): + if filter_only and header.upper() not in filter_only: + continue + if filter_not and header.upper() in filter_not: + continue + ans += [header, ": "] + mail.get_all(header, failobj=[]) + ["\n"] + + ret = "".join(ans) + + return re.sub(r'\n', CRLF, ret) + + +def get_mail_body(mail: mailbox.mboxMessage, only_part: int=0) -> str: + if mail.is_multipart(): + arr = [] + i = 0 + for part in mail.get_payload(): + i += 1 + if only_part and i != only_part: + continue + arr.append(part.as_string()) + return CRLF.join(arr) + else: + return str(mail.get_payload()) + + +def get_mail_full(mail: mailbox.mboxMessage) -> str: + return mail.as_string(policy=imap_policy) + + +def send_fetch_resp(ctx: Context, + seqs: imap_proto.Node, + attrs: imap_proto.Node) -> None: + for key in sequence(seqs, ctx["mbox"].n_msgs): + index = key - 1 + mail = ctx["mbox"].get_message(index) + + fields = set() + resp: CommonResponse + + ans = ListResponse(f"{key} FETCH", []) + + for attr in attrs["value"]: + if attr["name"] == "attr": + name = attr["value"].upper() + + match name: + case "UID": + # A number expressing the unique identifier of the message. + resp = NumberResponse(name, key) + case "FLAGS": + # A parenthesized list of flags that are set for this + # message. + resp = ListResponse(name, [f"\\{x}" for x in MailFlags(mail.get_flags())]) + case "INTERNALDATE": + # A string representing the internal date of the message. + continue + case "ENVELOPE": + # A parenthesized list that describes the envelope structure + # of a message. + continue + case "BODY" | "BODYSTRUCTURE": + # The [MIME-IMB] body structure of the message. + continue + case "RFC822": + # Equivalent to BODY[]. + resp = StringResponse(name, get_mail_full(mail)) + case "RFC822.HEADER": + # Equivalent to BODY[HEADER]. + resp = StringResponse(name, get_mail_headers(mail, [], [])) + case "RFC822.TEXT": + # Equivalent to BODY[TEXT]. + resp = StringResponse(name, get_mail_body(mail)) + case "RFC822.SIZE": + # A number expressing the [RFC-2822] size of the message. + resp = NumberResponse(name, len(get_mail_full(mail))) + + if name not in fields: + ans.append(resp) + fields |= set([name]) + + if attr["name"] in ("body", "body.peek"): + val = attr["value"]["section"] + + match val["name"]: + case "header": + name = "BODY[HEADER]" + resp = StringResponse(name, get_mail_headers(mail, [], [])) + case "header.fields": + name = "BODY[HEADER]" + resp = StringResponse(name, get_mail_headers(mail, val["value"], [])) + case "header.fields.not": + name = "BODY[HEADER]" + resp = StringResponse(name, get_mail_headers(mail, [], val["value"])) + case "text": + name = "BODY[TEXT]" + resp = StringResponse(name, get_mail_body(mail)) + case "_full": + name = "BODY[]" + resp = StringResponse(name, get_mail_full(mail)) + + if name not in fields: + ans.append(resp) + fields |= set([name]) + + ctx.send_result(str(ans)) + + +def send_store_resp(ctx: Context, seqs: imap_proto.Node, data: imap_proto.Node) -> None: + if data["value"]["item"] not in ("FLAGS", "FLAGS.SILENT"): + return + + for seq in sequence(seqs, ctx["mbox"].n_msgs): + index = seq - 1 + mail = ctx["mbox"].get_message(index) + + flags = MailFlags(mail.get_flags()) + + match data["value"]["op"]: + case "add": + flags |= set([x["value"] for x in data["value"]["flags"]["value"]]) + case "remove": + flags -= set([x["value"] for x in data["value"]["flags"]["value"]]) + case "replace": + flags.set([x["value"] for x in data["value"]["flags"]["value"]]) + + if "Deleted" in flags: + ctx["deleted"] |= set([index]) + elif seq in ctx["deleted"]: + ctx["deleted"] -= set([index]) + + mail.set_flags(str(flags)) + ctx["mbox"].update_message(index, mail) + + if data["value"]["item"] == "FLAGS": + resp = ListResponse(f"{seq} STORE", []) + resp.append(NumberResponse("UID", seq)) + resp.append(ListResponse("FLAGS", [ f"\\{x}" for x in flags ])) + ctx.send_result(str(resp)) + + +def get_mbox_stats(mbox: jiramail.Mailbox) -> Dict[str,int]: + ret = { + "uid_valid": int(os.path.getmtime(mbox.path)), + "uid_next": 0, + "msgs": 0, + "recent": 0, + } + + for seq in mbox.iterkeys(): + mail = mbox.get_message(seq) + flags = MailFlags(mail.get_flags()) + if "Recent" in flags: + ret["recent"] += 1 + ret["msgs"] += 1 + ret["uid_next"] = seq + + ret["uid_next"] += 1 + + return ret + + +def send_examine_resp(ctx: Context, mbox: jiramail.Mailbox) -> None: + flags_supported = " ".join([f"\\{x}" for x in MailFlags.supported()]) + stats = get_mbox_stats(mbox) + + ctx.send_result(f"FLAGS ({flags_supported})") + ctx.send_result(f"OK [PERMANENTFLAGS ({flags_supported})] Limited") + ctx.send_result(f"OK [UIDVALIDITY {stats['uid_valid']}] UIDs valid") + ctx.send_result(f"OK [UIDNEXT {stats['uid_next']}] Predicted next UID") + ctx.send_result(f"{stats['msgs']} EXISTS") + ctx.send_result(f"{stats['recent']} RECENT") + + +def send_status_resp(ctx: Context, + mbox: jiramail.Mailbox, + mailbox: str, + items: List[str]) -> None: + infos = get_mbox_stats(mbox) + fields = { + "MESSAGES" : "msgs", + "RECENT" : "recent", + "UIDNEXT" : "uid_next", + "UIDVALIDITY" : "uid_valid", + "UNSEEN" : "msgs", + } + resp = ListResponse(f"STATUS \"{mailbox}\"", []) + + for n in items: + n = n.upper() + + if n in fields: + resp.append(NumberResponse(n, infos[fields[n]])) + + ctx.send_result(str(resp)) + + +def send_list_resp(ctx: Context, cmdname: str, mailbox: str, items: List[str]) -> bool: + wildcard = None + found = False + + if mailbox: + wildcard = wildcard2re(mailbox, "/") + + # RFC-5258: Internet Message Access Protocol version 4 - LIST Command Extensions + # RFC-6154: IMAP LIST Extension for Special-Use Mailboxes + + if not wildcard or wildcard.match(""): + ctx.send_result(f'{cmdname} (\\Noselect \\HasChildren) "/" ""') + found = True + + for name in items: + if not wildcard or wildcard.match(name): + folder_flags = ["\\Marked", "\\HasNoChildren"] + ctx.send_result(f'{cmdname} ({" ".join(folder_flags)}) "/" "{name}"') + found = True + + return found + + +def command__always_fail(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + return ctx.resp_no(f"{cmd['name']} failed") + + +def command_noop(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_capability(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + caps = ["CAPABILITY", "IMAP4rev1"] # "LOGINDISABLED" + + if not ctx["authorized"] and "user" in ctx and "password" in ctx: + caps.extend(["AUTH=CRAM-MD5", "AUTH=PLAIN"]) + + ctx.send_result(" ".join(caps)) + return ctx.resp_ok(f"{cmd['name']} completed") + + +def auth_interact(ctx: Context, shared: str) -> Any: + ctx.send(ImapResponse("+", "", shared)) + return ctx.recv_line() + + +def command_authenticate(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["authorized"] = False + + if cmd["value"] not in ("CRAM-MD5"): + return ctx.resp_no("unsupported authentication mechanism") + + (ret, msg) = auth.cram_md5(ctx["user"], ctx["password"], auth_interact, ctx) + if not ret: + return ctx.resp_no(msg) + + ctx["authorized"] = True + return ctx.resp_ok("CRAM-MD5 authentication successful") + + +def command_login(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["authorized"] = False + user_ok = ctx["user"] == cmd["value"]["username"] + pass_ok = ctx["password"] == cmd["value"]["password"] + + if user_ok and pass_ok: + ctx["authorized"] = True + return ctx.resp_ok("LOGIN authentication successful") + + return ctx.resp_no(f"{cmd['name']} user name or password rejected") + + +def command_logout(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["quit"] = True + ctx.send_result("BYE IMAP4rev1 Server logging out") + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_list(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + found = send_list_resp(ctx, cmd["name"], + cmd["value"]["mailbox"], + ctx["config"]["sub"].keys()) + if not found: + return ctx.resp_no(f"{cmd['name']} nothing found") + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_lsub(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + found = send_list_resp(ctx, cmd["name"], + cmd["value"]["mailbox"], + ctx["subscribed"]) + if not found: + return ctx.resp_no(f"{cmd['name']} nothing found") + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_subscribe(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["subscribed"] |= set([cmd["value"]["mailbox"]]) + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_unsubscribe(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["subscribed"] -= set([cmd["value"]["mailbox"]]) + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_status(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + mailbox = jiramail.subs.get_mailbox(ctx["config"], cmd["value"]["mailbox"]) + + if not mailbox: + return ctx.resp_no(f"{cmd['name']} no such mailbox") + + try: + mbox = jiramail.Mailbox(mailbox) + except Exception as e: + err = f"unable to open mailbox: {e}" + logger.critical(err) + return ctx.resp_no(err) + + send_status_resp(ctx, mbox, + cmd["value"]["mailbox"], + cmd["value"]["items"]["value"]) + mbox.close() + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_examine(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + mailbox = jiramail.subs.get_mailbox(ctx["config"], cmd["value"]["mailbox"]) + + if not mailbox: + return ctx.resp_no(f"{cmd['name']} no such mailbox") + + try: + mbox = jiramail.Mailbox(mailbox) + except Exception as e: + err = f"unable to open mailbox: {e}" + logger.critical(err) + return ctx.resp_no(err) + + send_examine_resp(ctx, mbox) + mbox.close() + + return ctx.resp_ok(f"[READ-WRITE] {cmd['name']} completed") + + +def command_select(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + mailbox = jiramail.subs.get_mailbox(ctx["config"], cmd["value"]["mailbox"]) + + if not mailbox: + return ctx.resp_no(f"{cmd['name']} no such mailbox") + + if "mbox" in ctx: + ctx["mbox"].close() + + try: + ctx["mbox"] = jiramail.Mailbox(mailbox) + except Exception as e: + err = f"unable to open mailbox: {e}" + logger.critical(err) + return ctx.resp_no(err) + + #logger.info("Command SELECT: `%s` synchronization ...", cmd["value"]["mailbox"]) + + #for query in jiramail.subs.get_queries(ctx["config"], cmd["value"]["mailbox"]): + # logger.debug("Command SELECT: processing query: %s", query) + # jiramail.mbox.process_query(query, ctx["mbox"]) + + logger.info("Command SELECT: `%s` synchronization is complete.", cmd["value"]["mailbox"]) + ctx["select"] = cmd["value"]["mailbox"] + ctx["deleted"] = set() + + send_examine_resp(ctx, ctx["mbox"]) + + return ctx.resp_ok(f"[READ-WRITE] {cmd['name']} completed") + + +def command_close(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["select"] = "" + + if "mbox" in ctx: + ctx["mbox"].close() + del ctx["mbox"] + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_check(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + ctx["mbox"].sync() + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_copy(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + return ctx.resp_no(f"{cmd['name']} failed") + + +def command_fetch(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + seqs = cmd["value"]["sequence"] + attrs = cmd["value"]["attrs"] + + if len(attrs["value"]) > 0: + send_fetch_resp(ctx, seqs, attrs) + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_uid_fetch(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + seqs = cmd["value"]["sequence"] + attrs = cmd["value"]["attrs"] + + if len(attrs["value"]) > 0: + send_fetch_resp(ctx, seqs, attrs) + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_uid_store(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + send_store_resp(ctx, + cmd["value"]["sequence"], + cmd["value"]["value"]) + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_store(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + send_store_resp(ctx, + cmd["value"]["sequence"], + cmd["value"]["value"]) + + return ctx.resp_ok(f"{cmd['name']} completed") + + +def command_expunge(ctx: Context, cmd: imap_proto.Node) -> ImapResponse: + for seq in ctx["deleted"]: + ctx["mbox"].del_message(seq) + + ctx["mbox"].sync() + + return ctx.resp_ok(f"{cmd['name']} completed") + + +class ImapCMD: + handler: Callable[[Context, imap_proto.Node], ImapResponse] + + def __init__(self, handler: Callable[[Context, imap_proto.Node], ImapResponse], need_auth: bool, need_mailbox: bool): + self.need_auth = need_auth + self.need_mailbox = need_mailbox + self.handler = handler + + +commands: Dict[str, ImapCMD] = { + "AUTHENTICATE" : ImapCMD(command_authenticate , need_auth=False , need_mailbox=False ), + "CAPABILITY" : ImapCMD(command_capability , need_auth=False , need_mailbox=False ), + "CHECK" : ImapCMD(command_check , need_auth=False , need_mailbox=True ), + "CLOSE" : ImapCMD(command_close , need_auth=True , need_mailbox=True ), + "COPY" : ImapCMD(command_copy , need_auth=True , need_mailbox=True ), + "CREATE" : ImapCMD(command__always_fail , need_auth=True , need_mailbox=False ), + "DELETE" : ImapCMD(command__always_fail , need_auth=True , need_mailbox=False ), + "EXAMINE" : ImapCMD(command_examine , need_auth=True , need_mailbox=False ), + "EXPUNGE" : ImapCMD(command_expunge , need_auth=True , need_mailbox=True ), + "FETCH" : ImapCMD(command_fetch , need_auth=True , need_mailbox=True ), + "LIST" : ImapCMD(command_list , need_auth=True , need_mailbox=False ), + "LOGIN" : ImapCMD(command_login , need_auth=False , need_mailbox=False ), + "LOGOUT" : ImapCMD(command_logout , need_auth=False , need_mailbox=False ), + "LSUB" : ImapCMD(command_lsub , need_auth=True , need_mailbox=False ), + "NOOP" : ImapCMD(command_noop , need_auth=False , need_mailbox=False ), + "RENAME" : ImapCMD(command__always_fail , need_auth=True , need_mailbox=False ), + "SELECT" : ImapCMD(command_select , need_auth=True , need_mailbox=False ), + "STATUS" : ImapCMD(command_status , need_auth=True , need_mailbox=False ), + "STORE" : ImapCMD(command_store , need_auth=True , need_mailbox=True ), + "SUBSCRIBE" : ImapCMD(command_subscribe , need_auth=True , need_mailbox=False ), + "UID FETCH" : ImapCMD(command_uid_fetch , need_auth=True , need_mailbox=True ), + "UID STORE" : ImapCMD(command_uid_store , need_auth=True , need_mailbox=True ), + "UNSUBSCRIBE" : ImapCMD(command_unsubscribe , need_auth=True , need_mailbox=False ), + } + + +class ImapTCPHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + config = getattr(self.server, "config") + + ctx = Context({ + "config" : config, + "addr" : self.client_address, + "rfile" : self.rfile, + "wfile" : self.wfile, + "quit" : False, + }) + + if "imap" in config: + for param in ("user", "password"): + if param in config["imap"]: + ctx[param] = config["imap"][param] + + reset(ctx) + + #try: + # jiramail.jserv = jiramail.Connection(config.get("jira", {})) + #except Exception as e: + # logger.critical("unable to connect to jira: %s", e) + # return jiramail.EX_FAILURE + + parser = imap_proto.IMAPParser() + + try: + logger.info("%s: new connection", ctx["addr"]) + + ctx.send(ImapResponse("*", "OK", "IMAP4rev1 Service Ready")) + + while not ctx["quit"]: + line = ctx.recv_line() + + if line == '': + break + + node = parser.parse(line) + + if not isinstance(node, imap_proto.Node): + logger.critical("parser failed: %s", node) + break + + ctx["tag"] = str(node["value"]["tag"]) + cmd = node["value"]["cmd"] + + if cmd["name"] in commands: + if commands[cmd["name"]].need_auth and not authorized(ctx): + resp = ctx.resp_no(f"{cmd['name']} Authentication required") + elif commands[cmd["name"]].need_mailbox and ctx["select"] == "": + resp = ctx.resp_no("mailbox not selected") + else: + resp = commands[cmd["name"]].handler(ctx, cmd) + else: + resp = ctx.resp_ok("command not recognized") + + ctx.send(resp) + + except (BrokenPipeError, ConnectionResetError) as e: + logger.debug("%s: connection error: %s", ctx["addr"], e) + + logger.debug("%s: finish", ctx["addr"]) + + +class ImapServer(socketserver.ForkingTCPServer): + config: Dict[str, Any] + + def __init__(self, addr: Any, handler: Any): + self.address_family = socket.AF_INET + self.socket_type = socket.SOCK_STREAM + self.allow_reuse_address = True + super().__init__(addr, handler) + + +# pylint: disable-next=unused-argument +def main(cmdargs: argparse.Namespace) -> int: + config = jiramail.read_config() + + if isinstance(config, jiramail.Error): + logger.critical("%s", config.message) + return jiramail.EX_FAILURE + + jiramail.auth.logger = logger + jiramail.mbox.logger = logger + jiramail.subs.logger = logger + + saddr = ("localhost", config.get("imap", {}).get("port", 10143)) + + with ImapServer(saddr, ImapTCPHandler) as server: + server.config = config + server.serve_forever() + + return jiramail.EX_SUCCESS diff --git a/jiramail/imap_proto/__init__.py b/jiramail/imap_proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jiramail/imap_proto/_imaplex_tab.py b/jiramail/imap_proto/_imaplex_tab.py new file mode 100644 index 0000000..13393cc --- /dev/null +++ b/jiramail/imap_proto/_imaplex_tab.py @@ -0,0 +1,10 @@ +# _imaplex_tab.py. This file automatically created by PLY (version 3.11). Don't edit! +_tabversion = '3.10' +_lextokens = set(('AUTHENTICATE', 'BSLASH', 'CAPABILITY', 'CHECK', 'CLOSE', 'COLON', 'COMMA', 'COPY', 'CREATE', 'DELETE', 'DOT', 'EOL', 'EXAMINE', 'EXPUNGE', 'FETCH', 'GTSIGN', 'LIST', 'LOGIN', 'LOGOUT', 'LPAREN', 'LSBRACKET', 'LSUB', 'LTSIGN', 'MAILBOX', 'MINUS', 'NOOP', 'NUMBER', 'PLUS', 'QUOTED', 'RENAME', 'RPAREN', 'RSBRACKET', 'SELECT', 'SP', 'STAR', 'STATUS', 'STORE', 'SUBSCRIBE', 'UID', 'UNSUBSCRIBE', 'WORD')) +_lexreflags = 2 +_lexliterals = '' +_lexstateinfo = {'INITIAL': 'inclusive', 'paren': 'inclusive', 'bracket': 'inclusive', 'sign': 'inclusive'} +_lexstatere = {'INITIAL': [('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P\\s*\\r?\\n)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'EOL'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')])], 'paren': [('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')]), ('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P\\s*\\r?\\n)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'EOL'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')])], 'bracket': [('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')]), ('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P\\s*\\r?\\n)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'EOL'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')])], 'sign': [('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')]), ('(?P\\d+)|(?P[0-9a-z?#%~_.-]+)|(?P\\()|(?P\\))|(?P\\[)|(?P\\])|(?P\\<)|(?P\\>)|(?P\\s*\\r?\\n)|(?P"[^"]*")|(?P\\\\)|(?P\\.)|(?P\\-)|(?P\\+)|(?P\\ )|(?P\\*)|(?P:)|(?P,)', [None, ('t_ANY_NUMBER', 'NUMBER'), ('t_ANY_WORD', 'WORD'), ('t_ANY_LPAREN', 'LPAREN'), ('t_ANY_RPAREN', 'RPAREN'), ('t_ANY_LSBRACKET', 'LSBRACKET'), ('t_ANY_RSBRACKET', 'RSBRACKET'), ('t_ANY_LTSIGN', 'LTSIGN'), ('t_ANY_GTSIGN', 'GTSIGN'), (None, 'EOL'), (None, 'QUOTED'), (None, 'BSLASH'), (None, 'DOT'), (None, 'MINUS'), (None, 'PLUS'), (None, 'SP'), (None, 'STAR'), (None, 'COLON'), (None, 'COMMA')])]} +_lexstateignore = {'INITIAL': '', 'paren': '', 'bracket': '', 'sign': ''} +_lexstateerrorf = {'INITIAL': 't_error', 'paren': 't_error', 'bracket': 't_error', 'sign': 't_error'} +_lexstateeoff = {} diff --git a/jiramail/imap_proto/_imapyacc_tab.py b/jiramail/imap_proto/_imapyacc_tab.py new file mode 100644 index 0000000..d61a7fa --- /dev/null +++ b/jiramail/imap_proto/_imapyacc_tab.py @@ -0,0 +1,111 @@ + +# _imapyacc_tab.py +# This file is automatically generated. Do not edit. +# pylint: disable=W,C,R +_tabversion = '3.10' + +_lr_method = 'LALR' + +_lr_signature = 'commandAUTHENTICATE BSLASH CAPABILITY CHECK CLOSE COLON COMMA COPY CREATE DELETE DOT EOL EXAMINE EXPUNGE FETCH GTSIGN LIST LOGIN LOGOUT LPAREN LSBRACKET LSUB LTSIGN MAILBOX MINUS NOOP NUMBER PLUS QUOTED RENAME RPAREN RSBRACKET SELECT SP STAR STATUS STORE SUBSCRIBE UID UNSUBSCRIBE WORD\n command : tag SP cmd_noop EOL\n | tag SP cmd_authenticate EOL\n | tag SP cmd_capability EOL\n | tag SP cmd_check EOL\n | tag SP cmd_close EOL\n | tag SP cmd_copy EOL\n | tag SP cmd_create EOL\n | tag SP cmd_delete EOL\n | tag SP cmd_examine EOL\n | tag SP cmd_expunge EOL\n | tag SP cmd_fetch EOL\n | tag SP cmd_list EOL\n | tag SP cmd_login EOL\n | tag SP cmd_logout EOL\n | tag SP cmd_lsub EOL\n | tag SP cmd_rename EOL\n | tag SP cmd_select EOL\n | tag SP cmd_status EOL\n | tag SP cmd_store EOL\n | tag SP cmd_subscribe EOL\n | tag SP cmd_uid EOL\n | tag SP cmd_unsubscribe EOL\n \n tag : WORD\n | NUMBER\n \n cmd_capability : CAPABILITY\n cmd_check : CHECK\n cmd_close : CLOSE\n cmd_expunge : EXPUNGE\n cmd_logout : LOGOUT\n cmd_noop : NOOP\n \n cmd_create : CREATE SP mailbox\n cmd_delete : DELETE SP mailbox\n cmd_select : SELECT SP mailbox\n cmd_examine : EXAMINE SP mailbox\n cmd_subscribe : SUBSCRIBE SP mailbox\n cmd_unsubscribe : UNSUBSCRIBE SP mailbox\n \n cmd_authenticate : AUTHENTICATE SP WORD\n \n cmd_login : LOGIN SP QUOTED SP QUOTED\n \n cmd_list : LIST SP mailbox SP mailbox\n cmd_lsub : LSUB SP mailbox SP mailbox\n \n cmd_rename : RENAME SP mailbox SP mailbox\n \n cmd_copy : COPY SP sequence SP mailbox\n \n cmd_status : STATUS SP mailbox SP LPAREN status-items RPAREN\n \n status-items : status-items SP WORD\n | WORD\n \n mailbox : QUOTED\n | MAILBOX\n | WORD\n \n cmd_uid : UID SP FETCH SP sequence SP LPAREN fetch-attrs RPAREN\n | UID SP FETCH SP sequence SP fetch-attrs\n | UID SP STORE SP sequence SP store_args\n \n cmd_store : STORE SP sequence SP store_args\n \n store_args : PLUS WORD SP LPAREN flag-list RPAREN\n | MINUS WORD SP LPAREN flag-list RPAREN\n | WORD SP LPAREN flag-list RPAREN\n \n flag-list : flag-value SP flag-value\n | flag-value\n \n flag-value : BSLASH WORD\n | WORD\n \n cmd_fetch : FETCH SP sequence SP LPAREN fetch-attrs RPAREN\n | FETCH SP sequence SP WORD\n \n sequence : sequence COMMA range\n | range\n \n range : NUMBER COLON NUMBER\n | NUMBER COLON STAR\n | NUMBER\n \n fetch-attrs : fetch-attrs SP fetch-attr\n | fetch-attr\n \n fetch-attr : WORD LSBRACKET NUMBER RSBRACKET fetch-partial\n fetch-attr : WORD LSBRACKET section RSBRACKET fetch-partial\n | WORD\n \n fetch-partial : LTSIGN NUMBER DOT NUMBER GTSIGN\n | LTSIGN NUMBER GTSIGN\n | empty\n \n section : WORD SP header-list\n | WORD\n | empty\n \n header-list : LPAREN headers RPAREN\n \n headers : headers SP WORD\n | WORD\n \n empty :\n ' + +_lr_action_items = {'WORD':([0,72,74,75,76,78,80,81,82,83,85,87,110,113,114,116,117,119,126,132,134,136,147,148,149,151,152,155,157,166,170,171,178,183,195,],[3,88,95,95,95,95,95,95,95,95,95,95,95,127,95,95,95,135,141,143,144,146,141,135,141,161,165,167,141,167,179,167,167,192,198,]),'NUMBER':([0,73,77,84,111,112,120,121,151,185,196,],[4,91,91,91,91,124,91,91,162,193,199,]),'$end':([1,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,],[0,-1,-2,-3,-4,-5,-6,-7,-8,-9,-10,-11,-12,-13,-14,-15,-16,-17,-18,-19,-20,-21,-22,]),'SP':([2,3,4,29,33,34,35,36,38,39,40,42,43,44,45,46,47,48,49,89,90,91,93,94,95,98,99,100,101,102,104,105,107,108,123,124,125,135,137,138,139,140,141,142,143,144,146,158,160,161,165,167,169,172,174,175,179,184,186,187,191,192,197,198,200,],[5,-23,-24,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,110,-63,-66,-46,-47,-48,113,114,115,116,117,118,119,120,121,-62,-64,-65,145,147,148,149,-68,-71,152,-45,154,156,149,-67,173,-44,-59,178,149,-81,-81,-58,-69,-74,-70,195,-80,-73,-79,-72,]),'NOOP':([5,],[28,]),'AUTHENTICATE':([5,],[29,]),'CAPABILITY':([5,],[30,]),'CHECK':([5,],[31,]),'CLOSE':([5,],[32,]),'COPY':([5,],[33,]),'CREATE':([5,],[34,]),'DELETE':([5,],[35,]),'EXAMINE':([5,],[36,]),'EXPUNGE':([5,],[37,]),'FETCH':([5,86,],[38,107,]),'LIST':([5,],[39,]),'LOGIN':([5,],[40,]),'LOGOUT':([5,],[41,]),'LSUB':([5,],[42,]),'RENAME':([5,],[43,]),'SELECT':([5,],[44,]),'STATUS':([5,],[45,]),'STORE':([5,86,],[46,108,]),'SUBSCRIBE':([5,],[47,]),'UID':([5,],[48,]),'UNSUBSCRIBE':([5,],[49,]),'EOL':([6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,30,31,32,37,41,88,92,93,94,95,96,97,103,106,109,122,127,128,129,130,131,133,140,141,150,153,158,159,160,174,175,177,181,184,186,187,188,190,197,200,],[50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,-30,-25,-26,-27,-28,-29,-37,-31,-46,-47,-48,-32,-34,-33,-35,-36,-42,-61,-39,-38,-40,-41,-52,-68,-71,-60,-43,-50,-51,-67,-81,-81,-55,-49,-69,-74,-70,-53,-54,-73,-72,]),'QUOTED':([74,75,76,78,79,80,81,82,83,85,87,110,114,115,116,117,],[93,93,93,93,100,93,93,93,93,93,93,93,93,129,93,93,]),'MAILBOX':([74,75,76,78,80,81,82,83,85,87,110,114,116,117,],[94,94,94,94,94,94,94,94,94,94,94,94,94,94,]),'COMMA':([89,90,91,98,105,123,124,125,137,138,],[111,-63,-66,111,111,-62,-64,-65,111,111,]),'COLON':([91,],[112,]),'STAR':([112,],[125,]),'LPAREN':([113,118,145,147,154,156,173,],[126,132,155,157,166,171,183,]),'PLUS':([119,148,],[134,134,]),'MINUS':([119,148,],[136,136,]),'RPAREN':([139,140,141,142,143,160,165,167,168,169,172,174,175,176,179,180,184,186,187,189,191,192,197,198,200,],[150,-68,-71,153,-45,-67,-44,-59,177,-57,181,-81,-81,188,-58,190,-69,-74,-70,-56,194,-80,-73,-79,-72,]),'LSBRACKET':([141,],[151,]),'RSBRACKET':([151,161,162,163,164,182,194,],[-81,-76,174,175,-77,-75,-78,]),'BSLASH':([155,166,171,178,],[170,170,170,170,]),'LTSIGN':([174,175,],[185,185,]),'DOT':([193,],[196,]),'GTSIGN':([193,199,],[197,200,]),} + +_lr_action = {} +for _k, _v in _lr_action_items.items(): + for _x,_y in zip(_v[0],_v[1]): + if not _x in _lr_action: _lr_action[_x] = {} + _lr_action[_x][_k] = _y +del _lr_action_items + +_lr_goto_items = {'command':([0,],[1,]),'tag':([0,],[2,]),'cmd_noop':([5,],[6,]),'cmd_authenticate':([5,],[7,]),'cmd_capability':([5,],[8,]),'cmd_check':([5,],[9,]),'cmd_close':([5,],[10,]),'cmd_copy':([5,],[11,]),'cmd_create':([5,],[12,]),'cmd_delete':([5,],[13,]),'cmd_examine':([5,],[14,]),'cmd_expunge':([5,],[15,]),'cmd_fetch':([5,],[16,]),'cmd_list':([5,],[17,]),'cmd_login':([5,],[18,]),'cmd_logout':([5,],[19,]),'cmd_lsub':([5,],[20,]),'cmd_rename':([5,],[21,]),'cmd_select':([5,],[22,]),'cmd_status':([5,],[23,]),'cmd_store':([5,],[24,]),'cmd_subscribe':([5,],[25,]),'cmd_uid':([5,],[26,]),'cmd_unsubscribe':([5,],[27,]),'sequence':([73,77,84,120,121,],[89,98,105,137,138,]),'range':([73,77,84,111,120,121,],[90,90,90,123,90,90,]),'mailbox':([74,75,76,78,80,81,82,83,85,87,110,114,116,117,],[92,96,97,99,101,102,103,104,106,109,122,128,130,131,]),'store_args':([119,148,],[133,159,]),'fetch-attrs':([126,147,157,],[139,158,172,]),'fetch-attr':([126,147,149,157,],[140,140,160,140,]),'status-items':([132,],[142,]),'section':([151,],[163,]),'empty':([151,174,175,],[164,186,186,]),'flag-list':([155,166,171,],[168,176,180,]),'flag-value':([155,166,171,178,],[169,169,169,189,]),'header-list':([173,],[182,]),'fetch-partial':([174,175,],[184,187,]),'headers':([183,],[191,]),} + +_lr_goto = {} +for _k, _v in _lr_goto_items.items(): + for _x, _y in zip(_v[0], _v[1]): + if not _x in _lr_goto: _lr_goto[_x] = {} + _lr_goto[_x][_k] = _y +del _lr_goto_items +_lr_productions = [ + ("S' -> command","S'",1,None,None,None), + ('command -> tag SP cmd_noop EOL','command',4,'p_command','parser.py',180), + ('command -> tag SP cmd_authenticate EOL','command',4,'p_command','parser.py',181), + ('command -> tag SP cmd_capability EOL','command',4,'p_command','parser.py',182), + ('command -> tag SP cmd_check EOL','command',4,'p_command','parser.py',183), + ('command -> tag SP cmd_close EOL','command',4,'p_command','parser.py',184), + ('command -> tag SP cmd_copy EOL','command',4,'p_command','parser.py',185), + ('command -> tag SP cmd_create EOL','command',4,'p_command','parser.py',186), + ('command -> tag SP cmd_delete EOL','command',4,'p_command','parser.py',187), + ('command -> tag SP cmd_examine EOL','command',4,'p_command','parser.py',188), + ('command -> tag SP cmd_expunge EOL','command',4,'p_command','parser.py',189), + ('command -> tag SP cmd_fetch EOL','command',4,'p_command','parser.py',190), + ('command -> tag SP cmd_list EOL','command',4,'p_command','parser.py',191), + ('command -> tag SP cmd_login EOL','command',4,'p_command','parser.py',192), + ('command -> tag SP cmd_logout EOL','command',4,'p_command','parser.py',193), + ('command -> tag SP cmd_lsub EOL','command',4,'p_command','parser.py',194), + ('command -> tag SP cmd_rename EOL','command',4,'p_command','parser.py',195), + ('command -> tag SP cmd_select EOL','command',4,'p_command','parser.py',196), + ('command -> tag SP cmd_status EOL','command',4,'p_command','parser.py',197), + ('command -> tag SP cmd_store EOL','command',4,'p_command','parser.py',198), + ('command -> tag SP cmd_subscribe EOL','command',4,'p_command','parser.py',199), + ('command -> tag SP cmd_uid EOL','command',4,'p_command','parser.py',200), + ('command -> tag SP cmd_unsubscribe EOL','command',4,'p_command','parser.py',201), + ('tag -> WORD','tag',1,'p_tag','parser.py',211), + ('tag -> NUMBER','tag',1,'p_tag','parser.py',212), + ('cmd_capability -> CAPABILITY','cmd_capability',1,'p_cmd_COMMON_NOARGS','parser.py',219), + ('cmd_check -> CHECK','cmd_check',1,'p_cmd_COMMON_NOARGS','parser.py',220), + ('cmd_close -> CLOSE','cmd_close',1,'p_cmd_COMMON_NOARGS','parser.py',221), + ('cmd_expunge -> EXPUNGE','cmd_expunge',1,'p_cmd_COMMON_NOARGS','parser.py',222), + ('cmd_logout -> LOGOUT','cmd_logout',1,'p_cmd_COMMON_NOARGS','parser.py',223), + ('cmd_noop -> NOOP','cmd_noop',1,'p_cmd_COMMON_NOARGS','parser.py',224), + ('cmd_create -> CREATE SP mailbox','cmd_create',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',231), + ('cmd_delete -> DELETE SP mailbox','cmd_delete',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',232), + ('cmd_select -> SELECT SP mailbox','cmd_select',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',233), + ('cmd_examine -> EXAMINE SP mailbox','cmd_examine',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',234), + ('cmd_subscribe -> SUBSCRIBE SP mailbox','cmd_subscribe',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',235), + ('cmd_unsubscribe -> UNSUBSCRIBE SP mailbox','cmd_unsubscribe',3,'p_cmd_COMMON_MAILBOX_ARG','parser.py',236), + ('cmd_authenticate -> AUTHENTICATE SP WORD','cmd_authenticate',3,'p_cmd_authenticate','parser.py',245), + ('cmd_login -> LOGIN SP QUOTED SP QUOTED','cmd_login',5,'p_cmd_login','parser.py',252), + ('cmd_list -> LIST SP mailbox SP mailbox','cmd_list',5,'p_cmd_list','parser.py',262), + ('cmd_lsub -> LSUB SP mailbox SP mailbox','cmd_lsub',5,'p_cmd_list','parser.py',263), + ('cmd_rename -> RENAME SP mailbox SP mailbox','cmd_rename',5,'p_cmd_rename','parser.py',273), + ('cmd_copy -> COPY SP sequence SP mailbox','cmd_copy',5,'p_cmd_copy','parser.py',283), + ('cmd_status -> STATUS SP mailbox SP LPAREN status-items RPAREN','cmd_status',7,'p_cmd_status','parser.py',293), + ('status-items -> status-items SP WORD','status-items',3,'p_status_items','parser.py',303), + ('status-items -> WORD','status-items',1,'p_status_items','parser.py',304), + ('mailbox -> QUOTED','mailbox',1,'p_mailbox','parser.py',311), + ('mailbox -> MAILBOX','mailbox',1,'p_mailbox','parser.py',312), + ('mailbox -> WORD','mailbox',1,'p_mailbox','parser.py',313), + ('cmd_uid -> UID SP FETCH SP sequence SP LPAREN fetch-attrs RPAREN','cmd_uid',9,'p_cmd_uid','parser.py',320), + ('cmd_uid -> UID SP FETCH SP sequence SP fetch-attrs','cmd_uid',7,'p_cmd_uid','parser.py',321), + ('cmd_uid -> UID SP STORE SP sequence SP store_args','cmd_uid',7,'p_cmd_uid','parser.py',322), + ('cmd_store -> STORE SP sequence SP store_args','cmd_store',5,'p_cmd_store','parser.py',343), + ('store_args -> PLUS WORD SP LPAREN flag-list RPAREN','store_args',6,'p_store_args','parser.py',353), + ('store_args -> MINUS WORD SP LPAREN flag-list RPAREN','store_args',6,'p_store_args','parser.py',354), + ('store_args -> WORD SP LPAREN flag-list RPAREN','store_args',5,'p_store_args','parser.py',355), + ('flag-list -> flag-value SP flag-value','flag-list',3,'p_flag_list','parser.py',377), + ('flag-list -> flag-value','flag-list',1,'p_flag_list','parser.py',378), + ('flag-value -> BSLASH WORD','flag-value',2,'p_flag_value','parser.py',385), + ('flag-value -> WORD','flag-value',1,'p_flag_value','parser.py',386), + ('cmd_fetch -> FETCH SP sequence SP LPAREN fetch-attrs RPAREN','cmd_fetch',7,'p_cmd_fetch','parser.py',396), + ('cmd_fetch -> FETCH SP sequence SP WORD','cmd_fetch',5,'p_cmd_fetch','parser.py',397), + ('sequence -> sequence COMMA range','sequence',3,'p_sequence','parser.py',427), + ('sequence -> range','sequence',1,'p_sequence','parser.py',428), + ('range -> NUMBER COLON NUMBER','range',3,'p_range','parser.py',435), + ('range -> NUMBER COLON STAR','range',3,'p_range','parser.py',436), + ('range -> NUMBER','range',1,'p_range','parser.py',437), + ('fetch-attrs -> fetch-attrs SP fetch-attr','fetch-attrs',3,'p_fetch_attrs','parser.py',446), + ('fetch-attrs -> fetch-attr','fetch-attrs',1,'p_fetch_attrs','parser.py',447), + ('fetch-attr -> WORD LSBRACKET NUMBER RSBRACKET fetch-partial','fetch-attr',5,'p_fetch_attr','parser.py',454), + ('fetch-attr -> WORD LSBRACKET section RSBRACKET fetch-partial','fetch-attr',5,'p_fetch_attr','parser.py',455), + ('fetch-attr -> WORD','fetch-attr',1,'p_fetch_attr','parser.py',456), + ('fetch-partial -> LTSIGN NUMBER DOT NUMBER GTSIGN','fetch-partial',5,'p_fetch_partial','parser.py',481), + ('fetch-partial -> LTSIGN NUMBER GTSIGN','fetch-partial',3,'p_fetch_partial','parser.py',482), + ('fetch-partial -> empty','fetch-partial',1,'p_fetch_partial','parser.py',483), + ('section -> WORD SP header-list','section',3,'p_section_spec','parser.py',493), + ('section -> WORD','section',1,'p_section_spec','parser.py',494), + ('section -> empty','section',1,'p_section_spec','parser.py',495), + ('header-list -> LPAREN headers RPAREN','header-list',3,'p_header_list','parser.py',513), + ('headers -> headers SP WORD','headers',3,'p_headers','parser.py',520), + ('headers -> WORD','headers',1,'p_headers','parser.py',521), + ('empty -> ','empty',0,'p_empty','parser.py',528), +] diff --git a/jiramail/imap_proto/parser.py b/jiramail/imap_proto/parser.py new file mode 100644 index 0000000..ca8d319 --- /dev/null +++ b/jiramail/imap_proto/parser.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 + +import re +import logging + +from typing import Dict, Any + +import ply.lex as lex # type: ignore +import ply.yacc as yacc # type: ignore + +from ply.lex import TOKEN + +logger = logging.getLogger("imap_proto") +logger.setLevel(logging.DEBUG) + + +class Node(Dict[str, Any]): + def __init__(self, name: str, value: Any): + self["name"] = name + self["value"] = value + +class _ParserError(Exception): + def __init__(self, message: str): + self.message = message + + def __str__(self) -> str: + return f"Error: {self.message}" + +class _LexError(_ParserError): + pass + +class _YaccError(_ParserError): + pass + + +class Error(BaseException): + def __init__(self, err: _ParserError): + self.err = err + + def __str__(self) -> str: + return str(self.err) + + +class IMAPParser: + def __init__(self, **kwargs: Dict[str, Any]) -> None: + debuglog = None + + if "debug" in kwargs and kwargs["debug"]: + debuglog = logger + + self.lex = lex.lex(reflags=re.IGNORECASE, module=self, errorlog=logger, + lextab="_imaplex_tab", optimize=True, **kwargs) + + self.yacc = yacc.yacc(start="command", module=self, + errorlog=logger, debuglog=debuglog, + tabmodule="_imapyacc_tab", optimize=True, **kwargs) + + + commands = [ + "FETCH", "CAPABILITY", "CHECK", "CLOSE", "NOOP", "LOGOUT", + "EXPUNGE", "SELECT", "LIST", "LOGIN", "UID", "STORE", "CREATE", + "DELETE", "RENAME", "COPY", "EXAMINE", "STATUS", "SUBSCRIBE", + "UNSUBSCRIBE", "LSUB", "AUTHENTICATE", + ] + + tokens = [ + "SP", "EOL", "NUMBER", "WORD", "MAILBOX", "QUOTED", + "COLON", "COMMA", "DOT", "STAR", "PLUS", "MINUS", "BSLASH", + "LSBRACKET", "RSBRACKET", "LPAREN", "RPAREN", "LTSIGN", "GTSIGN", + ] + commands + + t_INITIAL_EOL = r'\s*\r?\n' + t_ANY_SP = r'\ ' + t_ANY_STAR = r'\*' + t_ANY_COLON = r':' + t_ANY_COMMA = r',' + t_ANY_DOT = r'\.' + t_ANY_PLUS = r'\+' + t_ANY_MINUS = r'\-' + t_ANY_BSLASH = r'\\' + t_ANY_QUOTED = r'"[^"]*"' + + states = ( + ('paren','inclusive'), + ('bracket','inclusive'), + ('sign','inclusive'), + ) + + @TOKEN(r'\d+') # type: ignore + def t_ANY_NUMBER(self, t: lex.LexToken) -> lex.LexToken: + try: + t.value = int(t.value) + except ValueError: + print("Integer value to large %d", t.value) + t.value = 0 + return t + + + @TOKEN(r'[0-9a-z?#%~_.-]+') # type: ignore + def t_ANY_WORD(self, t: lex.LexToken) -> lex.LexToken: + if not t.lexer.lexstatestack: + v = t.value.upper() + if v in self.commands: + t.type = v + t.value = v + return t + + if re.match(r'^[0-9a-z](?:[0-9a-z_.-]*[0-9a-z])?$', t.value, re.IGNORECASE): + t.type = "WORD" + return t + + t.type = "MAILBOX" + return t + + + @TOKEN(r'\(') # type: ignore + def t_ANY_LPAREN(self, t: lex.LexToken) -> lex.LexToken: + t.type = "LPAREN" + t.lexer.push_state('paren') + return t + + + @TOKEN(r'\)') # type: ignore + def t_ANY_RPAREN(self, t: lex.LexToken) -> lex.LexToken: + t.type = "RPAREN" + t.lexer.pop_state() + return t + + + @TOKEN(r'\[') # type: ignore + def t_ANY_LSBRACKET(self, t: lex.LexToken) -> lex.LexToken: + t.type = "LSBRACKET" + t.lexer.push_state('bracket') + return t + + + @TOKEN(r'\]') # type: ignore + def t_ANY_RSBRACKET(self, t: lex.LexToken) -> lex.LexToken: + t.type = "RSBRACKET" + t.lexer.pop_state() + return t + + @TOKEN(r'\<') # type: ignore + def t_ANY_LTSIGN(self, t: lex.LexToken) -> lex.LexToken: + t.type = "LTSIGN" + t.lexer.push_state('sign') + return t + + + @TOKEN(r'\>') # type: ignore + def t_ANY_GTSIGN(self, t: lex.LexToken) -> lex.LexToken: + t.type = "GTSIGN" + t.lexer.pop_state() + return t + + + def t_error(self, t: lex.LexToken) -> None: + t.lexer.skip(1) + raise _LexError(f"illegal character '{t.value[0]}'") + + + def rule_list(self, name: str, p: yacc.YaccProduction, + maxlen: int=4, recurpos: int=1, + startpos: int=1, appendpos: int=3) -> None: + if len(p) == maxlen: + p[recurpos]["value"].append(p[appendpos]) + p[0] = p[recurpos] + else: + p[0] = Node(name, [p[startpos]]) + + + def unquote(self, data: str) -> str: + if data.startswith('"') and data.endswith('"'): + return data[1:-1] + return data + + + def p_command(self, p: yacc.YaccProduction) -> None: + ''' + command : tag SP cmd_noop EOL + | tag SP cmd_authenticate EOL + | tag SP cmd_capability EOL + | tag SP cmd_check EOL + | tag SP cmd_close EOL + | tag SP cmd_copy EOL + | tag SP cmd_create EOL + | tag SP cmd_delete EOL + | tag SP cmd_examine EOL + | tag SP cmd_expunge EOL + | tag SP cmd_fetch EOL + | tag SP cmd_list EOL + | tag SP cmd_login EOL + | tag SP cmd_logout EOL + | tag SP cmd_lsub EOL + | tag SP cmd_rename EOL + | tag SP cmd_select EOL + | tag SP cmd_status EOL + | tag SP cmd_store EOL + | tag SP cmd_subscribe EOL + | tag SP cmd_uid EOL + | tag SP cmd_unsubscribe EOL + ''' + p[0] = Node("command", { + "tag": p[1], + "cmd": p[3], + }) + + + def p_tag(self, p: yacc.YaccProduction) -> None: + ''' + tag : WORD + | NUMBER + ''' + p[0] = p[1] + + + def p_cmd_COMMON_NOARGS(self, p: yacc.YaccProduction) -> None: + ''' + cmd_capability : CAPABILITY + cmd_check : CHECK + cmd_close : CLOSE + cmd_expunge : EXPUNGE + cmd_logout : LOGOUT + cmd_noop : NOOP + ''' + p[0] = Node(p[1], {}) + + + def p_cmd_COMMON_MAILBOX_ARG(self, p: yacc.YaccProduction) -> None: + ''' + cmd_create : CREATE SP mailbox + cmd_delete : DELETE SP mailbox + cmd_select : SELECT SP mailbox + cmd_examine : EXAMINE SP mailbox + cmd_subscribe : SUBSCRIBE SP mailbox + cmd_unsubscribe : UNSUBSCRIBE SP mailbox + ''' + p[0] = Node(p[1], { + "mailbox": p[3], + }) + + + def p_cmd_authenticate(self, p: yacc.YaccProduction) -> None: + ''' + cmd_authenticate : AUTHENTICATE SP WORD + ''' + p[0] = Node(p[1], p[3]) + + + def p_cmd_login(self, p: yacc.YaccProduction) -> None: + ''' + cmd_login : LOGIN SP QUOTED SP QUOTED + ''' + p[0] = Node(p[1], { + "username": self.unquote(p[3]), + "password": self.unquote(p[5]), + }) + + + def p_cmd_list(self, p: yacc.YaccProduction) -> None: + ''' + cmd_list : LIST SP mailbox SP mailbox + cmd_lsub : LSUB SP mailbox SP mailbox + ''' + p[0] = Node(p[1], { + "refname": p[3], + "mailbox": p[5], + }) + + + def p_cmd_rename(self, p: yacc.YaccProduction) -> None: + ''' + cmd_rename : RENAME SP mailbox SP mailbox + ''' + p[0] = Node(p[1], { + "mailbox_old": p[3], + "mailbox_new": p[5], + }) + + + def p_cmd_copy(self, p: yacc.YaccProduction) -> None: + ''' + cmd_copy : COPY SP sequence SP mailbox + ''' + p[0] = Node(p[1], { + "sequence": p[3], + "mailbox": p[5], + }) + + + def p_cmd_status(self, p: yacc.YaccProduction) -> None: + ''' + cmd_status : STATUS SP mailbox SP LPAREN status-items RPAREN + ''' + p[0] = Node(p[1], { + "mailbox": p[3], + "items": p[6], + }) + + + def p_status_items(self, p: yacc.YaccProduction) -> None: + ''' + status-items : status-items SP WORD + | WORD + ''' + self.rule_list("status-items", p) + + + def p_mailbox(self, p: yacc.YaccProduction) -> None: + ''' + mailbox : QUOTED + | MAILBOX + | WORD + ''' + p[0] = self.unquote(p[1]) + + + def p_cmd_uid(self, p: yacc.YaccProduction) -> None: + ''' + cmd_uid : UID SP FETCH SP sequence SP LPAREN fetch-attrs RPAREN + | UID SP FETCH SP sequence SP fetch-attrs + | UID SP STORE SP sequence SP store_args + ''' + p[0] = Node(f"{p[1]} {p[3]}", { "name": p[3].lower() }) + + match p[0]["value"]["name"]: + case "fetch": + p[0]["value"]["sequence"] = p[5] + + if len(p) == 8: + p[0]["value"]["attrs"] = p[7] + elif len(p) == 10: + p[0]["value"]["attrs"] = p[8] + p[0]["value"]["attrs"]["value"].insert(0, Node("attr", "UID")) + + case "store": + p[0]["value"]["sequence"] = p[5] + p[0]["value"]["value"] = p[7] + + + def p_cmd_store(self, p: yacc.YaccProduction) -> None: + ''' + cmd_store : STORE SP sequence SP store_args + ''' + p[0] = Node(p[1], { + "sequence": p[3], + "value": p[5], + }) + + + def p_store_args(self, p: yacc.YaccProduction) -> None: + ''' + store_args : PLUS WORD SP LPAREN flag-list RPAREN + | MINUS WORD SP LPAREN flag-list RPAREN + | WORD SP LPAREN flag-list RPAREN + ''' + p[0] = Node("store-args", {}) + p[0]["value"]["op"] = "replace" + pos_word = 1 + + if p[1] == "+": + p[0]["value"]["op"] = "add" + pos_word += 1 + elif p[1] == "-": + p[0]["value"]["op"] = "remove" + pos_word += 1 + + if p[pos_word] not in ("FLAGS", "FLAGS.SILENT"): + self.p_error(p.slice[pos_word]) + + p[0]["value"]["item"] = p[pos_word].upper() + p[0]["value"]["flags"] = p[pos_word+3] + + + def p_flag_list(self, p: yacc.YaccProduction) -> None: + ''' + flag-list : flag-value SP flag-value + | flag-value + ''' + self.rule_list("flags", p) + + + def p_flag_value(self, p: yacc.YaccProduction) -> None: + ''' + flag-value : BSLASH WORD + | WORD + ''' + if len(p) == 3: + p[0] = Node("flag", p[2]) + else: + p[0] = Node("flag", p[1]) + + + def p_cmd_fetch(self, p: yacc.YaccProduction) -> None: + ''' + cmd_fetch : FETCH SP sequence SP LPAREN fetch-attrs RPAREN + | FETCH SP sequence SP WORD + ''' + p[0] = Node(p[1], {}) + p[0]["value"]["sequence"] = p[3] + + if len(p) == 8: + p[0]["value"]["attrs"] = p[6] + else: + vals = [] + match p[5]: + case "FAST": + vals = [Node("attr", "FLAGS"), + Node("attr", "INTERNALDATE"), + Node("attr", "RFC822.SIZE")] + case "ALL": + vals = [Node("attr", "FLAGS"), + Node("attr", "INTERNALDATE"), + Node("attr", "RFC822.SIZE"), + Node("attr", "ENVELOPE")] + case "FULL": + vals = [Node("attr", "FLAGS"), + Node("attr", "INTERNALDATE"), + Node("attr", "RFC822.SIZE"), + Node("attr", "ENVELOPE"), + Node("attr", "BODY")] + p[0]["value"]["attrs"] = vals + + + def p_sequence(self, p: yacc.YaccProduction) -> None: + ''' + sequence : sequence COMMA range + | range + ''' + self.rule_list("sequence", p) + + + def p_range(self, p: yacc.YaccProduction) -> None: + ''' + range : NUMBER COLON NUMBER + | NUMBER COLON STAR + | NUMBER + ''' + p[0] = Node("range", { "begin": p[1], "end": p[1] }) + if len(p) == 4: + p[0]["value"]["end"] = p[3] + + + def p_fetch_attrs(self, p: yacc.YaccProduction) -> None: + ''' + fetch-attrs : fetch-attrs SP fetch-attr + | fetch-attr + ''' + self.rule_list("attrs", p) + + + def p_fetch_attr(self, p: yacc.YaccProduction) -> None: + ''' + fetch-attr : WORD LSBRACKET NUMBER RSBRACKET fetch-partial + fetch-attr : WORD LSBRACKET section RSBRACKET fetch-partial + | WORD + ''' + if len(p) >= 5: + if p[1].upper() not in ("BODY", "BODY.PEEK"): + self.p_error(p.slice[1]) + + p[0] = Node(p[1].lower(), {}) + + if isinstance(p[3], int): + p[0]["value"]["section"] = Node("part", p[3]) + else: + p[0]["value"]["section"] = p[3] + + if len(p) == 6 and p[5]: + p[0]["value"]["partial"] = p[5] + else: + v = p[1].upper() + if v not in ("BODYSTRUCTURE", "ENVELOPE", "FLAGS", "INTERNALDATE", "RFC822.HEADER", "RFC822.SIZE", "RFC822.TEXT", "UID"): + self.p_error(p.slice[1]) + + p[0] = Node("attr", v) + + + def p_fetch_partial(self, p: yacc.YaccProduction) -> None: + ''' + fetch-partial : LTSIGN NUMBER DOT NUMBER GTSIGN + | LTSIGN NUMBER GTSIGN + | empty + ''' + if len(p) == 6: + p[0] = Node("partial", {"number": p[2], "size": p[4] }) + elif len(p) == 4: + p[0] = Node("partial", {"number": p[2], "size": 2**64 }) + + + def p_section_spec(self, p: yacc.YaccProduction) -> None: + ''' + section : WORD SP header-list + | WORD + | empty + ''' + if len(p) > 1 and p[1]: + p1 = p[1].upper() + + if p1 not in ("HEADER.FIELDS", "HEADER.FIELDS.NOT", "HEADER", "TEXT", "MIME"): + self.p_error(p.slice[1]) + + if len(p) == 4: + p[0] = Node(p1.lower(), p[3]) + elif len(p) == 2: + p[0] = Node(p1.lower(), p1) + else: + p[0] = Node("_full", []) + + + def p_header_list(self, p: yacc.YaccProduction) -> None: + ''' + header-list : LPAREN headers RPAREN + ''' + p[0] = p[2]["value"] = [x.upper() for x in p[2]["value"]] + + + def p_headers(self, p: yacc.YaccProduction) -> None: + ''' + headers : headers SP WORD + | WORD + ''' + self.rule_list("headers", p) + + + def p_empty(self, p: yacc.YaccProduction) -> None: + ''' + empty : + ''' + + + def p_error(self, p: lex.LexToken) -> None: + if p: + raise _YaccError(f"syntax error at token {p.type} (line={p.lineno}:{p.lexpos}): {p.value}") + else: + raise _YaccError("unexpected EOF") + + + def tokenize(self, data: str) -> None: + self.lex.input(data) + while True: + tok = self.lex.token() + if not tok: + break + print(tok.type, tok.value, tok.lineno, tok.lexpos) + + + def parse(self, data: str) -> Node | Error: + try: + node = self.yacc.parse(data) + except _ParserError as e: + return Error(e) + if not isinstance(node, Node): + return Error(_ParserError(f"unexpected parser result {node}")) + return node diff --git a/jiramail/smtp.py b/jiramail/smtp.py index b81cb12..459bcfd 100644 --- a/jiramail/smtp.py +++ b/jiramail/smtp.py @@ -5,21 +5,15 @@ __author__ = 'Alexey Gladkov ' import argparse -import base64 -import binascii import email import email.message -import hashlib -import hmac -import os -import random import re import socket -import time from typing import Tuple, Dict, List, Any import jiramail +import jiramail.auth as auth import jiramail.change logger = jiramail.logger @@ -95,36 +89,9 @@ def command_ehlo(state: Dict[str, Any], args: str) -> SMTPAnswer: return SMTPAnswer("".join(ans)) -def command_auth_cram_md5(state: Dict[str, Any]) -> Tuple[bool, SMTPAnswer]: - pid = os.getpid() - now = time.time_ns() - rnd = random.randrange(2**32 - 1) - - shared = f"<{pid}.{now}.{rnd}@jiramail>" - shared_base64 = base64.b64encode(shared.encode()).decode() - - send(state["conn"], f"334 {shared_base64}\r\n") - - line = recv_line(state) - - try: - buf = base64.standard_b64decode(line).decode() - except binascii.Error: - return (False, SMTPAnswer("502 Couldn't decode your credentials\r\n")) - - fields = buf.split(" ") - - if len(fields) != 2: - return (False, SMTPAnswer("502 Wrong number of fields in the token\r\n")) - - hexdigest = hmac.new(state["password"].encode(), - shared.encode(), - hashlib.md5).hexdigest() - - if hmac.compare_digest(state["user"], fields[0]) and hmac.compare_digest(hexdigest, fields[1]): - return (True, SMTPAnswer("235 2.7.0 Authentication successful\r\n")) - - return (False, SMTPAnswer("535 Authentication credentials invalid\r\n")) +def auth_interact(state: Dict[str, Any], shared: str) -> Any: + send(state["conn"], f"334 {shared}\r\n") + return recv_line(state) def command_auth(state: Dict[str, Any], args: str) -> SMTPAnswer: @@ -145,7 +112,10 @@ def command_auth(state: Dict[str, Any], args: str) -> SMTPAnswer: # used without the presence of an external security layer such as [TLS]. match auth_type: case "CRAM-MD5": - (state["authorized"], ans) = command_auth_cram_md5(state) + (state["authorized"], msg) = auth.cram_md5(state["user"], state["password"], auth_interact, state) + if not state["authorized"]: + return SMTPAnswer(f"535 {msg}\r\n") + ans = SMTPAnswer("235 2.7.0 Authentication successful\r\n") case _: return SMTPAnswer("504 5.5.2 Unrecognized authentication type\r\n") diff --git a/jiramail/subs.py b/jiramail/subs.py index 28feee5..c10b9b1 100644 --- a/jiramail/subs.py +++ b/jiramail/subs.py @@ -17,6 +17,29 @@ config_section = "sub" +def get_mailbox(config: Dict[str, Any], name: str) -> str: + if name not in config[config_section]: + return "" + return str(config[config_section][name]["mbox"]) + + +def get_queries(config: Dict[str, Any], name: str) -> List[str]: + queries: List[str] = [] + + if name not in config[config_section]: + return queries + + section = config[config_section][name] + + if "assignee" in section: + queries.append(f"assignee = '{section.get('assignee')}'") + + if "query" in section: + queries.append(section["query"]) + + return queries + + def sync_mailbox(config: Dict[str, Any], mailbox: str, queries: Dict[str, List[str]]) -> int: logger = jiramail.setup_logger(multiprocessing.get_logger(), level=jiramail.logger.level, @@ -76,20 +99,12 @@ def main(cmdargs: argparse.Namespace) -> int: config_section, target) return jiramail.EX_FAILURE - mailbox = section["mbox"] + mailbox = get_mailbox(config, target) if mailbox not in mailboxes: mailboxes[mailbox] = {} - if target not in mailboxes[mailbox]: - mailboxes[mailbox][target] = [] - - if "assignee" in section: - username = section["assignee"] - mailboxes[mailbox][target].append(f"assignee = '{username}'") - - if "query" in section: - mailboxes[mailbox][target].append(section["query"]) + mailboxes[mailbox][target] = get_queries(config, target) nprocs = min(5, len(mailboxes.keys())) diff --git a/requirements.txt b/requirements.txt index 3b7a13b..26a9f4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ jira>=3.5.2 tabulate>=0.9.0 +ply>=3.11