CPanel Email Filter API Incorrectly Documented
Not sure where is best to put this, but just wanted to flag to the CPanel team and other users that the UAPI delete_filter DOES NOT require the account field, despite stating it is required in the documentation at https://api.docs.cpanel.net/specifications/cpanel.openapi/email-filtering/delete_filter.
I couldn't find a global equivalent, so for the sake of it I just tried omitting the account field, since similar API functions mentioned that if not specified, it would use the global filters instead.
Lo and behold, this behavior also exists on delete_filter!
Hope this helps someone in the future.
Kind regards,
SEBO Australia IT
-
Hey there! Could you provide me with the specific API call you're using where this works? I tried removing the specific "account portion of the API call and it failed:
[root@host ~]# uapi --user=username Email delete_filter filtername='test2'
---
apiversion: 3
func: delete_filter
module: Email
result:
data: ~
errors:
- Filter "test2" not found.
messages: ~
metadata: {}
status: 0
warnings: ~but adding that back in worked well:
[root@host ~]# uapi --user=username Email delete_filter account='cptest@domain.com' filtername='test2'
---
apiversion: 3
func: delete_filter
module: Email
result:
data:
filtername: test2
errors: ~
messages:
- Filter "test2" deleted for "cptest@domain.com".
metadata: {}
status: 1
warnings: ~If you have an example where this is working how you've explained I'd be happy to bring that up with the development team.
0 -
Hi, I've attached a screenshot of me removing the global filter I added called 'testfilter'

The command also omits the user flag since we are running as the CPanel user and not root, maybe that changes the behaviour somehow? But if my understanding is correct, that would be unexpected.
We are using cPanel version 110.0.15 (though we have asked our hosting manager to update CPanel to a more recent version ASAP due to CVE-2026-41940).
I was even able to set up a rudimentary python2 script to sync the spam filtering between our servers as a cronjob (our hosting provider uses an extremely restrictive cagefs, we can't even run curl, pip, or python3, but we can run pycurl within python). It has been working like a charm so far, though it is a bit hacky.import pycurl
from StringIO import StringIO
import json
import subprocess
import sys
import re
def request(api_call):
buffer = StringIO()
c = pycurl.Curl()
# port 2083 is seemingly blocked by our hosting provider, but allowed through the cpanel subdomain.
urlopt = 'https://cpanel.<domain name>/execute/%s' % (api_call)
print("Connecting to " + urlopt)
c.setopt(c.URL, urlopt)
c.setopt(c.CONNECTTIMEOUT, 10)
c.setopt(c.TIMEOUT, 30)
c.setopt(c.HTTPHEADER,['Authorization: cpanel <userB>::<API KEY>'])
c.setopt(c.SSL_VERIFYPEER, 0) # yolo
c.setopt(c.SSL_VERIFYHOST, 0)
c.setopt(c.WRITEFUNCTION, buffer.write)
c.perform()
c.close()
return buffer.getvalue()
def run_local_uapi(*args):
"""Executes a local UAPI command securely via subprocess without shell."""
cmd = ['uapi', '--output=json'] + list(args)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = proc.communicate()
if proc.returncode != 0:
print("UAPI Subprocess Error: %s" % err)
return out
def transform_string(val):
"""Replaces domains/users securely while preventing Python 2 UnicodeEncodeErrors."""
if val is None:
return ''
# Safely convert to a unicode object first
if isinstance(val, str):
val_uni = val.decode('utf-8', 'replace')
elif isinstance(val, unicode):
val_uni = val
else:
val_uni = unicode(val)
# Strip the literal cPanel variable prefix
val_uni = val_uni.replace(u'$home/mail', u'')
# Strip the physical absolute mail path prefix (just in case)
val_uni = re.sub(ur'/home[0-9]*/<userB>/mail', u'', val_uni)
# Do the regular domain and username replacements
transformed = val_uni.replace(u'<domainA>', u'<domainB>').replace(u'<userA>', u'<userB>')
# Encode back to a utf-8 byte string so subprocess/print don't crash
return transformed.encode('utf-8')
def make_uapi_arg(key, val_bytes):
"""Safely builds key=value arguments. Escapes leading '@' to prevent cPanel file lookups."""
# If the value starts with @, prepend a backslash
if val_bytes.startswith('@'):
val_bytes = '\\' + val_bytes
return "%s=%s" % (key, val_bytes)
try:
# Fetch remote filters and strictly verify successful retrieval BEFORE doing anything else
print("Fetching remote filters...")
remote_response = request('Email/list_filters')
remote_data = json.loads(remote_response)
if remote_data.get('status') != 1:
print("Failed to fetch remote filters. Errors: %s" % remote_data.get('errors'))
sys.exit(1)
remote_filters = remote_data.get('data',[])
if not remote_filters:
print("No filters found on the remote server to migrate.")
sys.exit(0)
print("Successfully retrieved %d filters from remote server." % len(remote_filters))
# Fetch local filters via CLI UAPI
print("Fetching local filters for deletion...")
local_response = run_local_uapi('Email', 'list_filters')
local_data = json.loads(local_response)
# Local CLI UAPI still wraps its response in a 'result' dict
local_result = local_data.get('result', {})
# Delete existing local filters (Now safe, remote filters are verified!)
if local_result.get('status') == 1:
local_filters = local_result.get('data',[])
for f in local_filters:
fname = f.get('filtername')
if fname:
print("Deleting local filter: %s" % fname)
run_local_uapi('Email', 'delete_filter', 'filtername=%s' % fname)
else:
print("Failed to fetch local filters. Cannot proceed with deletion safely.")
print("Errors: %s" % local_result.get('errors'))
sys.exit(1)
# Migrate and store the new filters
print("Migrating filters to local server...")
for f in remote_filters:
fname = transform_string(f.get('filtername'))
# Build the command arguments array for Python subprocess
args =['Email', 'store_filter', make_uapi_arg('filtername', fname)]
# Parse & Add Conditions
rules = f.get('rules',[])
for i, rule in enumerate(rules):
idx = i + 1 # Parameters are 1-indexed (e.g. part1, match1)
args.append(make_uapi_arg('part%d' % idx, transform_string(rule.get('part'))))
args.append(make_uapi_arg('match%d' % idx, transform_string(rule.get('match'))))
args.append(make_uapi_arg('val%d' % idx, transform_string(rule.get('val'))))
if rule.get('opt'):
args.append(make_uapi_arg('opt%d' % idx, transform_string(rule.get('opt'))))
# Parse & Add Destinations
actions = f.get('actions',[])
for i, action in enumerate(actions):
idx = i + 1 # Parameters are 1-indexed (e.g. action1, dest1)
args.append(make_uapi_arg('action%d' % idx, transform_string(action.get('action'))))
args.append(make_uapi_arg('dest%d' % idx, transform_string(action.get('dest'))))
print("Storing local filter: %s" % fname)
# Execute the UAPI store_filter command locally
store_response = run_local_uapi(*args)
try:
store_data = json.loads(store_response)
store_result = store_data.get('result', {})
if store_result.get('status') != 1:
print("Failed to store filter '%s'. Errors: %s" % (fname, store_result.get('errors')))
except ValueError:
print("Failed to parse JSON response when storing filter '%s'. Response: %s" % (fname, store_response))
print("Filter migration complete!")
except Exception as err:
print("Error: %s" % err)0
Please sign in to leave a comment.
Comments
2 comments