1. Overview
The mail proxy daemon implements BastionGuard’s local SMTP relay and outbound email protection service. It acts as a local mail submission endpoint, accepts SMTP clients on local listening sockets, optionally upgrades connections to TLS, applies configuration-driven routing and signature enforcement, and forwards messages to remote SMTP servers through libcurl.
The daemon is designed as a transparent or policy-enforcing outbound relay, depending on the loaded configuration. It supports JSON-driven profiles, Thunderbird profile import fallback, mandatory signature injection, local queueing on delivery failure, STARTTLS and implicit TLS listeners, and Thunderbird certificate/trust bootstrap for the local TLS endpoint.
The module integrates with:
- POSIX sockets – local SMTP listeners, accept loop, and client session I/O
- OpenSSL – server-side TLS context creation, certificate generation, STARTTLS, and implicit TLS listeners
- libcurl – remote SMTP relay delivery
- nlohmann::json – loading and validating
mail.json - gettext / libintl – translated runtime messages through
_() - Thunderbird profile inspection – SMTP account import fallback and runtime redirection of Thunderbird SMTP settings
- Filesystem persistence – local queue storage, TLS certificate/key storage, and configuration lookup
- Threading – one detached thread per accepted SMTP client plus parallel listener threads
2. Core Configuration Model
2.1 MailSignatureConfig
The daemon uses a structured signature model for plain-text and HTML signature injection. The signature object contains:
display_namejob_titlecompanyphonewebsitelogo_path
2.2 MailRelayProfile
Each outbound relay profile defines how a message is matched and forwarded. A profile includes:
idandlabelmatch_from– exact sender address matchingmatch_from_domain– sender-domain matchingsmtp_hostandsmtp_portstarttlsandimplicit_tlsusernameandpasswordauth_method– numeric authentication modeoauth2_token– token used when XOAUTH2 is selectedssl_verify– remote TLS verification flagsignature– embedded signature configuration
2.3 MailSecurityConfig
The global daemon configuration contains:
versionenabledscan_outgoinginject_signaturelocal_smtp_hostlocal_smtp_portlocal_smtp_tls_portlocal_submission_portadvertise_starttlsenable_implicit_tls_listenerdefault_profile_idprofiles
At runtime, this object controls both listener topology and outbound relay behavior.
3. Configuration Loading and Validation
3.1 mail.json Resolution
The daemon resolves the user configuration file through mail_json_path_user(). Resolution order:
- Environment override:
BASTIONGUARD_MAIL_CONFIG
- User path:
~/.config/BastionGuard/mail.json
- Fallback path:
/var/lib/bastionguard/config/mail.json
3.2 Validation Rules
Loaded configuration is validated by validate_mail_security_config(). Validation includes:
versionmust be validlocal_smtp_hostmust be present and must be a valid IPv4 addresslocal_smtp_portandlocal_smtp_tls_portmust be within1..65535- At least one SMTP profile must exist
- Each profile must have non-empty
idandlabel - When protection is enabled and a profile has a remote SMTP host,
smtp_portmust be valid - If
default_profile_idis set, it must resolve to an existing profile
3.3 Thunderbird Import Fallback
If mail.json cannot be loaded or validated, the daemon attempts a fallback import from Thunderbird profiles. A temporary MailSecurityConfig is built with sane defaults:
enabled = truescan_outgoing = trueinject_signature = truelocal_host = 127.0.0.1local_port = 2525local_tls_port = 2465local_submission_port = 2587
If Thunderbird import also fails, the daemon starts in a reduced pass-through mode with a placeholder profile and enabled = false, so it can still listen locally without policy-driven relay enforcement.
4. Thunderbird Integration
4.1 Thunderbird Profile Discovery
The daemon can inspect Thunderbird’s local configuration under:
~/.thunderbird
It parses:
profiles.iniprefs.js
SMTP server entries are collected into ThunderbirdSmtpEntry objects, while account identities are collected into ThunderbirdIdentityEntry objects.
4.2 Imported Profile Mapping
Thunderbird SMTP settings are converted into BastionGuard relay profiles. Import logic preserves:
- SMTP hostname and port
- STARTTLS / implicit TLS mode
- Authentication method
- Username
- Per-identity sender matching
- Sender-domain matching
If a Thunderbird profile already points to a loopback address, the import logic attempts to recover the real remote SMTP destination from custom backup fields such as:
mail.smtpserver.<id>.bg_orig_hostname
mail.smtpserver.<id>.bg_orig_port
4.3 Runtime SMTP Redirection
At startup, the daemon calls restore_thunderbird_smtp_from_backup() and may also redirect Thunderbird SMTP settings to the local proxy through redirect_thunderbird_smtp_to_proxy().
The redirection logic rewrites Thunderbird SMTP server entries so that Thunderbird submits to the local proxy instead of the original remote relay, while preserving the original destination in BastionGuard-specific backup preferences.
4.4 Thunderbird Certificate Installation
When the daemon generates or reuses a local TLS certificate, it attempts to install that certificate into Thunderbird profiles using:
certutil -A -n 'BastionGuard Mail Proxy' -t 'P,,' -i <cert> -d 'sql:<profile_dir>'
This enables Thunderbird to trust the local BastionGuard TLS endpoint.
5. Listener Topology and Socket Management
5.1 Local Listener Ports
The daemon can expose up to three SMTP listener classes:
- Plain SMTP listener on
local_smtp_port(default2525) - Submission listener on
local_submission_port(default2587) - Implicit TLS listener on
local_smtp_tls_port(default2465)
All listeners bind to the configured local_smtp_host. When the host is 127.0.0.1, the implementation also attempts to create parallel IPv6 loopback listeners on ::1.
5.2 Socket Creation
Server sockets are created through create_server_socket(host, port, extra_fd). The routine:
- Selects
AF_INETorAF_INET6based on the host format - Enables
SO_REUSEADDR - Uses
IPV6_V6ONLYon IPv6 listeners - Binds to the requested local address and port
- Starts listening with backlog
16
Failure conditions are logged explicitly with errno and strerror(errno).
5.3 Accept Loop and Concurrency Model
Each listener runs through accept_loop(). For every accepted client connection, the daemon spawns a detached worker thread that handles the SMTP session through handle_client(...).
Separate listener threads are launched for:
- Implicit TLS
- IPv6 implicit TLS
- Submission
- IPv6 submission
- IPv6 plain SMTP
The main thread directly handles the primary plain listener.
6. TLS Infrastructure
6.1 Local Certificate Paths
The local TLS certificate and key are stored under:
~/.local/share/BastionGuard/tls/mailproxy.crt~/.local/share/BastionGuard/tls/mailproxy.key
6.2 Self-Signed Certificate Generation
If the local certificate or key is missing, the daemon generates a self-signed RSA certificate through OpenSSL. The generated certificate uses:
- RSA 2048-bit key
- Random serial number
- Subject CN:
BastionGuard Mail Proxy
- SHA-256 signature
- Long validity period
6.3 TLS Server Context
The OpenSSL server context is built by create_tls_server_ctx(). The daemon:
- Creates a server context with
TLS_server_method() - Enforces minimum protocol version
TLS 1.2 - Loads the generated or existing certificate and private key
- Verifies that the private key matches the certificate
6.4 STARTTLS and Implicit TLS
The daemon supports both:
- STARTTLS on plain/submission listeners when
advertise_starttlsis enabled - Implicit TLS on the dedicated TLS listener when
enable_implicit_tls_listeneris enabled
TLS negotiation is implemented through upgrade_to_tls(), which upgrades an active SmtpIO session using SSL_accept().
7. SMTP Session Handling
7.1 Session State Model
Per-connection SMTP state is tracked in SmtpSessionState and includes:
helo_namemail_fromrcpt_todata
The state is reset through reset_session() after message completion or RSET.
7.2 Supported SMTP Commands
The daemon currently implements the following SMTP command handling:
EHLO/HELOSTARTTLSAUTH PLAINAUTH LOGINMAIL FROMRCPT TODATARSETNOOPQUIT
Unsupported commands return:
502 5.5.2 Command not implemented
7.3 EHLO Capabilities
Capabilities are sent through send_ehlo_capabilities(). The server advertises:
SIZE 524288008BITMIMESTARTTLSwhen available and not already activeAUTH PLAIN LOGIN
7.4 SMTP Input and DATA Collection
The helper receive_data_block() collects the message body until the SMTP terminator:
<CR><LF>.<CR><LF>
Leading-dot transparency is handled by removing the extra dot on received lines beginning with ..
8. Profile Selection and Relay Routing
8.1 Sender-Based Profile Selection
The relay profile is selected through select_profile_for_sender(cfg, from_email).
Matching priority:
- Exact match against
match_from - Domain match against
match_from_domain - Fallback to
default_profile_id - Fallback to the first available profile in some error-tolerant paths
Sender addresses are normalized to lowercase and stripped of surrounding angle brackets when necessary.
8.2 Transparent Relay vs Protected Relay
The runtime behavior depends on cfg.enabled:
- When disabled, the daemon operates in a transparent relay mode and attempts to forward mail without enforcing full protection logic
- When enabled, the daemon requires a valid selected relay profile and enforces mandatory signature injection before delivery
Even in transparent mode, the code still attempts signature injection opportunistically if configured, but delivery can continue without signature if signature injection is not applicable.
9. Message Normalization and Header Handling
9.1 Newline Normalization
All messages are normalized to SMTP-compliant CRLF line endings through normalize_newlines_to_crlf().
9.2 Header Completion
Before outbound delivery, messages are passed through ensure_basic_headers_layout(). This function:
- Ensures a valid header/body separator exists
- Adds a
Fromheader if missing - Adds a
Toheader if missing and recipients are known - Creates a basic plain-text MIME skeleton if no header block exists at all
This guarantees that relayed mail has a minimal RFC-compatible structure even if the original client payload was incomplete.
9.3 MIME Utilities
The daemon contains a large set of internal MIME helpers used for signature enforcement, including:
split_headers_body()extract_header_value()extract_content_type_lower()extract_transfer_encoding_lower()extract_boundary_param()split_lines_keep_crlf()base64_encode_wrapped()base64_decode()quoted_printable_decode()
These utilities allow recursive MIME traversal and safe rewriting of text-bearing entities.
10. Signature Injection Engine
10.1 Signature Policy
Signature enforcement is implemented through inject_signature_mandatory(). When cfg.inject_signature is enabled, the daemon attempts to inject a BastionGuard-branded signature into the outgoing message.
If no suitable text-bearing MIME part can be modified, the message is rejected in protected mode.
10.2 Plain Text Signature
Plain-text signatures are generated by build_basic_signature_text(). The output contains:
- Display name
- Job title and company
- Phone number
- Website
- A final BastionGuard branding line
10.3 HTML Signature
HTML signatures are generated by build_basic_signature_html(). The generated HTML is card-like and branded, using escaped text fields from the signature configuration and an inline logo reference:
cid:bastionguard-logo
The fixed logo path currently used is:
/usr/share/BastionGuard/data/logo.png
10.4 Multipart Rewriting
The recursive function inject_signature_into_entity() traverses MIME parts and applies signatures to supported entities:
- text/plain – plain-text signature appended directly
- text/html – HTML signature inserted before
</body>when present - multipart/* – recursive descent into child parts
For HTML parts, the function wrap_html_with_inline_logo_related() converts the entity into a multipart/related wrapper and embeds the logo as an inline MIME part.
10.5 Transfer-Encoding Handling
The signature engine supports direct rewriting of entities encoded as:
7bit8bitbinary
It also includes decode-and-rewrite logic for:
base64quoted-printable
Unsupported encodings outside these known cases cause signature enforcement failure in strict mode.
10.6 Failure Semantics
When protected mode is active and signature injection fails, the daemon rejects the SMTP transaction with:
550 5.6.0 Unable to enforce mandatory signature
This makes signature application a hard policy requirement when configured.
11. Remote SMTP Delivery
11.1 libcurl-Based Relay
Actual remote delivery is implemented by send_via_remote_smtp(), which uses libcurl in SMTP upload mode.
The target URL is built as:
smtp://host:portfor plain/STARTTLS relaysmtps://host:portfor implicit TLS relay
11.2 Authentication Modes
The relay logic supports multiple authentication schemes based on auth_method:
- Default username/password mode
AUTH=PLAINAUTH=LOGINAUTH=XOAUTH2whenauth_method == 3and an OAuth2 token is available
11.3 TLS Verification and Retry Strategy
Remote TLS verification is controlled by the profile’s ssl_verify flag. In addition, the code includes a retry path: if the first relay attempt fails بسبب certificate verification errors such as CURLE_PEER_FAILED_VERIFICATION or CURLE_SSL_CACERT, the daemon retries once with peer and host verification disabled.
This fallback is explicitly logged and should be considered a compatibility mechanism rather than an ideal security posture.
11.4 Loop Prevention
The relay logic rejects profiles whose remote SMTP host points back to loopback addresses such as:
127.0.0.1::1localhost
This prevents accidental proxy recursion and SMTP forwarding loops.
12. Local Queueing and Failure Recovery
12.1 Queue Path
When remote delivery fails, the daemon can persist the message to a local queue under:
~/.local/share/BastionGuard/mail-queue
12.2 Queue Format
Each queued message is stored as two files sharing a common timestamp/PID prefix:
.jsonmetadata file containing:mail_fromrcpt_tolast_errorqueued_at
.emlraw message payload file
12.3 SMTP Behavior on Queue Success
If remote relay fails but local queueing succeeds, the daemon still acknowledges the SMTP transaction as accepted using:
250 2.0.0 Message accepted for local queue
This makes the queue a resilience layer for temporary upstream SMTP failures.
13. Main Startup Sequence
13.1 Process Initialization
At startup, the daemon:
- Installs signal handlers for
SIGINTandSIGTERM - Initializes locale and gettext domain
- Initializes OpenSSL
- Initializes libcurl globally
- Loads configuration from
mail.jsonor Thunderbird fallback - Restores Thunderbird SMTP settings from BastionGuard backup fields
- Opens plain, submission, and optional TLS listeners
- Creates the TLS server context when TLS support is required
- Starts parallel listener threads
- Runs the main plain accept loop
13.2 Shutdown Flow
Shutdown is triggered by setting the atomic global flag:
g_running = false
Once listener loops exit, the daemon joins any joinable listener threads, closes sockets, frees the TLS context, cleans up libcurl, and logs termination.
14. Runtime and Security Considerations
- Privilege scope: the daemon is designed to run as a local user service and stores its runtime state under user-scoped paths
- Local-only binding policy: configuration validation enforces an IPv4 local host address, reducing accidental external exposure
- TLS support: both STARTTLS and implicit TLS are supported for local SMTP clients
- Loop prevention: remote relay profiles that point back to loopback addresses are rejected to prevent recursive forwarding
- Mandatory signature enforcement: when enabled, messages can be rejected if the signature engine cannot safely modify the MIME structure
- MIME-aware processing: the daemon recursively traverses multipart content and handles common transfer encodings before rewriting
- Queue resilience: failed remote deliveries can be persisted locally instead of being immediately lost
- Thunderbird integration risk surface: runtime editing of Thunderbird
prefs.jsimproves usability but must be considered a sensitive interoperability path - Remote SMTP compatibility fallback: retry without TLS verification improves interoperability with broken servers but weakens transport authenticity guarantees
- Concurrency model: each accepted client runs in its own detached thread, so robustness depends on bounded session duration and proper socket cleanup