mirror of
https://github.com/zulip/zulip.git
synced 2025-11-14 02:48:00 +00:00
Refactor email-mirror to handle running on any machine
(imported from commit 2971449ceaacb564770e66874fc095f77e68d445)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user