<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FoxBox Integration Notes]]></title><description><![CDATA[FoxBox Integration Notes]]></description><link>https://foxbox-integrations.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 12:47:51 GMT</lastBuildDate><atom:link href="https://foxbox-integrations.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Postfix to SMS with FoxBox: recipient controls and uncertain retries]]></title><description><![CDATA[A FoxBox mail-to-SMS configuration lab: restrict relay clients and recipients, validate Postfix maps, and handle uncertain submissions.]]></description><link>https://foxbox-integrations.hashnode.dev/postfix-to-sms-with-foxbox-recipient-controls-and-uncertain-retries</link><guid isPermaLink="true">https://foxbox-integrations.hashnode.dev/postfix-to-sms-with-foxbox-recipient-controls-and-uncertain-retries</guid><category><![CDATA[postfix]]></category><category><![CDATA[Linux]]></category><dc:creator><![CDATA[Davide Cantaluppi]]></dc:creator><pubDate>Wed, 21 Oct 2026 21:10:02 GMT</pubDate><content:encoded><![CDATA[<p>A mail-to-SMS route has two separate jobs: decide which email may enter, then handle a message whose submission result may be uncertain. A catch-all address and a retry loop solve neither problem on their own.</p>
<p>This note uses a dedicated Postfix receiving instance and FoxBox's documented base <code>scripts_mail</code> path. It is a configuration review and offline lab, not a report of a live Linux appliance deployment. The completed checks used native Postfix 3.2.2 utilities on macOS; no SMTP server, email, API call or SMS was sent.</p>
<p>Disclosure: this guide was prepared with AI assistance for FoxBox Integration Notes. Davide Cantaluppi is associated with Mindly / FoxBox. The examples are original illustrative configuration; they do not imply certification or a delivery guarantee.</p>
<h2>Choose the receiving mode first</h2>
<p>The <a href="https://www.smsfoxbox.it/manual/FoxBox_Complete_Manual_EN.html">FoxBox manual, chapters 21–22</a> describes a base path of Postfix → local <code>nas</code> account → Procmail → <code>email-to-foxbox.sh</code> → API queue submission. SMS numbers come from <code>/etc/foxbox-numbers.txt</code>; they are not extracted from the email recipient. The parser uses the first five body lines. Its format differs from the username/PIN mailbox-polling workflow. Use one consumer per message stream.</p>
<p>For this example, <code>ops@...</code> and <code>power@...</code> are two permitted email entry points for the SAME fixed SMS recipient list. They are not two different phonebook groups. Group routing requires its separately reviewed implementation.</p>
<p>The examples use the reserved <code>.invalid</code> suffix and documentation network <code>192.0.2.0/24</code>. Replace them only in an isolated, reviewed installation. Do not paste this fragment over an existing corporate mail server configuration.</p>
<h2>Route one domain from the upstream relay</h2>
<p>On the existing authorized upstream Postfix relay, the conceptual transport-table entry is:</p>
<pre><code class="language-text"># Add to the appropriate existing transport table after review.
sms.example.invalid smtp:[192.0.2.20]:25
</code></pre>
<p>The brackets select this next hop without an MX lookup. A transport entry chooses a destination; it does not authorize a sender or recipient. Preserve the relay's other routes. Check its <code>parent_domain_matches_subdomains</code> value: <code>transport_maps</code> must not be included if this domain entry is intended to match only that domain. Do not add <code>*</code> or <code>.sms.example.invalid</code> as convenience fallbacks. See the <a href="https://www.postfix.org/transport.5.html">Postfix transport table documentation</a>.</p>
<h2>Put independent restrictions on the receiving instance</h2>
<p>The receiving host in this example has a reviewed local <code>nas</code> account and the appropriate Procmail rule. Its SMTP listener is reachable only from the approved upstream relay. Retain network filtering and use an authenticated, protected transport if this connection crosses an untrusted network. The fragments below are not a TLS deployment recipe.</p>
<pre><code class="language-text"># main.cf fragment — dedicated, receiving-only instance
myhostname = sms-ingress.example.invalid
mydomain = example.invalid
mydestination = $myhostname, localhost.$mydomain, localhost
inet_interfaces = 192.0.2.20
inet_protocols = ipv4
mynetworks = 127.0.0.0/8
recipient_delimiter =

virtual_alias_domains = sms.example.invalid
virtual_alias_maps = hash:/etc/postfix/sms_virtual
smtpd_relay_restrictions = reject_unauth_destination
smtpd_client_restrictions = check_client_access cidr:/etc/postfix/sms_clients, reject
smtpd_recipient_restrictions = check_recipient_access hash:/etc/postfix/sms_recipients, reject
smtpd_reject_unlisted_recipient = yes

mailbox_command = /usr/bin/procmail -a "$EXTENSION"
default_transport = error:5.1.1 Remote delivery disabled on SMS ingress
relay_transport = error:5.1.1 Remote relay disabled on SMS ingress
</code></pre>
<pre><code class="language-text"># /etc/postfix/sms_clients — CIDR table; do not run postmap to compile it
192.0.2.10/32 OK

# /etc/postfix/sms_recipients — indexed table
ops@sms.example.invalid OK
power@sms.example.invalid OK

# /etc/postfix/sms_virtual — indexed table
ops@sms.example.invalid nas@localhost
power@sms.example.invalid nas@localhost
</code></pre>
<p>Three decisions remain separate: the client address must be approved; the recipient domain must be a legitimate destination for this instance; and the full recipient must be in the explicit table. There is no broad <code>permit_mynetworks</code> or SASL permit ahead of these checks. The network boundary still matters: a permitted relay must enforce which of its own applications can use this route. A forged From header proves nothing.</p>
<p>Leave <code>sms.example.invalid</code> out of <code>mydestination</code>, because it is a virtual alias domain. Do not add <code>@sms.example.invalid</code>, a domain-wide <code>OK</code>, or a bare local-part alias. Empty <code>recipient_delimiter</code> prevents an address such as <code>ops+anything@...</code> from falling back to <code>ops@...</code>. Indexed Postfix maps are case-insensitive, so “exact” here means the complete normalized address, not case-sensitive spelling. Review existing canonical, BCC, alias and content-filter rules too: this fragment cannot neutralize other configuration silently.</p>
<p>The receiving-only error transports deliberately prevent ordinary remote outbound mail. Plan monitoring and delivery-status notifications through a separate reviewed path. This instance is not automatically an SMS-to-email outbound relay. <a href="https://www.postfix.org/SMTPD_ACCESS_README.html">Postfix SMTP access controls</a> explain why relay permission and access restrictions are different checks.</p>
<h2>Validate maps before enabling the route</h2>
<p>Build the two indexed maps in the isolated target configuration after inspecting the text files. Use the map type supported by that installation; this example uses <code>hash</code>:</p>
<pre><code class="language-sh">postmap hash:/etc/postfix/sms_recipients
postmap hash:/etc/postfix/sms_virtual
postmap -q ops@sms.example.invalid hash:/etc/postfix/sms_recipients
postmap -q ops@sms.example.invalid hash:/etc/postfix/sms_virtual
postmap -q 192.0.2.10 cidr:/etc/postfix/sms_clients
postmap -q ops+extra@sms.example.invalid hash:/etc/postfix/sms_recipients
postmap -q unknown@sms.example.invalid hash:/etc/postfix/sms_virtual
</code></pre>
<p>Expected results are <code>OK</code>, <code>nas@localhost</code>, <code>OK</code>, then no match for the last two queries. No match is meaningful because the restriction list subsequently ends in <code>reject</code>. A map query alone does not run the SMTP restriction engine.</p>
<p>Our offline fixture checked allowed and unknown recipients, plus addressing, subdomains, lookalike suffixes, foreign domains, case folding, source addresses, the transport entry and parsed configuration settings. A separate response-loss model demonstrated the retry problem below. All 34 checks passed. The fixture substitutes loopback for the documentation listener address and uses Apple's service-account names only in its temporary macOS configuration. It does not start Postfix or modify system files.</p>
<p>The remaining commissioning work is explicit: on the target Linux version, run configuration validation, then test allowed and rejected SMTP transactions into a sink. Check both approved and unapproved source networks. Verify the exact Procmail account and parsing behaviour while the API transport is disabled. No map-only result proves that the deployed listener is not an open relay.</p>
<h2>Treat an uncertain submission as its own state</h2>
<p>The base parser checks HTTP and application status; HTTP 200 alone is insufficient. Its failure path can cause Postfix redelivery, and an uncertain API response can create duplicate submissions. API acceptance is not handset delivery. The supplied parser is for simple text/plain mail rather than complete MIME decoding, so reject unsupported formats upstream or use a separately reviewed normalizer.</p>
<p>There is also an earlier ambiguity: the upstream MTA can lose the final SMTP response after the receiving MTA accepted the message. Postfix explicitly documents possible duplicate delivery after <code>smtp_data_done_timeout</code>. Shortening timeouts does not provide idempotency. See the <a href="https://www.postfix.org/postconf.5.html#smtp_data_done_timeout">Postfix parameter reference</a>.</p>
<p>Use an incident identifier stable across retries, with a distinct notification revision when content or state changes. A Message-ID is useful correlation data, but does not by itself guarantee uniqueness or authorization. Record the upstream queue ID, local queue ID, application attempt and returned gateway identifiers separately.</p>
<p>Before enabling unattended SMS, review every retry layer: the source application, SMTP relay, local delivery, parser HTTP client and any operator replay. Define these outcomes:</p>
<table>
<thead>
<tr>
<th>Observed outcome</th>
<th>Operational action</th>
</tr>
</thead>
<tbody><tr>
<td>Definite rejection before acceptance</td>
<td>Correct the cause; retry only under the documented policy.</td>
</tr>
<tr>
<td>Documented API acceptance</td>
<td>Track the returned identifiers; do not resubmit solely because a handset has not yet received it.</td>
</tr>
<tr>
<td>Timeout, reset or unreadable reply after an attempt may have reached the gateway</td>
<td>Mark unknown, stop automatic replay and reconcile evidence.</td>
</tr>
<tr>
<td>Final delivery status</td>
<td>Record only the status actually provided; absence remains unknown.</td>
</tr>
</tbody></table>
<p>A durable outbox can record intent before a submission and preserve accepted/unknown outcomes across process restarts. It also needs atomic ownership, retention, operator decisions and a policy for a crash before the request leaves the host. This is an architectural requirement, not a feature installed by the configuration above. The offline model shows why: its fake gateway accepts once and loses the response; blind retry accepts twice, while retaining <code>unknown</code> prevents a second attempt. Preventing replay can also leave an unsent event awaiting investigation. It is not an exactly-once claim.</p>
<p>For an existing ambiguous queued message, an administrator can contain future attempts with a targeted <code>postsuper -h QUEUE_ID</code> after verifying the queue ID. Holding a queue entry cannot recall a delivery process already running. Held mail does not expire under the normal queue-lifetime settings, so give it an explicit review deadline. Do not flush or requeue the whole deferred queue. Reconcile against available gateway records before any deliberate replay, and monitor held/expired items through a separate operational path.</p>
<p>Finally, set an explicit freshness policy for alerts. A six-hour-old power event may be misleading even if the queue can still deliver it. The base mapping shown here does not implement event expiry or deduplication. Keep the live transport disabled until the deployment's retry, ambiguity and expiry behaviour has been verified against an isolated sink.</p>
]]></content:encoded></item><item><title><![CDATA[From accepted request to actionable alert: a dry-run lab for SMS integrations]]></title><description><![CDATA[Test SMS API responses offline with FoxBox HIGHWAY. Separate acceptance, delivery and acknowledgment without API keys or live messages.]]></description><link>https://foxbox-integrations.hashnode.dev/from-accepted-request-to-actionable-alert-a-dry-run-lab-for-sms-integrations</link><guid isPermaLink="true">https://foxbox-integrations.hashnode.dev/from-accepted-request-to-actionable-alert-a-dry-run-lab-for-sms-integrations</guid><category><![CDATA[Python]]></category><category><![CDATA[Node-Red]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Davide Cantaluppi]]></dc:creator><pubDate>Wed, 21 Oct 2026 19:41:01 GMT</pubDate><content:encoded><![CDATA[<p>Disclosure: this guide was prepared with AI assistance for FoxBox. The public HIGHWAY interface provides a concrete example. The lab runs entirely in memory, with no gateway, credential, recipient, SMS or email involved.</p>
<p>A monitoring job can finish successfully while nobody owns its alert. HTTP success, application acceptance, network delivery and human acknowledgment are separate observations. Store them separately: a green transport check should not silently close an incident.</p>
<h2>1. Define acceptance before connecting a sender</h2>
<p>The reviewed HIGHWAY v1 interface receives web-services requests at <code>/index.php</code>. Its existing Node-RED example uses a form-encoded POST with <code>app=ws</code>, <code>op=pv</code>, <code>format=json</code> and runtime fields <code>u</code>, <code>h</code>, <code>to</code> and <code>msg</code>. Authentication belongs in protected runtime configuration, never a tutorial or exported flow.</p>
<p>For a single recipient, the reviewed acceptance shape has one object in <code>data</code>, with <code>status=OK</code>, <code>error=0</code> and a nonzero queue or SMS-log identifier. This means acceptance for processing. The separate <code>ds</code> operation concerns stored status; it does not establish human acknowledgment.</p>
<p>These observations refer to public source revision <code>a6794b602d858d95a8131ad54cbb25057b5a2226</code>. Check your installed interface before adapting the example. A repository does not establish the configuration of every deployed appliance.</p>
<h2>2. Run four synthetic outcomes</h2>
<p>Save this complete example as <code>alert_contract_demo.py</code>. It contains no network client or send function. The license covers this original teaching example.</p>
<pre><code class="language-python"># SPDX-License-Identifier: GPL-3.0-or-later
# Offline teaching example: no networking, credentials, recipients or sends.
import json


def error_code(value):
    if type(value) is int and value &gt;= 0:
        return value
    if isinstance(value, str) and value.isascii() and value.isdecimal():
        return int(value)
    return None


def valid_id(value):
    if type(value) is int:
        return value &gt; 0
    if not isinstance(value, str) or not value or not value.isascii():
        return False
    if value.isdecimal():
        return int(value) &gt; 0
    return all(c.isalnum() or c in "-_" for c in value)


def classify(http_status, body):
    if http_status is None:
        return "transport_unknown"
    if type(http_status) is not int:
        return "unverified"
    if http_status in (401, 403, 429):
        return "rejected"
    if not 200 &lt;= http_status &lt; 300 or not isinstance(body, dict):
        return "unverified"
    top_status = body.get("status")
    if "status" in body and top_status not in ("OK", "ERR"):
        return "unverified"
    top_error = error_code(body["error"]) if "error" in body else None
    if "error" in body and top_error is None:
        return "unverified"
    if top_status == "ERR" or (top_error is not None and top_error &gt; 0):
        if top_status == "OK" or not top_error or body.get("data"):
            return "unverified"
        return "rejected"
    rows = body.get("data")
    if not isinstance(rows, list) or len(rows) != 1:
        return "unverified"
    row = rows[0]
    if not isinstance(row, dict):
        return "unverified"
    status, error = row.get("status"), error_code(row.get("error"))
    if status == "ERR" and error is not None and error &gt; 0:
        return "rejected"
    if status != "OK" or error != 0:
        return "unverified"
    if valid_id(row.get("queue")) or valid_id(row.get("smslog_id")):
        return "accepted_for_processing"
    return "unverified"


CASES = {
    "accepted": (200, {"data": [{"status": "OK", "error": 0,
                                "queue": "DEMO-QUEUE"}]}),
    "authentication": (403, {}),
    "malformed": (200, {"data": []}),
    "timeout": (None, None),
}

if __name__ == "__main__":
    for name, (http_status, body) in CASES.items():
        print(json.dumps({"case": name, "outcome": classify(http_status, body),
                          "simulation": True, "transport_attempted": False,
                          "delivery_verified": False,
                          "human_acknowledged": False}, sort_keys=True))
</code></pre>
<p>Run <code>python3 alert_contract_demo.py</code>. In order, the four outcomes should be <code>accepted_for_processing</code>, <code>rejected</code>, <code>unverified</code> and <code>transport_unknown</code>. Every record keeps transport, delivery and human acknowledgment false because the exercise is simulated. These teaching labels are not the full Node-RED flow's result schema.</p>
<p>Change the queue identifier to <code>0</code>, omit <code>status</code> or <code>error</code>, or use a boolean or fractional error. These incomplete or invalid responses must remain <code>unverified</code>, not become proof of rejection. Positive integer application errors support rejection only when consistent with the response. An HTTP success code alone does not establish acceptance. This conservative teaching classifier is not a production client.</p>
<h2>3. Inspect the existing Node-RED flow safely</h2>
<p>Import the published Flow Library example into an isolated workspace. Its exported tab is disabled and Inject has no repeating schedule. Keep <code>FOXBOX_ENABLE_SEND</code> unset or exactly <code>false</code>. Enable the tab for inspection, deploy and press Inject manually: expect a redacted <code>dry_run</code> result with reason <code>sending_disabled</code> and no HTTP request.</p>
<p>This check needs no credentials or recipient. Inspect the Function nodes before attaching real events. The operator configures the destination; incoming messages cannot supply it. The flow verifies TLS certificates, disables redirects and performs no automatic retry.</p>
<p>The recorded isolated test of this existing flow passed 22 checks with 13 localhost mock requests on Node-RED 5.0.8. That evidence applies to that flow and environment, not hardware, a carrier or handset delivery.</p>
<h2>4. Preserve uncertainty after a timeout</h2>
<p>A gateway may accept a request before its response is lost. Calling that outcome “not sent” and immediately retrying can create duplicates. Retain an event ID and attempt number, record the unknown outcome and use the installation's status facilities before deciding on another attempt.</p>
<p>Log the timestamp, classification and next review action. Exclude message bodies, full telephone numbers, tokens and raw responses from routine logs. Keep reconciliation identifiers in restricted operational records.</p>
<p>The existing flow's interval guard uses one Function-node context. With Node-RED's default in-memory store, that state is lost on restart. It is not durable deduplication, a global quota or a delivery queue.</p>
<h2>5. Give ownership its own deadline</h2>
<p>Until an authorized responder accepts the correct incident, ownership remains unconfirmed. Delivery evidence cannot prove that someone understood the alert or started work.</p>
<p>Bind any future acknowledgment mechanism to the incident and an authenticated responder. Decide what happens to late acknowledgments, duplicate replies and recovery events. Acknowledgment, authority to remediate and resolution are separate transitions. This lab implements no authentication service or incoming-SMS parser.</p>
<p>For OTP, leave code generation, expiry and verification with the identity system. This notification example implements none of them. NIST treats SMS out-of-band authentication separately from phishing-resistant methods; selecting a transport does not establish a deployment's authentication strength.</p>
<p>For CRA or NIS2 reviews, retain versions, configuration assumptions and test boundaries as technical evidence. This lab makes no conformity or certification claim. It provides an inspectable acceptance rule and a visible place for uncertainty and ownership.</p>
<p>References:</p>
<ul>
<li><a href="https://gitlab.com/davide.cantaluppi23/fb_highway_public">Public HIGHWAY source</a></li>
<li><a href="https://flows.nodered.org/flow/a5e629302c888f9d2a80602968be30ea">Existing Node-RED flow</a></li>
<li><a href="https://www.smsfoxbox.it/manual/FoxBox_Complete_Manual_EN.html">Gateway account configuration, chapter 12</a></li>
<li><a href="https://pages.nist.gov/800-63-4/sp800-63b/authenticators/">NIST authentication guidance</a></li>
</ul>
]]></content:encoded></item></channel></rss>