Refactor email-mirror to handle running on any machine

(imported from commit 2971449ceaacb564770e66874fc095f77e68d445)
This commit is contained in:
Leo Franchi
2013-10-08 15:02:47 -04:00
parent 9979d884e5
commit 2d276179d0
4 changed files with 80 additions and 43 deletions

View File

@@ -1547,12 +1547,21 @@ def encode_email_address_helper(name, email_token):
# ordinal of that character, padded with zeroes to the maximum number of # ordinal of that character, padded with zeroes to the maximum number of
# bytes of a UTF-8 encoded Unicode character. # bytes of a UTF-8 encoded Unicode character.
encoded_name = re.sub("\W", lambda x: "%" + str(ord(x.group(0))).zfill(4), name) encoded_name = re.sub("\W", lambda x: "%" + str(ord(x.group(0))).zfill(4), name)
return "%s+%s@streams.zulip.com" % (encoded_name, email_token) encoded_token = "%s+%s" % (encoded_name, email_token)
return settings.EMAIL_GATEWAY_PATTERN % (encoded_token,)
def decode_email_address(email): def decode_email_address(email):
# Perform the reverse of encode_email_address. Only the stream name will be # Perform the reverse of encode_email_address. Returns a tuple of (streamname, email_token)
# transformed. pattern_parts = [re.escape(part) for part in settings.EMAIL_GATEWAY_PATTERN.split('%s')]
return re.sub("%\d{4}", lambda x: unichr(int(x.group(0)[1:])), email) match_email_re = re.compile("(.*?)".join(pattern_parts))
match = match_email_re.match(email)
if not match:
return None
token = match.group(1)
decoded_token = re.sub("%\d{4}", lambda x: unichr(int(x.group(0)[1:])), token)
return decoded_token.split('+')
# In general, it's better to avoid using .values() because it makes # In general, it's better to avoid using .values() because it makes
# the code pretty ugly, but in this case, it has significant # the code pretty ugly, but in this case, it has significant

View File

@@ -1,7 +1,7 @@
#!/usr/bin/python #!/usr/bin/python
""" """
Forward messages sent to @streams.zulip.com to Zulip. Forward messages sent to the configured email gateway to Zulip.
Messages to that address go to the Inbox of emailgateway@zulip.com. Messages to that address go to the Inbox of emailgateway@zulip.com.
@@ -37,13 +37,6 @@ import html2text
sys.path.insert(0, path.join(path.dirname(__file__), "../../../api")) sys.path.insert(0, path.join(path.dirname(__file__), "../../../api"))
import zulip import zulip
GATEWAY_EMAIL = "emailgateway@zulip.com"
# Application-specific password.
PASSWORD = "xxxxxxxxxxxxxxxx"
SERVER = "imap.gmail.com"
PORT = 993
## Setup ## ## Setup ##
log_format = "%(asctime)s: %(message)s" log_format = "%(asctime)s: %(message)s"
@@ -57,28 +50,36 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logger.addHandler(file_handler) logger.addHandler(file_handler)
email_gateway_user = get_user_profile_by_email(GATEWAY_EMAIL) email_gateway_user = get_user_profile_by_email(settings.EMAIL_GATEWAY_BOT_ZULIP_USER)
api_key = email_gateway_user.api_key api_key = email_gateway_user.api_key
if settings.DEPLOYED: if settings.DEPLOYED:
staging_client = zulip.Client( staging_api_client = zulip.Client(
site="https://staging.zulip.com", email=GATEWAY_EMAIL, api_key=api_key) site="https://staging.zulip.com",
prod_client = zulip.Client( email=settings.EMAIL_GATEWAY_BOT_ZULIP_USER,
site="https://api.zulip.com", email=GATEWAY_EMAIL, api_key=api_key) api_key=api_key)
inbox = "INBOX"
api_client = zulip.Client(
site=settings.EXTERNAL_HOST,
email=settings.EMAIL_GATEWAY_BOT_ZULIP_USER,
api_key=api_key)
else: else:
staging_client = prod_client = zulip.Client( api_client = staging_api_client = zulip.Client(
site="http://localhost:9991/api", email=GATEWAY_EMAIL, api_key=api_key) site=settings.EXTERNAL_HOST,
inbox = "Test" email=settings.EMAIL_GATEWAY_BOT_ZULIP_USER,
api_key=api_key)
def redact_stream(error_message): def redact_stream(error_message):
stream_match = re.search("(\S+?)\+(\S+?)@streams.zulip.com", error_message) domain = settings.EMAIL_GATEWAY_PATTERN.rsplit('@')[-1]
stream_match = re.search(r'\b(.*?)@' + domain, error_message)
if stream_match: if stream_match:
stream_name = stream_match.groups()[0] stream_name = stream_match.groups()[0]
return error_message.replace(stream_name, "X" * len(stream_name)) return error_message.replace(stream_name, "X" * len(stream_name))
return error_message return error_message
def report_to_zulip(error_message): def report_to_zulip(error_message):
error_stream = Stream.objects.get(name="errors", realm__domain="zulip.com") error_stream = Stream.objects.get(name="errors", realm__domain=settings.ADMIN_DOMAIN)
send_zulip(error_stream, "email mirror error", send_zulip(error_stream, "email mirror error",
"""~~~\n%s\n~~~""" % (error_message,)) """~~~\n%s\n~~~""" % (error_message,))
@@ -103,13 +104,13 @@ class ZulipEmailForwardError(Exception):
pass pass
def send_zulip(stream, topic, content): def send_zulip(stream, topic, content):
if stream.realm.domain in ["zulip.com", "wdaher.com"]:
api_client = staging_client
else:
api_client = prod_client
# TODO: restrictions on who can send? Consider: cross-realm # TODO: restrictions on who can send? Consider: cross-realm
# messages, private streams. # messages, private streams.
if stream.realm.domain == 'zulip.com':
client = staging_api_client
else:
client = api_client
message_data = { message_data = {
"type": "stream", "type": "stream",
# TODO: handle rich formatting. # TODO: handle rich formatting.
@@ -119,7 +120,7 @@ def send_zulip(stream, topic, content):
"domain": stream.realm.domain "domain": stream.realm.domain
} }
response = api_client.send_message(message_data) response = client.send_message(message_data)
if response["result"] != "success": if response["result"] != "success":
raise ZulipEmailForwardError(response["msg"]) raise ZulipEmailForwardError(response["msg"])
@@ -189,12 +190,9 @@ def extract_and_upload_attachments(message):
return "\n".join(attachment_links) return "\n".join(attachment_links)
def extract_and_validate(email): def extract_and_validate(email):
# Recipient is of the form
# <stream name>+<regenerable stream token>@streams.zulip.com
try: try:
stream_name_and_token = decode_email_address(email).rsplit("@", 1)[0] stream_name, token = decode_email_address(email)
stream_name, token = stream_name_and_token.rsplit("+", 1) except TypeError:
except ValueError:
raise ZulipEmailForwardError("Malformed email recipient " + email) raise ZulipEmailForwardError("Malformed email recipient " + email)
if not valid_stream(stream_name, token): if not valid_stream(stream_name, token):
@@ -214,13 +212,24 @@ def delete(result, proto):
return proto.close().addCallback(logout, proto) return proto.close().addCallback(logout, proto)
def find_emailgateway_recipient(message): def find_emailgateway_recipient(message):
# We can't use Delivered-To; that is emailgateway@zulip.com. # We can't use Delivered-To; if there is a X-Gm-Original-To
recipients = message.get_all("X-Gm-Original-To", []) # it is more accurate, so try to find the most-accurate
# recipient list in descending priority order
recipient_headers = ["X-Gm-Original-To", "Delivered-To", "To"]
recipients = []
for recipient_header in recipient_headers:
r = message.get_all(recipient_header, None)
if r:
recipients = r
break
pattern_parts = [re.escape(part) for part in settings.EMAIL_GATEWAY_PATTERN.split('%s')]
match_email_re = re.compile(".*?".join(pattern_parts))
for recipient_email in recipients: for recipient_email in recipients:
if recipient_email.lower().endswith("@streams.zulip.com"): if match_email_re.match(recipient_email):
return recipient_email return recipient_email
raise ZulipEmailForwardError("Missing recipient @streams.zulip.com") raise ZulipEmailForwardError("Missing recipient in mirror email")
def fetch(result, proto, mailboxes): def fetch(result, proto, mailboxes):
if not result: if not result:
@@ -264,7 +273,7 @@ def examine_mailbox(result, proto, mailbox):
def select_mailbox(result, proto): def select_mailbox(result, proto):
# Select which mailbox we care about. # Select which mailbox we care about.
mbox = filter(lambda x: inbox in x[2], result)[0][2] mbox = filter(lambda x: settings.EMAIL_GATEWAY_IMAP_FOLDER in x[2], result)[0][2]
return proto.select(mbox).addCallback(examine_mailbox, proto, result) return proto.select(mbox).addCallback(examine_mailbox, proto, result)
def list_mailboxes(res, proto): def list_mailboxes(res, proto):
@@ -272,7 +281,7 @@ def list_mailboxes(res, proto):
return proto.list("","*").addCallback(select_mailbox, proto) return proto.list("","*").addCallback(select_mailbox, proto)
def connected(proto): def connected(proto):
d = proto.login(GATEWAY_EMAIL, PASSWORD) d = proto.login(settings.EMAIL_GATEWAY_LOGIN, settings.EMAIL_GATEWAY_PASSWORD)
d.addCallback(list_mailboxes, proto) d.addCallback(list_mailboxes, proto)
d.addErrback(login_failed) d.addErrback(login_failed)
return d return d
@@ -285,12 +294,12 @@ def done(_):
def main(): def main():
imap_client = protocol.ClientCreator(reactor, imap4.IMAP4Client) imap_client = protocol.ClientCreator(reactor, imap4.IMAP4Client)
d = imap_client.connectSSL(SERVER, PORT, ssl.ClientContextFactory()) d = imap_client.connectSSL(settings.EMAIL_GATEWAY_IMAP_SERVER, settings.EMAIL_GATEWAY_IMAP_PORT, ssl.ClientContextFactory())
d.addCallbacks(connected, login_failed) d.addCallbacks(connected, login_failed)
d.addBoth(done) d.addBoth(done)
class Command(BaseCommand): class Command(BaseCommand):
help = """Forward emails set to @streams.zulip.com to Zulip. help = """Forward emails sent to the configured email gateway to Zulip.
Run this command out of a cron job. Run this command out of a cron job.
""" """

View File

@@ -1368,7 +1368,7 @@ def send_message_backend(request, user_profile,
return json_error("User not authorized for this query") return json_error("User not authorized for this query")
realm = None realm = None
if domain: if domain and domain != user_profile.realm.domain:
if not is_super_user: if not is_super_user:
# The email gateway bot needs to be able to send messages in # The email gateway bot needs to be able to send messages in
# any realm. # any realm.

View File

@@ -117,3 +117,22 @@ else:
APNS_SANDBOX = "push_sandbox" APNS_SANDBOX = "push_sandbox"
APNS_FEEDBACK = "feedback_sandbox" APNS_FEEDBACK = "feedback_sandbox"
APNS_CERT_FILE = "/etc/ssl/django-private/apns-dev.pem" APNS_CERT_FILE = "/etc/ssl/django-private/apns-dev.pem"
# Administrator domain for this install
ADMIN_DOMAIN = "zulip.com"
# Email mirror configuration
# The email of the Zulip bot that the email gateway
# should post as
EMAIL_GATEWAY_BOT_ZULIP_USER = "emailgateway-bot@zulip.com"
EMAIL_GATEWAY_LOGIN = "emailgateway@zulip.com"
EMAIL_GATEWAY_PASSWORD = "xxxxxxxxxxxxxxxx"
EMAIL_GATEWAY_IMAP_SERVER = "imap.gmail.com"
EMAIL_GATEWAY_IMAP_PORT = 993
EMAIL_GATEWAY_IMAP_FOLDER = "INBOX"
# The email address pattern to use for auto-generated stream emails
# The %s will be replaced with a unique token, and the resulting email
# must be delivered to the Inbox of the EMAIL_GATEWAY_LOGIN account above
EMAIL_GATEWAY_PATTERN = "%s@streams.zulip.com"