API
JSON over HTTP, /api prefix, session cookie or Authorization: Bearer <API token>. /docs, /redoc and /openapi.json are disabled.
POST /api/login {password};POST /api/logout;GET /api/mePOST /api/password {current, new}, change the admin password (new≥ 8 characters). Requires a valid session and a correctcurrent. Returns 409 if theADMIN_PASSWORDenv var is set (the env var always wins, so the password cannot be changed through the API). On success, the new hash is stored indata/admin.json, all other sessions are invalidated and a fresh session cookie is returned for the caller.- Servers:
GET/POST /api/servers,GET/PUT/DELETE /api/servers/{id},POST /api/servers/{id}/testServer ={id,name,imap_host,imap_port,imap_security:'ssl'|'starttls'|'none',verify_tls,last_test:float|null,last_test_result:{imap,caldav,carddav}|null,(stored by the test, cleared when a connection setting changes)root_folder:str|null,caldav_url:str|null,carddav_url:str|null,dav_enabled:bool,workers:int,notes,tuning:'auto'|'manual',builders:int|null}(manual:workersIMAP connections,buildersbuilder processes, null = machine default;tuningomitted on creation =manualifworkersis not 2 or 4 (earlier defaults) elseauto, omitted on update = unchanged) (caldav_url/carddav_url: empty =.well-knownauto-discovery, otherwise a template with{email},{user},{domain}) - Mailboxes (
accountin the API):GET /api/accounts?server_id=,POST /api/accounts {server_id,email,password,login?},PUT/DELETE /api/accounts/{id},POST /api/accounts/{id}/test→{imap:{ok,msg,quota},caldav:{ok,msg},carddav:{ok,msg}},POST /api/accounts/import-csv(multipart file + server_id; columnsemail;password[;login],,or;, optional header) →{created,updated,errors:[{line,msg}]};GET /api/accounts/csv-template. Account ={id,server_id,email,login,has_password,status:'new'|'ok'|'auth_failed'|'error',last_test,pst_id|null,source:{kind,config,has_secret},target:{kind|null,config,has_secret},options:{mail:bool,calendar:bool,contacts:bool},job:{...}|null} - Repair (see Repair a damaged mailbox), a list separate from the accounts:
GET/POST /api/repair/boxes {server_id,email,password,login?},PUT/DELETE /api/repair/boxes/{id}(409 while a run is active),POST /api/repair/boxes/{id}/test→{imap:{ok,msg,quota,uidplus}},POST /api/repair/boxes/import-csv(multipart file + server_id) →{created,updated,errors,ids}. Box ={id,server_id,email,login,has_password,status,last_test,last_test_result,last_scan:Run|null,active_run:Run|null}.POST /api/repair/runs {box_ids,mode:'scan'|'repair'|'restore',folder?}→ 202{runs:[Run],skipped_busy:[id]};GET /api/repair/runs/{id}(withreport:{issues,folders,examples,errors,notes}),GET /api/repair/boxes/{id}/runs,POST /api/repair/runs/{id}/stop. Run ={id,box_id,mode,status:'queued'|'running'|'stopping'|'stopped'|'done'|'done_with_errors'|'failed',folder,progress:{total,seen,affected,repaired,restored,skipped,errors,folder,folders_done,folders_total},error,issues}.POST /api/repair/boxes/{id}/preview {folder,uid}→{issues,already,before:{subject,text,parts},after|null,fixes,left,error}(reads only). - Connectors (see Connectors):
GET /api/connectors→[{kind,label,description,source:Role|null,target:Role|null}], with Role ={items:[...],capabilities:[...],fields:[{name,label,type,required,secret,default,options?,help?}]}. Render forms from it:typeistext,password,number,boolean,selectorurl, and new kinds need no UI change.GET/PUT /api/accounts/{id}/source,PUTbody{kind,config,secrets?}, returns the account.kind:"pst"goes back to the PST (config:{pst_id}optional). Migration state is kept per source: pointing at a source again resumes it without duplicates.PUT /api/accounts/{id}/pstonly changes the linked file: it does not switch an account away from another source, except between a PST (kind:"pst") and any other uploaded file (kind:"files",config:{pst_id}), which it sets itself.GET/PUT /api/accounts/{id}/target, same body,kind:nullgoes back to the server of the account. The target serves the items it declares, the server covers the others. Pointing at another mailbox forgets the account’s items: everything is sent again. Secrets:secretsomitted keeps them,{}clears them, a value replaces that field. They are never returned (has_secretonly). 422 invalid config or unknown kind, 409 while a job is active for the account.POST /api/connectors/{kind}/test {role:'source'|'target',config,secrets?,account_id?}tries a config without saving it (secretsomitted withaccount_id: those stored on the account for this kind) →{ok:true,msg,folders:[{path,kind,special,count}]}for a source,{ok:true,msg,quota,...}for a target,{ok:false,error_kind:'auth'|'config'|'unreachable'|'transient'|'rejected'|'quota'|'folder_missing',msg}when refused. Exchange kinds and their fields: see Exchange. A job’sprogressand report gaintaskson routes that carry tasks.POST /api/accounts/{id}/rules {delimiter?,root_folder?,trash_folder?,managesieve?:{host?,port?,security?,verify_tls?,login?,password?,script_name?,activate?}}reads the inbox rules of a source with therulescapability and converts them to Sieve, best effort →{ok:true,script,converted:[name],partial:[{name,dropped:[...]}],not_converted:[{name,reasons:[...]}],warnings:[...],uploaded,activated,upload_error:{error_kind,msg}|null},{ok:false,error_kind,msg}if the source refuses.managesieveuploads the script (default host, login and password: those of the account); it is not activated unlessactivateis true. 422 if the source cannot read rules. - PST upload (resumable):
POST /api/uploads {filename,size,last_modified}→{id,chunk_size,received:[n...]}(same file = same upload, resume)PUT /api/uploads/{id}/chunks/{n}binary body,X-Chunk-Sha256header → 200{n,ok}/ 422 if wrong hashGET /api/uploads/{id}→{id,filename,size,chunk_size,received:[...],status}POST /api/uploads/{id}/complete→ starts validation (signature + scan);GET /api/psts,GET /api/psts/{id},DELETE /api/psts/{id};GET /api/psts/{id}/preview?limit=20(limit 1..200) → zip (.eml/.ics/.vcf per folder, sample spread across mails/appointments/contacts) generated by the engine in a subprocess (app.server.preview, 120 s timeout); 404 unknown, 409 if PST notvalid, 500{detail:"crash libpff pendant l'aperçu"}if the subprocess dies; PST ={id,filename,size,status:'uploading'|'validating'|'valid'|'invalid',scan:{...},error}. An uploaded file that is not a PST (mbox, Maildir in a zip, .eml, .msg, .ics, .vcf, .zip) is analysed too:scan = {format:'files',contents:[...],folders:[{path,kind,count}],totals:{mail,calendar,contacts},skipped:[...],n_skipped}, and/previewanswers 409 for it. Validation errors name both the PST and the file formatsPUT /api/accounts/{id}/pst {pst_id} - Server-side intake (see Server-side intake):
POST /api/psts/fetchstarts a download on the server and returns the PST row (statusfetching). Body, bykind:http{url, token? | username?, password?};sftp{host, port?, path, username, password? | private_key?, passphrase?, host_key_sha256?};s3{endpoint, bucket, key, region?, access_key, secret_key}; all acceptfilename?. 422 if the destination is refused (link-local, metadata) or the body is incomplete, 507 if the disk is too full. Secrets are encrypted and never returned.POST /api/psts/{id}/cancel→{ok, state:'cancelling'|'removed'}(409 if the PST is not being fetched);POST /api/psts/{id}/retry-fetch(only fromfetch_failed) → PST.GET /api/intake/inbox→{path, enabled, settle_seconds, files:[{name,size,modified_ago}]}. PST rows gainsource:'upload'|'inbox'|'fetch',origin(label without secrets),received_bytes(progress whilefetching;sizeis 0 until the total is known) andchain(outcome of the automatic assignment, or null:{result:'assigned'|'ambiguous'|'no_match'|'account_has_pst'|'already_assigned'|'error', account_id?, job_id?, ...}). PSTstatusalso takesfetching,cancellingandfetch_failed(reason inerror). - Settings:
GET /api/intake/settings,PUT /api/intake/settings(partial update, same shape) →{notifications:{enabled,mask_addresses,group_window,disk_low_mb,events:{<event>:bool},ntfy:{enabled,url,topic,priority,has_token},webhook:{enabled,url,has_token},email:{enabled,host,port,security,username,from,to,has_password}},inbox:{enabled,settle_seconds,path},auto_chain:{enabled,start_import,default_domains:{server_id:domain}}}. Secrets (ntfy.token,webhook.token,email.password) are write-only: omit to keep,""to clear. 422 with a message on invalid input.POST /api/intake/notifications/test {channel?}→{ok, results:{<channel>:{ok,error}}}; nochannel= every enabled one. - Jobs:
POST /api/runs {account_ids:[...]}(queues),POST /api/jobs/{id}/pause|resume|stop|retry,GET /api/jobs?active=1,GET /api/jobs/{id}→{id,account_id,status:'queued'|'running'|'paused'|'stopping'|'stopped'|'done'|'done_with_errors'|'failed',phase:'precheck'|'mail'|'calendar'|'contacts'|'verify',progress:{mail:{done,total,errors},calendar:{...},contacts:{...}},current_folder,rate,eta,started_at,finished_at,error,tuning:{mode,builders,connections,fallback}|null}GET /api/jobs/{id}/log?after=(lines),GET /api/jobs/{id}/report(JSON),GET /api/jobs/{id}/errors,POST /api/jobs/{id}/retry-errors - Planning (see Plan, filter and control a migration):
POST /api/runs/plan {account_ids}→ 202{id,status:'queued'|'running'|'done'|'failed',account_ids,error,result};GET /api/plans/{id}(full result:{accounts:[{account_id,email,status:'ok'|'warning'|'error',source,targets,kinds,folders,quota,target_limit,warnings,notes}],totals,done,total}),GET /api/plans. Nothing is written to a target.POST /api/runsalso takesname(creates a batch),mode:'full'|'delta'andpriority.GET/PUT /api/servers/{id}/policyandGET/PUT /api/accounts/{id}/policy:{policy}(account: alsoeffective); body{folder_map:[{source,target}],exclude_folders:[...],date_from,date_to,max_size_mb}plus, for a server,window:{days?,start,end,tz}andrate_mb_s;nullor{}clears; 422 with the reason when invalid.GET /api/batches,GET /api/batches/{id}→{id,name,created_at,state:'queued'|'running'|'paused'|'finished',jobs,by_status,items,failed,with_errors,finished_at,job_list?};POST /api/batches/{id}/pause|resume|stop|retry-failed→ batch plus{changed,skipped}.POST /api/jobs/reorder {job_ids}→{queue:[...]};PUT /api/jobs/{id}/priority {priority};POST /api/jobs/{id}/delta(final resync of a finished job).POST /api/jobs/{id}/compare {detail?}→ 202;GET /api/jobs/{id}/compare[?run=1&detail=1]→{id,status,result:{ok,totals,folders:[{kind,target,folders,expected,on_target,missing,extra,sent,errors,pending,filtered,skipped,missing_items?}]}}or{status:'none'};GET /api/jobs/{id}/compare/csv.GET /api/jobs/{id}/filtered,GET /api/jobs/{id}/oversized. Jobs gainpriority,batch_idandpause_reason('window': waiting for the allowed hours, resumes by itself;null: paused by the administrator). The report gainsfiltered,oversized,policy,totals.<kind>.filteredand, after a comparison,compare. - Mailboxes as CSV:
POST /api/accounts/import-csvaccepts the optional columns of Import mailboxes and may answerwarnings:[{line,msg}];GET /api/accounts/export-csv[?server_id=]writes them back (never a password or a secret). - API tokens (see Command line and API tokens):
GET /api/tokens,POST /api/tokens {name}→ 201 withtoken(shown once),DELETE /api/tokens/{id}. Any route acceptsAuthorization: Bearer <token>; the token routes themselves need a session (403 with a token). - Real time:
GET /api/events(SSE) emitsjob/pst/accountwith the full object,planandcompare(plans without their folder lists), anduploadwith{id,status,received_count}. GET /api/health.