Scripting with Lua
Lua scripts let tmail react to new mail and other events, run background jobs, bind keys and define rules. This page covers where scripts live, what they can call, and the limits of the sandbox.
A first script
Section titled “A first script”Scripts live in ~/.config/tildemail/scripts/, starting from init.lua:
events.on("new_mail", function(msg) if msg.headers["List-Unsubscribe"] then mail.move(msg, "Newsletter") else sys.notify(msg.from, msg.subject) endend)
keys.bind("N", function() sys.notify("tmail", "key N pressed") end)Run tmail watch to see it work (see tmail watch).
Where scripts run
Section titled “Where scripts run”The script engine is in the library and tested. The shipped tmail runs init.lua in three places:
tmail watchstarts the engine together with the event hub, the job scheduler and the new-mail watcher, so thenew_mailhandlers, the other events and the jobs all work there.tmail rules runstarts the engine only, so that the rules are defined. Events are not delivered and jobs do not run in that command.tmailin a terminal, the terminal interface, starts the same services astmail watch, so the handlers, the rules and the jobs run while it is open, and the key bindings and the commands you define work in it. A Lua error ininit.luadoes not stop the interface: it appears as an error message and in the log. When another tmail already runs the services, the interface starts in reading mode and runs no script.
No other command runs a script.
Modules
Section titled “Modules”Every function below exists on develop; the engine renders the same list into definitions.lua.
| Module | Functions and constants |
|---|---|
events |
events.on(name, fn): runs fn every time the event happens. Built-in events are listed below; native plugins may register additional names. An unregistered name is an error. Handlers run in subscription order. |
keys |
keys.bind(lhs, rhs): binds a key in the global context. keys.bind(context, lhs, rhs): binds a key in global, sidebar, list or reader. rhs is a function or an action name such as "message.delete". A function can take a description as the last argument, keys.bind(lhs, fn, "text"), which the which-key panel shows. keys.unbind(context, lhs): removes that binding, preset bindings included. See Binding from Lua. |
mail |
mail.move(msg, folder): moves the message to another folder of its account. mail.flag(msg, "flagged" | "seen"): sets a flag. mail.mark_read(msg): marks it read. |
gpg |
gpg.has_key(address): true when the keyring holds a usable public key for the address, false when it does not. See gpg.has_key. gpg.search_key(address): looks public keys up for an address without importing them. gpg.import_key(fingerprint [, address]): imports one public key into the keyring. See gpg.search_key and gpg.import_key. |
sys |
sys.notify(title, body): shows a desktop notification with notify-send, without waiting. &, < and > in the body are escaped, so markup in a subject is shown as text. sys.date(format): the local time as text; see sys.date. |
log |
log.debug, log.info, log.warning, log.error(message, ...): write to the tmail log. The message is a Serilog template whose properties the extra arguments fill. log.LEVEL_DEBUG, log.LEVEL_INFO, log.LEVEL_WARNING and log.LEVEL_ERROR hold the level numbers. |
jobs |
jobs.enqueue(fn), jobs.every(name, minutes, fn), jobs.remove(name), jobs.list(). See Jobs from Lua. |
rules |
rules.on(name, conditions, actions): defines a rule, or replaces the rule of that name. rules.off(name): removes it. rules.list(): the names, in order of definition. See Rules. |
bar |
bar.top(fn) and bar.bottom(fn) build the top bar and the status line from the state of the screen. bar.interval(seconds) also builds them every N seconds. bar.symbol(name) returns a symbol of the theme. bar.hints(keys, style) turns the key hints of the pane into styled groups. See bar. |
commands |
commands.add(name, fn [, description]): defines a command for the : line, or replaces the command of that name. commands.remove(name): removes it. commands.list(): the names, in order of definition. See commands. |
commands
Section titled “commands”commands.add(name, fn [, description]) defines a command that is meant to be typed after : in the terminal interface:
commands.add("archive-old", function(args) -- args is a table with the words typed after the name return "archived with " .. (args[1] or "no argument")end, "Archive the old messages")namehas 1 to 64 characters: lowercase letters, digits,_and-, starting with a letter. A name with a dot belongs to the input actions, andquit,q,help,folder,theme,aboutandjobsare built in; any of these is an error.fnreceives a fresh table of strings, one per word typed after the name, with quotes and escapes already resolved. If it returns a string, that string is the message shown to the user; any other return value is ignored. The message is cut to 300 characters and control characters are replaced.descriptionis optional, must be a string and is cut to 120 characters.- A command runs like a hook: one at a time with the other Lua work and under the same instruction budget. A command that fails or runs out of budget is reported to the
script_errorevent and shows its error to the user asNAME: <message>. - Defining a name again replaces the command where it stands, so the order of
commands.list()does not change. A reload of the scripts forgets every command.
Type a command after : in the terminal interface. A command that returns a string shows it on the last line of the screen; one that returns several lines shows them in a window.
The terminal interface has two bars, and your scripts build both of them: the top bar (a row above the panes, hidden while it has nothing to say) and the status line (the last row). tmail ships a script that draws the status line, so the interface looks right without any script of yours; you replace it by calling bar.bottom again. A bar is a function: it gets the state of the screen and returns what to draw, piece by piece.
bar.bottom(function(ctx) local separator = { bar.symbol("separator"), fg = "border" } local left = { { ctx.place, bold = true } }
-- No counts and no connection before the first account exists. if ctx.counts ~= "" then table.insert(left, separator) table.insert(left, { ctx.counts }) end
if ctx.conn.state ~= "none" then table.insert(left, separator) table.insert(left, { ctx.conn.symbol .. " " .. ctx.conn.text, fg = ctx.conn.role }) end
return { left = left, right = bar.hints(ctx.keys, { key = { bold = true }, desc = { fg = "muted" } }), }end)That is the shape of the built-in bar: the place in bold, dim separators from the theme, the counts, the connection in the color the program picks for its state, and the keys of the pane with the key in bold and its meaning in the muted color. The built-in bar also draws ctx.notes between the counts and the connection. The top bar takes the same kind of function. This one is a clock on a colored row, and it uses bar.interval because nothing on the screen changes when a second goes by:
bar.interval(1)bar.top(function(ctx) return { left = { { " " .. sys.date("HH:mm:ss"), bold = true } }, right = { ctx.place .. " " }, bg = "#7A3E9D", }end)The Lua of tmail has no os library (see the sandbox), so the clock is sys.date.
What a bar returns
Section titled “What a bar returns”A function returns a table with left and right, each a list of items, and optionally bg, the color of the whole row. Both sides are optional; return {} is an empty bar.
- An item is a bare string, a piece or a group.
- A piece is
{ text, fg = color, bg = color, bold = true }.textis a string and comes first;fg,bgandboldare optional, and a piece without them takes the colors of the row. A bare string is a piece with no style. - A group is a list of strings and pieces, such as
{ { "3 " }, { "unread", fg = "unread" } }. The program keeps a group whole: when the bar is too narrow it drops the group or keeps it, and never keeps half of one. Items that are not groups are dropped one by one. A table is a group when its first element is a table or it has more than one element; a table with one string first is a piece. A group holds strings and pieces, not other groups, and thefg,bgandboldthat you write on the group table itself are ignored: style the pieces inside it. - A color is the name of a role of the theme (
"muted","success","border", see Themes) or"#RRGGBB". A role follows the theme; a hex color is the same on every theme, so a bar that only uses roles looks right on all of them.bgon the returned table fills the whole row; without it the status line sits on thebackgroundof the theme, and the top bar ontop_bar_bgwithtop_bar_fgfor the text. - Bold is the only attribute.
- A text is cleaned for the terminal (a line break or another control character becomes a space and invisible characters are removed) and cut at 200 characters, never inside an emoji. A side holds at most 64 pieces, counting the pieces inside groups.
An unknown role, a color that is neither a role nor #RRGGBB, a text that is not a string, a bold that is not a boolean, or a side that is not a list is an error that names the item, for example left item 2: fg: unknown role 'mutd'; use a role of the theme, such as muted, or '#RRGGBB'.
The functions
Section titled “The functions”bar.top(fn)andbar.bottom(fn)set the function of a bar. Calling one again replaces the function, which is how you replace the built-in status line;nilremoves it. The top bar is hidden while both its sides are empty, and the panes use its row while it is hidden. The built-in script does not set the top bar.bar.interval(seconds)builds the bars again everyseconds(a number from 1 to 86400), on top of every change of the screen.bar.interval(nil)goes back to building them only when something changes. Use it for something that moves by itself, such as a clock. It is one interval for both bars.bar.symbol(name)returns the symbol of the theme in use for a key of its[symbols]table ("separator","online","offline","error_mark", and the others in Themes). An unknown name is an error that lists the known ones. Call it inside the function: the symbol then follows the theme that is in use when the bar is built.bar.hints(keys, style)turnsctx.keysinto a list of groups, one per hint: the key, a space and what it does, with two spaces before every group after the first.styleis optional:{ key = { fg = ..., bg = ..., bold = true }, desc = { ... } }. Use the result asright.
The context
Section titled “The context”ctx is a snapshot of the screen; changing it does nothing to the interface.
| Field | Value |
|---|---|
ctx.pane |
"mail". The chat and “needs me” screens will add "chat" and "needs_me". |
ctx.focus |
"sidebar", "list" or "reader" in the mail screens ("stream" and "input" are for the chat). |
ctx.place |
where you are, in the language of the interface: work ▸ INBOX, work for an account with no folder open, tmail before the first account exists. |
ctx.counts |
the counts as one text: 4 messages, 3 unread. Empty in the reader, during a search (the search is in ctx.notes) and before an account exists. |
ctx.conn |
the connection of the account you are on, always a table: { state, text, role, symbol }. See below. |
ctx.notes |
a list of { kind, text, role } for what else the middle of the bar tells, in this order: position (message 2 of 4, in the reader), signature (in the reader, only for a signed message; role is signature_good for a good signature, signature_bad for a bad one, signature_unknown for an unknown key or an untrusted one), search (the query and how many messages match), busy (the folder is updating) and jobs (running jobs). role is nil when the note has none. |
ctx.keys |
a list of { key, desc } for the pane that has the focus, most important first, in the language of the interface: { key = "j/k", desc = "move" }. They come from the hint texts of the language files, so they can differ from a key you rebound. |
ctx.width |
the columns of the bar. |
ctx.mail |
{ account, folder, total, unread, loaded_more } in the mail screens once a folder is open, otherwise nil. total and unread count the messages that are loaded, and loaded_more is true when more may exist than are loaded. |
ctx.chat |
{ account, name, kind, people, new, mentions } for a chat screen, otherwise nil. There is no chat screen yet, so it is always nil today. |
ctx.conn.state is "online", "offline", "error", "connecting", "disconnecting" or "none". The program fills role and symbol with the defaults below, so a bar can draw the connection in one piece; text is already in the language of the interface.
state |
role |
symbol |
|---|---|---|
online |
success |
the online symbol |
offline |
muted |
the offline symbol |
error |
error |
the error_mark symbol |
connecting, disconnecting |
warning |
the offline symbol |
none |
muted |
empty, and text is empty too |
A mail account is online, offline or error; the other two states are for the chat. none means there is no account yet.
How it updates
Section titled “How it updates”The bars are built again when the screen changes (the folder, the counts, the focus, the reader, the jobs, the state of an account) and, if you set bar.interval, on that interval. Changes that come close together are folded into one build (about 50 ms), and the function runs on the script thread, off the thread that draws: the screen draws the last result and never waits for Lua. While a build runs, changes make exactly one more follow. A function runs like a hook, one at a time with the other Lua work and under the same instruction budget.
What the program keeps for itself:
- A message (a notice, an error, a success) replaces the left side for a few seconds, in the color of its severity.
- The keys of a sequence that you are typing are drawn before the right side.
- When a bar is wider than the screen, the program drops items from the end of
rightfirst, and only whenrightis empty does it cutleftand end it with the ellipsis of the theme.
A function that fails, or returns something invalid, is reported like any script error when it starts to fail; while it keeps failing it is not reported again at every build (it goes to the log), and it is reported again if it works once and then fails. The last good result stays on the screen. When there has been no good result yet, the screen shows the plain bar described below, so a mistake in init.lua never leaves you with no status line.
In reading mode (another tmail holds the services, see Where scripts run) no script runs, and the status line is a plain bar made by the program: the place, the counts, the notes, the read-only note and the connection, then the keys, with no styles. The same plain bar, without the read-only note, is drawn until the first result of your script arrives, and it stays on the screen if your script removes the status line with bar.bottom(nil). The top bar stays hidden in that mode.
The built-in bar and tmail bar dump
Section titled “The built-in bar and tmail bar dump”The default status line is a script that ships inside the program, runs before your init.lua and is updated with the program. It uses only roles and symbols of the theme, so it follows every theme. tmail bar dump prints it (see tmail bar dump), so you can start from it:
tmail bar dump > ~/.config/tildemail/scripts/bars.luaChange the file and require("bars") it from init.lua, or paste the parts you want. Your own bar.bottom(...) replaces the built-in one. A reload of the scripts forgets every function and the interval and runs the built-in script again. The top_bar_fg and top_bar_bg roles are the default colors of the top bar.
Migrating from top_status
Section titled “Migrating from top_status”The top_status module is gone, a breaking change. Its entries were a flat list of texts with one color for the whole bar; a bar is a function now. Where you had
top_status.add(" c Compose", nil)top_status.add(function() return " " .. sys.date("HH:mm:ss") end, 1)top_status.set_colors("#FFFFFF", "#7A3E9D")write one function, give it the interval of the clock, and put the colors on the piece and on the row:
bar.interval(1)bar.top(function(ctx) return { left = { " c Compose", { " " .. sys.date("HH:mm:ss"), fg = "#FFFFFF" } }, bg = "#7A3E9D", }end)top_status.add had an interval for each entry; bar.interval is one interval for the bars. The entries no longer show in :jobs and in jobs.list() as top_status #<id>, because a bar is not a job. An entry that failed showed (error); a bar that fails keeps its last good result.
sys.date
Section titled “sys.date”sys.date(format) returns the local time as text. format is a .NET custom date and time format of 1 to 64 characters: "HH:mm:ss" gives 15:05:07, "ddd dd MMM yyyy" gives Thu 29 Oct 2026, "'at' HH:mm" copies the text in quotes (there is no week number). The names of days and months are always in English, whatever the language of the machine. A format that is not a string, is empty or longer than 64 characters, or is not valid is a Lua error. A format of a single letter is a standard format ("d" is the short date); use two characters or a % in front ("%d" is the day of the month) to mean a custom one. This is not the format of os.date: "%H:%M" would give the month where it means the minutes, so a % outside quotes, except in the two-character form "%d", is an error; write "HH:mm".
The message table
Section titled “The message table”A new_mail handler receives a msg table with account, folder, uid, from, subject, date (ISO 8601) and headers.
headers keeps the original header names as keys, and a lookup with any other casing (msg.headers["list-unsubscribe"]) finds the same header. The mail functions read account, folder and uid from the table, so they act on the message the handler was called for.
gpg.has_key
Section titled “gpg.has_key”gpg.has_key only reads the keyring. A script can also look keys up and import them (see below), but it cannot sign, encrypt or decrypt.
- Argument. An address, a key id or a fingerprint (hex of 8, 16, 40 or 64 digits, optionally with
0x). A bare address matches exactly (<address>, never a substring). Any other text is refused. - Meaning. A key that encryption cannot use counts as no key, because encrypting to it fails. An expired or revoked key, a sign-only key, a key whose encryption subkeys are all expired or revoked, a key whose user ids for that address are all revoked, and, when the key is looked up by address, a disabled key all make it
false. A disabled key named by key id or fingerprint is still found, and gpg encrypts to it. It does not say that the key is trusted: an untrusted key still counts as present, and gpg’s trust model decides whether encrypting to it works. - Side effects. It never asks for a passphrase or a pinentry and never adds, removes or changes keys. In an empty home, gpg only creates its
pubring.kbxandtrustdb.gpg. - Errors. It raises a Lua error, never
false, when:- gpg lists no key and reports an error of its own while looking (a broken keyring, or a stale
keyringline ingpg.conf, for example); gpgis missing, fails, or does not answer after about 10 seconds;- the argument is empty, starts with
-, holds a control character or is longer than 320 characters.
- gpg lists no key and reports an error of its own while looking (a broken keyring, or a stale
gpg.search_key and gpg.import_key
Section titled “gpg.search_key and gpg.import_key”gpg.search_key(address) looks for public keys of an e-mail address and returns them. gpg.import_key(fingerprint [, address]) adds one public key to your keyring. Neither can sign, encrypt or decrypt, and neither runs on its own: nothing calls them unless a script does.
-
A script can import keys on its own.
gpg.import_keyneeds no confirmation, so a script that calls it, for example from anew_mailhandler, adds public keys to your keyring without asking you. Write such a script only if that is what you want. -
An imported key is not trusted. The import never changes ownertrust, never certifies the key and never trusts it. Check the fingerprint with its owner and certify the key with gpg yourself before you rely on it. Until then, gpg may refuse to encrypt to it, as for any untrusted key. If your
gpg.confsetstrust-model always, gpg encrypts to any key in the keyring, so an imported key can be used for encryption at once: the import keeps your gpg options. -
What a lookup tells the outside. The address is sent to the web key directory host of its domain and to the key server, which learn that you asked about it. The key server is the one in
dirmngr.conf, or the default of your dirmngr (hkps://keys.openpgp.org); akeyserverline ingpg.confdoes not apply. The settings ofdirmngr.conf(a Tor proxy, for example) apply. A lookup of a sender’s address, for example from anew_mailhandler, works as a read receipt: the web key directory host of the sender’s domain, which the sender controls, learns that the mail was processed and when, and your IP address unless dirmngr goes through Tor. There is no rate limit; each call blocks the single script thread for up to about 30 seconds (60 for an import with an address). A key server decides the order of its index and only the first 10 entries are looked at, so it can push the real key out of the results; keys.openpgp.org only returns user ids whose e-mail address was verified. -
gpg.search_key(address)asks the web key directory first and then the key server. It downloads into a temporary keyring that is deleted afterwards and never changes yours. A key is returned only if one of its user ids has exactly that address (any case). No key gives an empty array. Each element is a table:Field Value fingerprintThe primary key fingerprint, upper-case hex without spaces. user_idsAn array of strings, as gpg prints them. They are claims of the key holder, not verified identities, and may hold control characters. createdISO 8601 string. expiresISO 8601 string, or nilwhen the key does not expire.expired,revokedBooleans. Expired and revoked keys are returned, flagged, not hidden. source"wkd"or"keyserver". -
gpg.import_key(fingerprint [, address])takes a full fingerprint: exactly 40 or 64 hex digits, in any case, with no spaces, no0x, no short or long key id and no e-mail address. Withoutaddressit asks the key server for that fingerprint (a key that only a web key directory has needs the address). Withaddressit first runs the same lookup for that address and requires a key with that fingerprint among the results. The key is downloaded into a temporary keyring first; its fingerprint must be exactly the requested one, the data to import must hold that one key and no other (another key can carry the same fingerprint as a subkey; such data is refused), and the key must not exceed 1 MiB. Only then is it imported, without the signatures that other people made on it. It returns the table of the imported key, the same as one element ofgpg.search_key. The import is logged with the fingerprint. -
Address argument. Not blank, no leading
-, no control character, at most 320 characters, and it must contain@: a key id is not accepted here. It must be a plain address (bob@example.org), not a name with angle brackets. -
Errors. Both raise a Lua error, never an empty result or
false, when:- an argument breaks the rules above (gpg is not run);
gpgordirmngris missing or fails, the network or the key server fails, or the answer is not a key;- the key server returns a key with another fingerprint than the requested one, or data that holds another key besides it, or a key that is too large, or the address lookup does not return the fingerprint (the keyring is left as it was);
- the call does not finish in about 30 seconds (
gpg.search_key, andgpg.import_keywithout an address; with an address, about 60).
Events
Section titled “Events”events.on(name, fn) calls fn with one table each time the event happens:
events.on("message_sent", function(e) sys.notify("Sent", e.subject)end)
events.on("offline", function(e) sys.notify(e.account, "working from the cache")end)
events.on("job_failed", function(e) log.warning("job {A} failed: {B}", e.name, e.error)end)A handler is fire-and-forget:
- It runs when its turn comes, one at a time with every other Lua unit.
- It cannot change or stop what happened; there is no
before_send. - A handler that fails is reported as
script_error, and the next handler still runs.
Events are about what tmail does itself: a flag changed or a message moved by another program is not reported, and neither is a tag (keyword) that a rule puts on a message. No payload carries a message body.
| Event | Table fields | When |
|---|---|---|
new_mail |
the message table | a new message arrived in a watched folder |
message_sent |
account, from, to (list), cc (list), subject, saved |
SMTP accepted a message; saved is false when the copy in the sent folder could not be stored |
send_failed |
account, from, subject, reason |
SMTP failed; not for a cancelled message, a refused confirmation or an editor error |
flags_changed |
account, folder, uid, added (list), removed (list) |
tmail asked for a flag change (seen, flagged, answered, deleted), also through mail.flag and mail.mark_read; it does not compare with the old flags, so setting a flag that was already set still raises it |
message_moved |
account, folder, uid, target |
tmail moved a message; folder and uid are where it was, so the table no longer points at the message (the mail functions would act on a UID that is gone) |
offline |
account |
the server became unreachable and tmail serves its cache |
online |
account |
the server answered again after offline |
account_error |
account, folder, error, failures |
watching a folder for new mail began to fail (once per run of failures; a run ends when the watch delivers a message or has run 30 seconds without failing, and the next failure starts a new one) |
startup |
none | init.lua ran at the first start |
config_reloaded |
none | the scripts were reloaded |
shutdown |
none | the host asked the scripts to wind down (IScriptEngine.ShutdownAsync, called before the engine is disposed) |
script_error |
source, message |
a script failed (source is init.lua, an event name, a key, a command or a job); a failure inside a script_error handler does not raise it again |
job_failed |
name, kind (queued or recurring), error |
a background job failed; for a Lua job the error is generic and script_error carries the real message |
signature_checked |
status (good, bad, unknown_key, untrusted, none), key, signer |
a signature was verified, alone or inside a decrypted message |
key_imported |
fingerprint, source (wkd or keyserver), user_ids (list, at most 10; the claims of the key holder, not verified), address (the address the key was looked up for, or nil) |
a public key was imported into the keyring, from the interface, a send or gpg.import_key; it does not mean the key is trusted |
decrypt_failed |
reason |
gpg could not decrypt a message (the first try of the interface, which runs gpg so that it cannot ask for a passphrase, does not raise it) |
Take care with a handler that does what it listens for. A flags_changed handler that calls mail.flag (even mail.mark_read on a message that is already read) raises flags_changed again, and so on without end, one server round trip each time. The same goes for a script_error handler that starts a job which fails, and for a key_imported handler that calls gpg.import_key (importing a key that is already in the keyring raises the event again). tmail does not break such loops.
Native plugins may expose custom events through AddScriptEvent. Subscribe with the same events.on(name, fn) API; the plugin documents its payload fields. Names are registered before the engine first starts and appear in definitions.lua. Reload clears handlers while preserving available event names.
The events travel through an in-process queue (IEventHub) that keeps at most 10,000 waiting events and drops the oldest. The service lifecycle starts the queue before the script engine; events published before the queue starts wait for it.
Jobs from Lua
Section titled “Jobs from Lua”jobs.enqueue(function() sys.notify("done", "ran once, in the background") end)jobs.every("check", 5, function() log.info("every five minutes") end) -- name, minutes, functionjobs.remove("check")for _, job in ipairs(jobs.list()) do log.info("{Name} is {State}", job.name, job.state) endjobs.everyneeds a positive number of minutes (up to a little under 50 days) and a name no other recurring job uses. The first run is one interval from now.jobs.removestops a recurring job a script created and ignores an unknown name. Called from inside the job’s own body, it stops that run at once.jobs.listreturns tables withname,kind(queued,recurringortracked),state(pending,runningoridle),interval_minutes(recurring jobs),last_error(when the last run failed) andprogress(when the job reported how far it is). It lists every job of the scheduler, also the ones tmail runs for itself, such as a search or the cache download of the terminal interface (kindstrackedandqueued).
Nothing runs until the scheduler starts: tmail watch and the terminal interface start it, and no other command does. Jobs written in Lua run one at a time, because scripts share one Lua state, and a Lua job that waits for its turn still occupies one of the workers. A job that fails is reported like any script error, and a recurring one runs again at its next tick. A reload of the scripts removes the recurring jobs they created and cancels the queued ones that have not started. The scheduler itself is described in Background jobs.
The sandbox
Section titled “The sandbox”Lua runs on LuaCSharp through Moongate’s module binder.
- Libraries. The state opens the base, string, table, math, coroutine and module libraries only.
dofile,loadfileandrawsetare removed, as arecoroutine.create,coroutine.wrapandcoroutine.resume(a coroutine would escape the instruction budget) and thepackagemembers that reach the filesystem (path,cpath,searchpath,loadlib). - Modules.
require("lib.util")loadsscripts/lib/util.luaand nothing outside the scripts directory, also through symbolic links. - No outside access. There is no
wait, no sleep and no access to the network or the filesystem. - Limits.
string.reprefuses a result over 16 MiB. - Output.
printwrites to the tmail log and, like Lua’s own, honours__tostring.
Every unit of Lua work (init.lua, one handler call, one key press, one job body) runs on a thread-pool thread, one unit at a time, under a budget of 10,000,000 VM instructions. A unit that exceeds it is stopped and reported.
A mail.* call blocks the script thread while the backend works, for at most 60 seconds. A gpg.* call does the same and gives up after about 10 seconds (gpg.has_key) or about 30 seconds (gpg.search_key, and gpg.import_key without an address; with an address, about 60). A call also stops when the unit of Lua work that made it is cancelled.
Script errors never escape. They are logged and raised through ScriptError with their source (init.lua, the event name, key X, or the job), and the other handlers still run.
init.lua may be a symbolic link (as dotfile managers create) to a regular file owned by you and not writable by group or others. Any other link is not run, and the reason is logged and reported. A missing init.lua is not an error.
Editor support and reload
Section titled “Editor support and reload”At every start the engine writes definitions.lua into the scripts directory, with an annotation for every module, function, constant and event name. It also creates .luarc.json next to it when that file does not exist. With the Lua language server, an editor opened on the scripts directory then completes mail., log. and events.on(".
definitions.luais rewritten each time, so do not edit it..luarc.jsonis yours and is never overwritten.- A scripts directory that cannot be written is logged, and the scripts still run.
The engine can also reload everything (ReloadAsync): it drops the Lua state, the event handlers, the key bindings, the commands and the jobs the scripts created, and runs init.lua again. A reload also forgets every rule (see Rules).