diff --git a/code/README b/code/README
new file mode 100644
index 0000000000000000000000000000000000000000..1c0d89bde03ac497c1fe93aaa733604d70e59a66
--- /dev/null
+++ b/code/README
@@ -0,0 +1,4 @@
+Copy zoom-code to /usr/local/lib/dovecot/sieve-pipe/, so that it can be
+executed by Sieve.
+
+Set up Sieve to pipe e-mail with the right subject to this filter.
diff --git a/code/zoom-code b/code/zoom-code
new file mode 100755
index 0000000000000000000000000000000000000000..88653d291b57074ad129cb78ce559681d51433e4
--- /dev/null
+++ b/code/zoom-code
@@ -0,0 +1,52 @@
+#!/usr/bin/python3
+# Sieve pipe handler for processing Zoom login codes
+
+import email
+import email.policy
+import os
+import re
+import subprocess
+import sys
+import syslog
+from tempfile import NamedTemporaryFile
+
+def fail(msg):
+    syslog.syslog(syslog.LOG_ERR, msg)
+    sys.exit(0)
+
+def mumble(msg):
+    syslog.syslog(syslog.LOG_INFO, msg)
+
+def main():
+    msg = email.message_from_file(sys.stdin, policy=email.policy.default)
+    to = msg.get('To', "")
+    m = re.match('(zoom(-m)?-[0-9]{1,2})@', to)
+    if not m:
+        fail('Cannot determine recipient account')
+    user = m[1]
+
+    body = msg.get_body()
+    body_ct = body.get_content_type()
+    if body_ct == 'text/html':
+        tmpf = NamedTemporaryFile(mode='w')
+        tmpf.write(body.get_content())
+        tmpf.flush()
+
+        res = subprocess.run(['/usr/bin/lynx', '-force_html', '-nolist', '-display_charset=utf-8', '-dump', tmpf.name], stdout=subprocess.PIPE, text=True)
+        if res.returncode != 0:
+            fail(f'Subprocess failed with return code {res.returncode}')
+
+        out = res.stdout
+    else:
+        out = body.get_content()
+
+    os.umask(0o022)
+    with open(f"/srv/mffzoom/code/www/{user}.txt", "w") as f:
+        f.write(out)
+
+try:
+    syslog.openlog('zoom-code')
+    main()
+    mumble('Login code stored')
+except Exception as e:
+    fail(str(e))