mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	Implement a v1 email-to-zulip forwarder.
(imported from commit 5f91fd69ee2075413d0d4aedf71b878bf98c640e)
This commit is contained in:
		
							
								
								
									
										201
									
								
								zerver/management/commands/email-mirror.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										201
									
								
								zerver/management/commands/email-mirror.py
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,201 @@
 | 
			
		||||
#!/usr/bin/python
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
Forward messages sent to @streams.zulip.com to Zulip.
 | 
			
		||||
 | 
			
		||||
Messages to that address go to the Inbox of emailgateway@zulip.com.
 | 
			
		||||
 | 
			
		||||
Messages meant for Zulip have a special recipient form of
 | 
			
		||||
 | 
			
		||||
<stream name>+<regenerable stream token>@streams.zulip.com
 | 
			
		||||
 | 
			
		||||
We extract and validate the target stream from information in the
 | 
			
		||||
recipient address and retrieve, forward, and archive the message.
 | 
			
		||||
 | 
			
		||||
Run this management command out of a cron job.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import email
 | 
			
		||||
from os import path
 | 
			
		||||
import logging
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.core.management.base import BaseCommand
 | 
			
		||||
 | 
			
		||||
from zerver.models import Stream, get_user_profile_by_email
 | 
			
		||||
 | 
			
		||||
from twisted.internet import protocol, reactor, ssl
 | 
			
		||||
from twisted.mail import imap4
 | 
			
		||||
 | 
			
		||||
sys.path.insert(0, path.join(path.dirname(__file__), "../../../api"))
 | 
			
		||||
import zulip
 | 
			
		||||
 | 
			
		||||
GATEWAY_EMAIL = "emailgateway@zulip.com"
 | 
			
		||||
# Application-specific password.
 | 
			
		||||
PASSWORD = "xxxxxxxxxxxxxxxx"
 | 
			
		||||
 | 
			
		||||
SERVER = "imap.gmail.com"
 | 
			
		||||
PORT = 993
 | 
			
		||||
 | 
			
		||||
## Setup ##
 | 
			
		||||
 | 
			
		||||
log_file = "/var/log/humbug/email-mirror.log"
 | 
			
		||||
log_format = "%(asctime)s: %(message)s"
 | 
			
		||||
logging.basicConfig(format=log_format)
 | 
			
		||||
 | 
			
		||||
formatter = logging.Formatter(log_format)
 | 
			
		||||
file_handler = logging.FileHandler(log_file)
 | 
			
		||||
file_handler.setFormatter(formatter)
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
logger.setLevel(logging.DEBUG)
 | 
			
		||||
logger.addHandler(file_handler)
 | 
			
		||||
 | 
			
		||||
api_key = get_user_profile_by_email(GATEWAY_EMAIL).api_key
 | 
			
		||||
if settings.DEPLOYED:
 | 
			
		||||
    staging_client = zulip.Client(
 | 
			
		||||
        site="https://staging.zulip.com", email=GATEWAY_EMAIL, api_key=api_key)
 | 
			
		||||
    prod_client = zulip.Client(
 | 
			
		||||
        site="https://api.zulip.com", email=GATEWAY_EMAIL, api_key=api_key)
 | 
			
		||||
else:
 | 
			
		||||
    staging_client = prod_client = zulip.Client(
 | 
			
		||||
        site="http://localhost:9991/api", email=GATEWAY_EMAIL, api_key=api_key)
 | 
			
		||||
 | 
			
		||||
def log_and_raise(error_msg):
 | 
			
		||||
    logger.error(error_msg)
 | 
			
		||||
    raise ZulipEmailForwardError(error_msg)
 | 
			
		||||
 | 
			
		||||
## Sending the Zulip ##
 | 
			
		||||
 | 
			
		||||
class ZulipEmailForwardError(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
def send_zulip(stream, topic, content):
 | 
			
		||||
    if stream.realm.domain == "zulip.com":
 | 
			
		||||
        api_client = staging_client
 | 
			
		||||
    else:
 | 
			
		||||
        api_client = prod_client
 | 
			
		||||
 | 
			
		||||
    # TODO: restrictions on who can send? Consider: cross-realm
 | 
			
		||||
    # messages, private streams.
 | 
			
		||||
    message_data = {
 | 
			
		||||
        "type": "stream",
 | 
			
		||||
        # TODO: handle rich formatting.
 | 
			
		||||
        "content": content[:2000],
 | 
			
		||||
        "subject": topic[:60],
 | 
			
		||||
        "to": stream.name,
 | 
			
		||||
        "domain": stream.realm.domain
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    response = api_client.send_message(message_data)
 | 
			
		||||
    if response["result"] != "success":
 | 
			
		||||
        log_and_raise(response["msg"])
 | 
			
		||||
 | 
			
		||||
def valid_stream(stream_name, token):
 | 
			
		||||
    try:
 | 
			
		||||
        stream = Stream.objects.get(email_token=token)
 | 
			
		||||
        return stream.name.lower() == stream_name.lower()
 | 
			
		||||
    except Stream.DoesNotExist:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
def extract_and_validate(email):
 | 
			
		||||
    # Recipient is of the form
 | 
			
		||||
    # <stream name>+<regenerable stream token>@streams.zulip.com
 | 
			
		||||
    try:
 | 
			
		||||
        stream_name_and_token = email.rsplit("@", 1)[0]
 | 
			
		||||
        stream_name, token = stream_name_and_token.rsplit("+", 1)
 | 
			
		||||
    except ValueError:
 | 
			
		||||
        log_and_raise("Malformed email recipient " + email)
 | 
			
		||||
 | 
			
		||||
    if not valid_stream(stream_name, token):
 | 
			
		||||
        log_and_raise("Bad stream token from email recipient " + email)
 | 
			
		||||
 | 
			
		||||
    return Stream.objects.get(email_token=token)
 | 
			
		||||
 | 
			
		||||
## IMAP callbacks ##
 | 
			
		||||
 | 
			
		||||
def logout(result, proto):
 | 
			
		||||
    # Log out.
 | 
			
		||||
    return proto.logout()
 | 
			
		||||
 | 
			
		||||
def delete(result, proto):
 | 
			
		||||
    # Close the connection, which also processes any flags that were
 | 
			
		||||
    # set on messages.
 | 
			
		||||
    return proto.close().addCallback(logout, proto)
 | 
			
		||||
 | 
			
		||||
def find_emailgateway_recipient(message):
 | 
			
		||||
    # We can't use Delivered-To; that is emailgateway@zulip.com.
 | 
			
		||||
    for header in ("To", "Cc", "Bcc"):
 | 
			
		||||
        recipient = message.get(header)
 | 
			
		||||
        if recipient and recipient.lower().endswith("@streams.zulip.com"):
 | 
			
		||||
            return recipient
 | 
			
		||||
    raise ZulipEmailForwardError("Missing recipient @streams.zulip.com")
 | 
			
		||||
 | 
			
		||||
def fetch(result, proto, mailboxes):
 | 
			
		||||
    if not result:
 | 
			
		||||
        return proto.logout()
 | 
			
		||||
 | 
			
		||||
    message_uids = result.keys()
 | 
			
		||||
    # Make sure we forward the messages in time-order.
 | 
			
		||||
    message_uids.sort()
 | 
			
		||||
    for uid in message_uids:
 | 
			
		||||
        message = email.message_from_string(result[uid]["RFC822"])
 | 
			
		||||
        subject = message.get("Subject", "(no subject)")
 | 
			
		||||
        body = message.get_payload()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            to = find_emailgateway_recipient(message)
 | 
			
		||||
            stream = extract_and_validate(to)
 | 
			
		||||
            send_zulip(stream, subject, body)
 | 
			
		||||
        except ZulipEmailForwardError:
 | 
			
		||||
            # TODO: notify sender of error, retry if appropriate.
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    # Delete the processed messages from the Inbox.
 | 
			
		||||
    message_set = ",".join([result[key]["UID"] for key in message_uids])
 | 
			
		||||
    d = proto.addFlags(message_set, ["\\Deleted"], uid=True, silent=False)
 | 
			
		||||
    d.addCallback(delete, proto)
 | 
			
		||||
 | 
			
		||||
    return d
 | 
			
		||||
 | 
			
		||||
def examine_mailbox(result, proto, mailbox):
 | 
			
		||||
    # Fetch messages from a particular mailbox.
 | 
			
		||||
    return proto.fetchMessage("1:*", uid=True).addCallback(fetch, proto, mailbox)
 | 
			
		||||
 | 
			
		||||
def select_mailbox(result, proto):
 | 
			
		||||
    # Select which mailbox we care about.
 | 
			
		||||
    mbox = filter(lambda x: "INBOX" in x[2], result)[0][2]
 | 
			
		||||
    return proto.select(mbox).addCallback(examine_mailbox, proto, result)
 | 
			
		||||
 | 
			
		||||
def list_mailboxes(res, proto):
 | 
			
		||||
    # List all of the mailboxes for this account.
 | 
			
		||||
    return proto.list("","*").addCallback(select_mailbox, proto)
 | 
			
		||||
 | 
			
		||||
def connected(proto):
 | 
			
		||||
    d = proto.login(GATEWAY_EMAIL, PASSWORD)
 | 
			
		||||
    d.addCallback(list_mailboxes, proto)
 | 
			
		||||
    d.addErrback(login_failed)
 | 
			
		||||
    return d
 | 
			
		||||
 | 
			
		||||
def login_failed(failure):
 | 
			
		||||
    return failure
 | 
			
		||||
 | 
			
		||||
def done(_):
 | 
			
		||||
    reactor.callLater(0, reactor.stop)
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    imap_client = protocol.ClientCreator(reactor, imap4.IMAP4Client)
 | 
			
		||||
    d = imap_client.connectSSL(SERVER, PORT, ssl.ClientContextFactory())
 | 
			
		||||
    d.addCallbacks(connected, login_failed)
 | 
			
		||||
    d.addBoth(done)
 | 
			
		||||
 | 
			
		||||
class Command(BaseCommand):
 | 
			
		||||
    help = """Forward emails set to @streams.zulip.com to Zulip.
 | 
			
		||||
 | 
			
		||||
Run this command out of a cron job.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
    def handle(self, **options):
 | 
			
		||||
        reactor.callLater(0, main)
 | 
			
		||||
        reactor.run()
 | 
			
		||||
		Reference in New Issue
	
	Block a user