Gleam FTP (gftp) is a Gleam client library for FTP (File Transfer Protocol) and FTPS (FTP over SSL/TLS) with full RFC compliance. It runs on the Erlang VM and provides a simple, type-safe API for all common FTP operations.
Based on the Rust FTP library suppaftp.
- FTP and FTPS (explicit and implicit) support
- Passive, Extended Passive (EPSV), and Active data transfer modes
- NAT workaround for passive mode behind firewalls
- Directory listing parsing (POSIX, DOS, MLSD/MLST formats)
- File upload, download, append, rename, and delete
- Directory creation, removal, and navigation
- File size and modification time queries
- Server feature discovery (FEAT/OPTS, RFC 2389)
- Custom command support for server-specific extensions
- OTP actor wrapper for safe concurrent use and chunk protection during data transfers
- Full RFC compliance: 959, 2228, 4217, 2428, 2389
- Erlang/OTP (target = erlang)
- Gleam >= 1.14.0
gleam add gftp@2You can both use FtpClient directly for simple command/response operations and callback-based data transfers, or the gftp/actor wrapper for safe concurrent use in OTP actors with message-based streaming. Here's a quick example using FtpClient directly:
import gftp
import gftp/file_type
import gftp/stream
import gftp/result as ftp_result
import gleam/bit_array
import gleam/option.{None}
import gleam/result
pub fn main() {
// Connect and login
let assert Ok(client) = gftp.connect("ftp.example.com", 21)
let assert Ok(_) = gftp.login(client, "user", "password")
// Set binary transfer type
let assert Ok(_) = gftp.transfer_type(client, file_type.Binary)
// Upload a file
let assert Ok(_) = gftp.stor(client, "hello.txt", fn(data_stream) {
stream.send(data_stream, bit_array.from_string("Hello, world!"))
|> result.map_error(ftp_result.Socket)
})
// List current directory
let assert Ok(_entries) = gftp.list(client, None)
// Download a file
let assert Ok(_) = gftp.retr(client, "hello.txt", fn(data_stream) {
let assert Ok(_data) = stream.receive(data_stream, 5000)
Ok(Nil)
})
// Quit and shutdown
let assert Ok(_) = gftp.quit(client)
let assert Ok(_) = gftp.shutdown(client)
}The actor wrapper (gftp/actor) provides two benefits over using FtpClient directly:
- Chunk protection — control commands are automatically rejected with
DataTransferInProgresswhile a data channel is open, preventing FTP protocol state corruption. - Message-based streaming — open a data channel with
open_retr,open_stor, etc. and receive data as messages in your OTP actor's inbox viastream.select_stream_messages. This lets you interleave FTP data packets with other message types (timers, other sockets, application events) using aprocess.Selector.
If you only need simple callback-based transfers (like gftp.retr, gftp.stor), using FtpClient directly is perfectly fine.
import gftp
import gftp/actor as ftp_actor
import gftp/stream
import gftp/result as ftp_result
import gleam/bit_array
import gleam/result
// Connect and wrap in an actor
let assert Ok(client) = gftp.connect("ftp.example.com", 21)
let assert Ok(started) = ftp_actor.start(client)
let handle = started.data
// All operations go through the actor handle
let assert Ok(_) = ftp_actor.login(handle, "user", "password")
let assert Ok(cwd) = ftp_actor.pwd(handle)
// Callback-based transfers work the same as with FtpClient
let assert Ok(_) = ftp_actor.stor(handle, "hello.txt", fn(data_stream) {
stream.send(data_stream, bit_array.from_string("Hello, world!"))
|> result.map_error(ftp_result.Socket)
})
let assert Ok(_) = ftp_actor.quit(handle)Use open_* to get a DataStream, then receive packets as messages in your own actor:
import gftp/actor as ftp_actor
import gftp/stream.{type StreamMessage, Packet, StreamClosed, StreamError}
import gleam/erlang/process
// Open a data channel — control commands are blocked until close
let assert Ok(data_stream) = ftp_actor.open_retr(handle, "large_file.bin")
// Request the first packet to be delivered as a message
stream.receive_next_packet_as_message(data_stream)
// Build a selector that handles FTP stream messages alongside your own messages
let selector =
process.new_selector()
|> stream.select_stream_messages(fn(msg) { msg })
// Receive packets in a loop
case process.select(selector, 30_000) {
Ok(Packet(data)) -> {
// Process chunk, then request the next one
stream.receive_next_packet_as_message(data_stream)
// ... continue selecting ...
}
Ok(StreamClosed) -> {
// Transfer complete — close the data channel to unblock control commands
let assert Ok(_) = ftp_actor.close_data_channel(handle, data_stream)
}
Ok(StreamError(err)) -> // handle error
Error(_) -> // timeout
}import gftp
// Connect with default 30s timeout
let assert Ok(client) = gftp.connect("ftp.example.com", 21)
// Connect with custom timeout (in milliseconds)
let assert Ok(client) = gftp.connect_timeout("ftp.example.com", 21, timeout: 10_000)Connect over plain FTP, then upgrade the connection to TLS:
import gftp
import kafein
let assert Ok(client) = gftp.connect("ftp.example.com", 21)
let ssl_options = kafein.WrapOptions(
server_name_indication: kafein.SniEnabled("ftp.example.com"),
// ... other TLS options
)
let assert Ok(client) = gftp.into_secure(client, ssl_options)
let assert Ok(_) = gftp.login(client, "user", "password")Connect directly over TLS (typically on port 990):
import gftp
import kafein
let ssl_options = kafein.WrapOptions(
server_name_indication: kafein.SniEnabled("ftp.example.com"),
// ... other TLS options
)
let assert Ok(client) = gftp.connect_secure_implicit("ftp.example.com", 990, ssl_options, 30_000)gftp defaults to passive mode, which works in most environments. You can switch modes as needed:
import gftp
import gftp/mode
// Passive mode (default) - client connects to server for data transfer
let client = gftp.with_mode(client, mode.Passive)
// Extended passive mode (RFC 2428) - required by some servers, supports IPv6
let client = gftp.with_mode(client, mode.ExtendedPassive)
// Active mode - server connects back to client (30s timeout for the connection)
let client = gftp.with_active_mode(client, 30_000)
// Enable NAT workaround for passive mode behind firewalls
let client = gftp.with_nat_workaround(client, True)import gftp
import gleam/option.{None, Some}
// Print working directory
let assert Ok(cwd) = gftp.pwd(client)
// Change directory
let assert Ok(_) = gftp.cwd(client, "/pub/data")
// Go to parent directory
let assert Ok(_) = gftp.cdup(client)
// Create and remove directories
let assert Ok(_) = gftp.mkd(client, "new_folder")
let assert Ok(_) = gftp.rmd(client, "old_folder")import gftp
import gftp/stream
import gftp/result as ftp_result
import gleam/bit_array
import gleam/result
// Upload a file
let assert Ok(_) = gftp.stor(client, "upload.txt", fn(data_stream) {
stream.send(data_stream, bit_array.from_string("file contents"))
|> result.map_error(ftp_result.Socket)
})
// Download a file
let assert Ok(_) = gftp.retr(client, "download.txt", fn(data_stream) {
let assert Ok(data) = stream.receive(data_stream, 5000)
// process data...
Ok(Nil)
})
// Append to a file
let assert Ok(_) = gftp.appe(client, "log.txt", fn(data_stream) {
stream.send(data_stream, bit_array.from_string("new log entry\n"))
|> result.map_error(ftp_result.Socket)
})
// Delete a file
let assert Ok(_) = gftp.dele(client, "old_file.txt")
// Rename a file
let assert Ok(_) = gftp.rename(client, "old_name.txt", "new_name.txt")
// Get file size and modification time
let assert Ok(size) = gftp.size(client, "file.txt")
let assert Ok(mtime) = gftp.mdtm(client, "file.txt")gftp provides multiple listing commands and parsers for structured output:
import gftp
import gftp/list as gftp_list
import gftp/list/file
import gftp/list/file_type
import gleam/list
import gleam/option.{None, Some}
// LIST command (human-readable format)
let assert Ok(lines) = gftp.list(client, None)
let assert Ok(files) = list.try_map(lines, gftp_list.parse_list)
// MLSD command (machine-readable, RFC 3659)
let assert Ok(lines) = gftp.mlsd(client, None)
let assert Ok(files) = list.try_map(lines, gftp_list.parse_mlsd)
// MLST command (single file info, RFC 3659)
let assert Ok(line) = gftp.mlst(client, Some("file.txt"))
let assert Ok(f) = gftp_list.parse_mlst(line)
// NLST command (file names only)
let assert Ok(names) = gftp.nlst(client, None)
// Access file metadata
let name = file.name(f)
let size = file.size(f)
let modified = file.modified(f)
let is_dir = file_type.is_directory(file.file_type(f))import gftp
import gleam/dict
import gleam/option.{None, Some}
// Discover server capabilities (RFC 2389)
let assert Ok(features) = gftp.feat(client)
// Check if a specific feature is supported
case dict.get(features, "MLST") {
Ok(Some(params)) -> // MLST supported with params
Ok(None) -> // MLST supported without params
Error(_) -> // MLST not supported
}
// Set server options
let assert Ok(_) = gftp.opts(client, "UTF8", Some("ON"))All operations return FtpResult(a), which is Result(a, FtpError):
import gftp
import gftp/result
case gftp.cwd(client, "/nonexistent") {
Ok(_) -> // success
Error(err) -> {
// Get a human-readable error description
let description = result.describe_error(err)
// Match on specific error types
case err {
result.UnexpectedResponse(response) -> // server rejected the command
result.ConnectionError(_) -> // connection failed
result.Tls(_) -> // TLS error
result.Socket(_) -> // socket error
result.BadResponse -> // malformed server response
_ -> // other errors
}
}
}For server-specific commands not covered by the API:
import gftp
import gftp/status
// Send a custom command
let assert Ok(response) = gftp.custom_command(client, "SITE CHMOD 755 file.txt", [status.CommandOk])
// Send a custom command that uses a data connection
let assert Ok(_) = gftp.custom_data_command(
client,
"LIST -la",
[status.AboutToSend, status.AlreadyOpen],
fn(data_stream, _response) {
let assert Ok(lines) = gftp.read_lines_from_stream(data_stream, 5000)
// process lines...
Ok(Nil)
},
)Function names follow FTP command names for familiarity with the protocol:
cwd (Change Working Directory), pwd (Print Working Directory), mkd (Make Directory),
rmd (Remove Directory), dele (Delete), retr (Retrieve), stor (Store),
appe (Append), nlst (Name List), mdtm (Modification Time), etc.
Full API documentation is available at https://hexdocs.pm/gftp.
The examples/ directory contains standalone Gleam projects demonstrating common gftp usage patterns. Each example can be run with gleam run after setting the FTP connection environment variables.
| Example | Description |
|---|---|
simple_client |
Direct FtpClient API: connect, login, list, upload, download, delete |
actor_client |
OTP actor wrapper with message-based streaming and chunk protection |
directory_listing |
Parse LIST/MLSD output into structured File records with metadata |
To quickly spin up a local FTP server using Docker:
docker run -d --name gftp-test-ftp \
-e "USERS=test|test|/home/test" \
-e ADDRESS=127.0.0.1 \
-e MIN_PORT=21100 \
-e MAX_PORT=21110 \
-p 2121:21 \
-p 21100-21110:21100-21110 \
delfer/alpine-ftp-server:latestThen run any example:
cd examples/simple_client
FTP_HOST=127.0.0.1 FTP_PORT=2121 FTP_USER=test FTP_PASSWORD=test gleam rungleam build # Build the project
gleam test # Run unit tests
gleam format # Format codeIntegration tests run against a real FTP server inside a Docker container. You need Docker installed and running on your machine to execute them.
GFTP_INTEGRATION_TESTS=1 gleam testTo also run active mode tests:
GFTP_INTEGRATION_TESTS=1 GFTP_ACTIVE_MODE_TESTS=1 gleam testYou can generate a changelog entry before a new release by running:
git cliff -u --tag vX.Y.ZThis project is licensed under the MIT License. See the LICENSE file for details.