Author SHA1 Message Date
jcoffey-dev 825f49671e Merge branch 'ci/gitea-actions' into 'main'
ci / build (push) Successful in 37m28s
ci: add Gitea Actions workflow

See merge request inbuxa/inbuxa-server!5
2026-09-22 00:01:05 -07:00
jcoffey-dev 29bcfecb80 ci: add Gitea Actions workflow ported from .gitlab-ci.yml
ci / build (pull_request) Successful in 24m51s
2026-09-21 22:45:05 -07:00
jcoffey-dev e953c68e2e Merge branch 'feat/legacy-protocols-nav' into 'main'
CI / build (pull_request) Waiting to run
Settings › Security gains Hardening (after the admin release and LP-6)

See merge request inbuxa/inbuxa-server!2
2026-09-21 11:17:17 -07:00
jcoffey-dev 9379c1f151 Merge branch 'feat/legacy-listener-create-refused' into 'main'
No legacy listener can be added while the switch is off (LP-4)

See merge request inbuxa/inbuxa-server!4
2026-09-21 10:50:00 -07:00
jcoffey-dev 30be928e14 Merge main, and put the Hardening link on top of LP-6's schema
LP-6 added auth.legacy-protocol-refused to the packaged schema, which this
branch also changes. The file is gzipped, so the two can't be merged line
by line: this takes main's schema and adds the Settings › Security ›
Hardening link to it again, with the hash recomputed.
2026-09-21 10:21:01 -07:00
jcoffey-dev 6b1e5c67e3 Merge branch 'feat/legacy-signin-refusal' into 'main'
Legacy sign-in is refused while the switch is off (LP-6)

See merge request inbuxa/inbuxa-server!3
2026-09-21 10:05:13 -07:00
jcoffey-dev 1a48474957 Settings › Security gains Hardening, the legacy protocols screen
Adds a link to CustomComponent/LegacyProtocols in the packaged schema's
Settings › Security, between Settings and Blocked IPs, and updates the
schema hash so admins fetch the new layout rather than a cached one.

INBUXA Admin draws the screen; this is what makes it reachable. An admin
from before that screen would show "Unknown component" here, so this
lands after the admin release that carries it.
2026-09-21 09:19:49 -07:00
10 changed files with 62 additions and 196 deletions
+42
View File
@@ -0,0 +1,42 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in.
#
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: docker
container:
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# Both kept inside the workspace (a per-job volume on the project disk),
# deliberately not on /tmp, which on this host is a tmpfs that a Rust
# build of this size has filled before. There is no cache between runs
# here -- the runner's cache server is off -- so every build is cold.
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
echo "CARGO_HOME=$GITHUB_WORKSPACE/.cargo" >> "$GITHUB_ENV"
echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/target" >> "$GITHUB_ENV"
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
- run: cargo build -p inbuxa --locked
# --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite.
- run: cargo test --workspace --locked --no-run
+1
View File
@@ -10,6 +10,7 @@ run.sh
!.gitattributes !.gitattributes
!.github !.github
!.gitlab-ci.yml !.gitlab-ci.yml
!.gitea
CLAUDE.md CLAUDE.md
# The cutover rehearsal writes its fixture and state here. # The cutover rehearsal writes its fixture and state here.
+6 -25
View File
@@ -47,9 +47,6 @@ pub struct Network {
#[derive(Clone)] #[derive(Clone)]
pub struct NetworkInfo { pub struct NetworkInfo {
pub pacc: Pacc, pub pacc: Pacc,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
/// served while legacy protocols are off (legacy-protocols LP-7).
pub pacc_jmap_only: Pacc,
pub mxs: Vec<MailExchanger>, pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>, pub services: VecMap<ServiceProtocol, Service>,
} }
@@ -323,26 +320,11 @@ impl Network {
} }
} }
let split = |pacc: &Configuration| { let (prefix, suffix) = serde_json::to_string(&pacc)
serde_json::to_string(pacc) .unwrap_or_default()
.unwrap_or_default() .rsplit_once(SPLIT_HERE)
.rsplit_once(SPLIT_HERE) .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.map(|(prefix, suffix)| Pacc { .unwrap();
prefix: prefix.to_string(),
suffix: suffix.to_string(),
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7
let pacc_jmap_only = {
let mut pacc = pacc.clone();
pacc.protocols.imap = None;
pacc.protocols.pop3 = None;
pacc.protocols.smtp = None;
pacc.protocols.managesieve = None;
split(&pacc)
};
let pacc = split(&pacc);
let mut network = Network { let mut network = Network {
node_id: bp.node_id() as u64, node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(), server_name: default_hostname.to_string(),
@@ -357,8 +339,7 @@ impl Network {
info: NetworkInfo { info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(), mxs: system.mail_exchangers.into_iter().collect(),
services: system.services, services: system.services,
pacc, pacc: Pacc { prefix, suffix },
pacc_jmap_only,
}, },
}; };
@@ -2,11 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::XmlVersion; use quick_xml::XmlVersion;
use quick_xml::events::Event; use quick_xml::events::Event;
@@ -57,12 +55,7 @@ impl Server {
let _ = writeln!(&mut config, "\t\t<Account>"); let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>"); let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>"); let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol { let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]), ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]), ServiceProtocol::Pop3 => ("POP3", [110, 995]),
@@ -2,11 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use std::fmt::Write; use std::fmt::Write;
use utils::url_params::UrlParams; use utils::url_params::UrlParams;
@@ -30,9 +28,6 @@ impl Server {
("%EMAILADDRESS%", default_host.as_str()) ("%EMAILADDRESS%", default_host.as_str())
}; };
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
// Build XML response // Build XML response
let mut config = String::with_capacity(1024); let mut config = String::with_capacity(1024);
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@@ -45,9 +40,6 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>" "\t\t<displayShortName>{domain}</displayShortName>"
); );
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol { let (protocol, tag, ports) = match protocol {
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
+9 -44
View File
@@ -2,15 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use ahash::{AHashMap, AHashSet}; use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use dns_update::{ use dns_update::{
@@ -39,8 +33,6 @@ impl Server {
let mut records = Vec::new(); let mut records = Vec::new();
let network = &self.core.network; let network = &self.core.network;
let default_host = network.server_name.as_str(); let default_host = network.server_name.as_str();
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
let domain_name = domain.name.as_str(); let domain_name = domain.name.as_str();
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
@@ -201,25 +193,6 @@ impl Server {
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
}; };
// inbuxa: legacy-protocols LP-7. While they are off, every
// name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) {
for (service_name, _) in services {
records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."),
record: DnsRecord::SRV(SRVRecord {
target: ".".to_string(),
priority: 0,
weight: 0,
port: 0,
}),
});
}
continue;
}
for (is_tls, (service_name, port)) in services.into_iter().enumerate() { for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
if is_tls == 1 || service.cleartext { if is_tls == 1 || service.cleartext {
records.push(NamedDnsRecord { records.push(NamedDnsRecord {
@@ -304,14 +277,6 @@ impl Server {
for (protocol, service) in &network.info.services { for (protocol, service) in &network.info.services {
let hostname = service.hostname.as_deref().unwrap_or(default_host); let hostname = service.hostname.as_deref().unwrap_or(default_host);
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays.
if legacy_off
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
{
continue;
}
let port = match protocol { let port = match protocol {
ServiceProtocol::Imap => 993, ServiceProtocol::Imap => 993,
ServiceProtocol::Pop3 => 995, ServiceProtocol::Pop3 => 995,
@@ -417,12 +382,6 @@ impl Server {
} }
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> { pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7
let pacc = if self.legacy_protocols_off().await? {
&self.core.network.info.pacc_jmap_only
} else {
&self.core.network.info.pacc
};
self.get_directory_for_domain(domain_name) self.get_directory_for_domain(domain_name)
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
@@ -431,9 +390,15 @@ impl Server {
.and_then(|directory| { .and_then(|directory| {
directory directory
.oidc_discovery_document() .oidc_discovery_document()
.map(|doc| pacc.build(&doc.url)) .map(|doc| self.core.network.info.pacc.build(&doc.url))
})
.unwrap_or_else(|| {
self.core
.network
.info
.pacc
.build(&self.core.network.http.url_https)
}) })
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
}) })
} }
} }
-46
View File
@@ -21,10 +21,6 @@
//! a legacy protocol is refused before any password is looked at, so a //! a legacy protocol is refused before any password is looked at, so a
//! listener that exists by mistake still lets nobody in. //! listener that exists by mistake still lets nobody in.
//! //!
//! And nothing advertises what is closed (LP-7): client configuration and
//! the suggested DNS records leave the legacy services out, or mark them as
//! not offered, while the switch is off.
//!
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! Nothing here touches the host's firewall, NAT port-forwards or any proxy
//! (LP-20). The server stops answering; what still routes the port is the //! (LP-20). The server stops answering; what still routes the port is the
//! operator's to reconcile. //! operator's to reconcile.
@@ -35,7 +31,6 @@ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SavedListener},
}; };
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId}; use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap; use store::registry::bootstrap::Bootstrap;
@@ -307,27 +302,6 @@ impl Server {
} }
} }
/// The services mail apps sign in to, which the switch turns off: nothing may
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
/// sending -- since inbound mail is never a configured service.
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
matches!(
protocol,
ServiceProtocol::Imap
| ServiceProtocol::Pop3
| ServiceProtocol::Smtp
| ServiceProtocol::Managesieve
)
}
impl Server {
/// Whether the server-wide switch is off, for the answers that must stop
/// offering legacy services (LP-7). Read per answer, as sign-in reads it.
pub async fn legacy_protocols_off(&self) -> trc::Result<bool> {
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -375,26 +349,6 @@ mod tests {
assert_eq!(err.value_as_str(trc::Key::AccountName), None); assert_eq!(err.value_as_str(trc::Key::AccountName), None);
} }
#[test]
fn only_the_services_mail_apps_sign_in_to_are_legacy() {
for protocol in [
ServiceProtocol::Imap,
ServiceProtocol::Pop3,
ServiceProtocol::Smtp,
ServiceProtocol::Managesieve,
] {
assert!(is_legacy_service(&protocol), "{protocol:?}");
}
for protocol in [
ServiceProtocol::Jmap,
ServiceProtocol::Caldav,
ServiceProtocol::Carddav,
ServiceProtocol::Webdav,
] {
assert!(!is_legacy_service(&protocol), "{protocol:?}");
}
}
#[test] #[test]
fn the_domain_comes_from_the_name_given() { fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string())); assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
+1 -63
View File
@@ -17,9 +17,7 @@ submission -- locked open -- is refused with the spec's words, with the right
password and with a wrong one, and refusals never add up to a disconnect password and with a wrong one, and refusals never add up to a disconnect
(LP-11). And that a normal IMAP sign-in works with the switch on, before and (LP-11). And that a normal IMAP sign-in works with the switch on, before and
after. And that while it is off, no listener the switch would close can be after. And that while it is off, no listener the switch would close can be
created, or made by an update (LP-4, test 4), and nothing advertises what is created, or made by an update (LP-4, test 4).
closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission,
and the suggested zone marks their SRV names not offered (LP-7, test 5).
Passwords are generated into files under target/e2e and never printed. Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1. Everything is removed afterwards unless KEEP=1.
@@ -191,40 +189,6 @@ def smtp_auths(port, user, passwords):
return replies return replies
def advertised(admin, admin_pw):
"""What each client-configuration answer and the suggested zone offer."""
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
timeout=30) as resp:
autoconfig = resp.read().decode()
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
'</AcceptableResponseSchema></Request></Autodiscover>').encode()
req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST")
req.add_header("Content-Type", "text/xml")
with urllib.request.urlopen(req, timeout=30) as resp:
autodiscover = resp.read().decode()
with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json",
timeout=30) as resp:
pacc = json.load(resp).get("protocols", {})
got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]})
zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", [])
if d.get("name") == "legacy.test"), "")
srv = {}
for line in zone.splitlines():
fields = line.split()
if "SRV" in fields and fields[0].startswith("_"):
srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1]
return {
"autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig},
"autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"<Type>{t}</Type>" in autodiscover},
"pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc},
"jmap": "jmap" in pacc,
"srv": srv,
}
def settle(port, want, tries=30): def settle(port, want, tries=30):
"""Wait for a port to reach the wanted state, so the check is not a race.""" """Wait for a port to reach the wanted state, so the check is not a race."""
for _ in range(tries): for _ in range(tries):
@@ -279,15 +243,6 @@ def main():
check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["submissions"]), "submission accepts before the switch")
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch") check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
# What is advertised with the switch on -- the control for LP-7.
before = advertised(admin, admin_pw)
print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v
for k, v in before.items() if k != "srv"})
check(before["autoconfig"] and before["autodiscover"],
"autoconfig and autodiscover offer mail apps a server with the switch on")
check(before["srv"].get("_imaps._tcp", ".") != ".",
"the suggested zone offers IMAP with the switch on")
# A normal sign-in works with the switch on -- the control for LP-6. # A normal sign-in works with the switch on -- the control for LP-6.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with the switch on") "IMAP sign-in works with the switch on")
@@ -340,19 +295,6 @@ def main():
if not all(r == SMTP_REFUSAL for r in replies): if not all(r == SMTP_REFUSAL for r in replies):
print(" replies:", replies) print(" replies:", replies)
# Nothing advertises what is closed (LP-7, test 5).
during = advertised(admin, admin_pw)
check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)")
check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)")
check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)")
names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp",
"_submission._tcp", "_submissions._tcp")
offered = {n: t for n, t in during["srv"].items() if n in names and t != "."}
check(not offered and "_imaps._tcp" in during["srv"],
"the suggested zone marks the legacy SRV names not offered, target . (LP-7)")
if offered or "_imaps._tcp" not in during["srv"]:
print(" srv:", during["srv"])
# No listener the switch would close can be added while it is off (LP-4, # No listener the switch would close can be added while it is off (LP-4,
# test 4), and the refusal names the policy. # test 4), and the refusal names the policy.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": { res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
@@ -399,10 +341,6 @@ def main():
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
after = advertised(admin, admin_pw)
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
"autoconfig and the suggested zone offer them again once back on")
# And sign-in works again, with no restart. # And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works again once the switch is back on") "IMAP sign-in works again once the switch is back on")