diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b1f7c79b..cbd1c942 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,7 +43,7 @@ jobs:
if: github.event_name == 'push' || inputs.Docker
steps:
- name: Install Cosign
- uses: sigstore/cosign-installer@v4
+ uses: sigstore/cosign-installer@v4.1.1
- name: Log In to GitHub Container Registry
uses: docker/login-action@v4
with:
@@ -377,6 +377,11 @@ jobs:
needs: [linux, windows, macos]
runs-on: ubuntu-latest
steps:
+ # Must run before artifacts are downloaded — checkout cleans the workspace.
+ - name: Checkout (for CHANGELOG)
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: actions/checkout@v6.0.2
+
- name: Download Artifacts
uses: actions/download-artifact@v8
with:
@@ -424,6 +429,20 @@ jobs:
archive/**/*.tar.gz
archive/**/*.zip
+ - name: Build release body
+ run: |
+ if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
+ awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md
+ echo "" >> release_body.md
+ else
+ : > release_body.md
+ fi
+ cat >> release_body.md <
+
+ ### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }})
+ EOF
+
- name: Release
uses: softprops/action-gh-release@v3
with:
@@ -433,9 +452,34 @@ jobs:
archive/**/*.sigstore.json
prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}}
tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}}
- # TODO add instructions about using cosign to verify binary artifact
- append_body: true
- body: |
-
+ # Tag-push releases are created as drafts; the `publish` job un-drafts
+ # them only after all build jobs succeed, so watcher notifications
+ # don't fire on broken builds.
+ draft: ${{ startsWith(github.ref, 'refs/tags/') || null }}
+ body_path: release_body.md
- ### Check binary attestation at [here](${{ steps.attest.outputs.attestation-url }})
+ publish:
+ name: Publish release
+ needs: [linux, windows, macos, multiarch, release]
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Un-draft release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}"
+
+ cleanup:
+ name: Cleanup failed release
+ needs: [linux, windows, macos, multiarch, release]
+ if: failure() && startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Delete draft release and tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs
index 413e05d8..4e175fee 100644
--- a/crates/common/src/config/network.rs
+++ b/crates/common/src/config/network.rs
@@ -196,12 +196,14 @@ impl Network {
},
};
- let mut http_host = system.default_hostname.clone();
+ let default_hostname = if !system.default_hostname.is_empty() {
+ system.default_hostname.as_str()
+ } else {
+ bp.registry.local_hostname()
+ };
+ let mut http_host = default_hostname.to_string();
for (service, details) in &system.services {
- let hostname = details
- .hostname
- .as_deref()
- .unwrap_or(&system.default_hostname);
+ let hostname = details.hostname.as_deref().unwrap_or(default_hostname);
match service {
ServiceProtocol::Jmap => {
@@ -319,7 +321,7 @@ impl Network {
.unwrap();
let mut network = Network {
node_id: bp.node_id() as u64,
- server_name: system.default_hostname,
+ server_name: default_hostname.to_string(),
security: Security::parse(bp).await,
contact_form: ContactForm::parse(bp).await,
asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(),
@@ -395,7 +397,7 @@ impl Http {
let use_permissive_cors = true;
#[cfg(not(feature = "dev_mode"))]
- let use_permissive_cors = http.use_permissive_cors;
+ let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
if use_permissive_cors {
http_headers.push((
@@ -425,8 +427,16 @@ impl Http {
}
Http {
- url_https: format!("https://{}", server_name),
- url_http: format!("http://{}", server_name),
+ url_https: if !bp.registry.is_bootstrap_mode() {
+ format!("https://{server_name}")
+ } else {
+ String::new()
+ },
+ url_http: if !bp.registry.is_bootstrap_mode() {
+ format!("http://{server_name}")
+ } else {
+ String::new()
+ },
allowed_endpoint: bp
.compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints()),
rate_authenticated: http.rate_limit_authenticated,
diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs
index 3213acbb..fc2bc88f 100644
--- a/crates/common/src/manager/boot.rs
+++ b/crates/common/src/manager/boot.rs
@@ -163,6 +163,7 @@ impl BootManager {
if bootstrap.registry.is_bootstrap_mode() {
trc::event!(
Server(trc::ServerEvent::BootstrapMode),
+ Hostname = bootstrap.registry.local_hostname().to_string(),
Details =
"No configuration file was found. Port 8080 is open for initial setup.",
Version = env!("CARGO_PKG_VERSION"),
diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs
index 56ec939e..2aaa3cbb 100644
--- a/crates/dav/src/request.rs
+++ b/crates/dav/src/request.rs
@@ -179,7 +179,7 @@ impl DavRequestDispatcher for Server {
Report::AclPrincipalPropSet(report) => {
// Validate permissions
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::DavPrincipalAcl)
{
return Err(DavError::Condition(
DavErrorCondition::new(
@@ -198,7 +198,7 @@ impl DavRequestDispatcher for Server {
Report::PrincipalMatch(report) => {
// Validate permissions
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::DavPrincipalMatch)
{
return Err(DavError::Condition(
DavErrorCondition::new(
@@ -218,7 +218,7 @@ impl DavRequestDispatcher for Server {
if resource == DavResourceName::Principal {
// Validate permissions
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::DavPrincipalSearch)
{
return Err(DavError::Condition(
DavErrorCondition::new(
diff --git a/crates/http-proto/src/context.rs b/crates/http-proto/src/context.rs
index a79f18ee..8134c215 100644
--- a/crates/http-proto/src/context.rs
+++ b/crates/http-proto/src/context.rs
@@ -33,10 +33,14 @@ impl<'x> HttpContext<'x> {
} else {
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
{
- format!(
- "{}:{}",
- server.core.network.http.url_http, self.session.local_port
- )
+ if !server.registry().is_bootstrap_mode() {
+ format!(
+ "{}:{}",
+ server.core.network.http.url_http, self.session.local_port
+ )
+ } else {
+ server.core.network.http.url_http.clone()
+ }
}
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
diff --git a/crates/http/src/auth/permissions.rs b/crates/http/src/auth/permissions.rs
index f3beb5e1..f8880f73 100644
--- a/crates/http/src/auth/permissions.rs
+++ b/crates/http/src/auth/permissions.rs
@@ -87,6 +87,18 @@ impl AccountApiHandler for Server {
permissions.clear(p.to_id() as usize);
}
+ if !self.core.groupware.allow_directory_query {
+ for p in [
+ Permission::JmapPrincipalQuery,
+ Permission::JmapPrincipalQueryChanges,
+ Permission::JmapPrincipalGet,
+ Permission::JmapPrincipalGetAvailability,
+ Permission::JmapPrincipalChanges,
+ ] {
+ permissions.clear(p.to_id() as usize);
+ }
+ }
+
if is_external_directory || is_recovery_admin {
permissions.clear(Permission::SysAccountPasswordGet.to_id() as usize);
permissions.clear(Permission::SysAccountPasswordUpdate.to_id() as usize);
diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs
index 32618fbf..6b604429 100644
--- a/crates/jmap/src/principal/availability.rs
+++ b/crates/jmap/src/principal/availability.rs
@@ -62,7 +62,7 @@ impl PrincipalGetAvailability for Server {
access_token: &AccessToken,
) -> trc::Result {
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::JmapPrincipalGetAvailability)
{
return Err(trc::JmapEvent::Forbidden
.into_err()
diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs
index ec9240ea..685e8346 100644
--- a/crates/jmap/src/principal/get.rs
+++ b/crates/jmap/src/principal/get.rs
@@ -32,7 +32,7 @@ impl PrincipalGet for Server {
access_token: &AccessToken,
) -> trc::Result> {
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::JmapPrincipalGet)
{
return Err(trc::JmapEvent::Forbidden
.into_err()
diff --git a/crates/jmap/src/principal/query.rs b/crates/jmap/src/principal/query.rs
index 7d1afc2e..81172176 100644
--- a/crates/jmap/src/principal/query.rs
+++ b/crates/jmap/src/principal/query.rs
@@ -42,7 +42,7 @@ impl PrincipalQuery for Server {
access_token: &AccessToken,
) -> trc::Result {
if !self.core.groupware.allow_directory_query
- && !access_token.has_permission(Permission::SysAccountQuery)
+ && !access_token.has_permission(Permission::JmapPrincipalQuery)
{
return Err(trc::JmapEvent::Forbidden
.into_err()
diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs
index 13968c33..036b7168 100644
--- a/crates/jmap/src/registry/mapping/account.rs
+++ b/crates/jmap/src/registry/mapping/account.rs
@@ -120,7 +120,18 @@ pub(crate) async fn account_set(
set.response.not_updated.append(id, SetError::not_found());
}
- let mut account_pass = AccountPassword::default();
+ let mut account_pass = AccountPassword {
+ secret: None,
+ current_secret: None,
+ otp_auth: OtpAuth {
+ otp_code: None,
+ otp_url: if old_credential.otp_auth.is_some() {
+ Some(MASKED_PASSWORD.to_string())
+ } else {
+ None
+ },
+ },
+ };
for (key, value) in value.into_expanded_object() {
let ptr = match key {
@@ -149,23 +160,26 @@ pub(crate) async fn account_set(
}
}
- let is_empty_secret =
- account_pass.secret.is_empty() || account_pass.secret == MASKED_PASSWORD;
- let is_empty_otp = account_pass
- .otp_auth
- .otp_url
+ let is_empty_secret = account_pass
+ .secret
.as_ref()
- .is_none_or(|url| url != MASKED_PASSWORD);
+ .is_none_or(|secret| secret == MASKED_PASSWORD);
+ let is_empty_otp = account_pass.otp_auth.otp_url.as_deref()
+ == Some(MASKED_PASSWORD)
+ || (account_pass.otp_auth.otp_url.is_none()
+ && old_credential.otp_auth.is_none());
if !is_empty_secret || !is_empty_otp {
- if is_empty_secret {
- account_pass.secret = old_credential.secret.clone();
- }
+ let user_provided_secret = if !is_empty_secret {
+ account_pass.secret.as_ref().unwrap()
+ } else {
+ old_credential.secret.as_str()
+ };
if is_empty_otp {
account_pass.otp_auth.otp_url = old_credential.otp_auth.clone();
}
// Password changes are not supported when using external directories
- if (account_pass.secret != old_credential.secret
+ if (user_provided_secret != old_credential.secret
|| account_pass.otp_auth.otp_url != old_credential.otp_auth)
&& set
.server
@@ -183,7 +197,7 @@ pub(crate) async fn account_set(
continue 'outer;
}
- if account_pass.secret != old_credential.secret
+ if user_provided_secret != old_credential.secret
|| account_pass.otp_auth.otp_url != old_credential.otp_auth
{
if old_credential.secret.is_empty() {
@@ -249,9 +263,9 @@ pub(crate) async fn account_set(
}
}
- if account_pass.secret != old_credential.secret {
+ if user_provided_secret != old_credential.secret {
if let Err(err) =
- set.server.is_secure_password(&account_pass.secret, &[])
+ set.server.is_secure_password(user_provided_secret, &[])
{
set.response.not_updated.append(
id,
@@ -278,7 +292,7 @@ pub(crate) async fn account_set(
old_credential.secret = hash_secret(
set.server.core.network.security.password_hash_algorithm,
- account_pass.secret.into_bytes(),
+ user_provided_secret.as_bytes().to_vec(),
)
.await
.caused_by(trc::location!())?;
@@ -722,7 +736,7 @@ pub(crate) async fn account_get(
None
},
},
- secret: MASKED_PASSWORD.into(),
+ secret: MASKED_PASSWORD.to_string().into(),
}
.into_value(),
);
diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs
index 145465f7..483b59ef 100644
--- a/crates/migration/src/lib.rs
+++ b/crates/migration/src/lib.rs
@@ -56,6 +56,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
}
_ => {
if is_new_install(server).await.caused_by(trc::location!())? {
+ write_schema_version(server).await?;
return Ok(());
} else {
abort(concat!(
@@ -67,7 +68,10 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
}
migrate_v0_16(server).await?;
+ write_schema_version(server).await
+}
+async fn write_schema_version(server: &Server) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
ValueClass::Any(AnyClass {
diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs
index 13c4cef2..24642375 100644
--- a/crates/registry/src/schema/structs.rs
+++ b/crates/registry/src/schema/structs.rs
@@ -18,7 +18,7 @@ pub enum Account {
#[serde(default)]
pub struct AccountPassword {
#[serde(rename = "secret")]
- pub secret: String,
+ pub secret: Option,
#[serde(rename = "currentSecret")]
pub current_secret: Option,
#[serde(rename = "otpAuth")]
diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs
index 585ebfe6..25c04a99 100644
--- a/crates/registry/src/schema/structs_impl.rs
+++ b/crates/registry/src/schema/structs_impl.rs
@@ -119,9 +119,10 @@ impl ObjectImpl for AccountPassword {
fn validate(&self, errors: &mut Vec) -> bool {
let neb = errors.len();
- let value = &self.secret;
- if value.is_empty() {
- errors.push(ValidationError::required(Property::Secret));
+ if let Some(value) = &self.secret {
+ if value.is_empty() {
+ errors.push(ValidationError::required(Property::Secret));
+ }
}
if let Some(value) = &self.current_secret {
if value.is_empty() {
@@ -165,7 +166,9 @@ impl Default for AccountPassword {
impl IntoValue for AccountPassword {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(5);
- map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
+ if self.secret.is_some() {
+ map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into()));
+ }
if self.current_secret.is_some() {
map.insert_unchecked(
Property::CurrentSecret,
diff --git a/install.sh.new b/install.sh.new
index 841e7275..535bb732 100644
--- a/install.sh.new
+++ b/install.sh.new
@@ -204,6 +204,9 @@ write_env_file() {
# Environment variables for the Stalwart service.
# Uncomment and edit an entry to override its default.
+# Override the hostname used in HTTP responses
+#STALWART_HOSTNAME=mail.example.com
+
# Enable bootstrap / recovery mode on startup. Accepted: 1, true. Default: false.
#STALWART_RECOVERY_MODE=true
diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz
index 9dc5ed61..a6ac7ab5 100644
Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ
diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256
index c87eb0ca..8335e5e9 100644
--- a/resources/schema/schema.json.sha256
+++ b/resources/schema/schema.json.sha256
@@ -1 +1 @@
-cfUVQJSeFKFiYn3iirIvn58LaqJ3_HppFEBrqbXrr-E
\ No newline at end of file
+_NV9JZvmSHwGCNQ7Gw4Uj027Bpp2UT3LGf2zHyp4tOM
\ No newline at end of file