Repository: letsencrypt/boulder

Issue:
Ability to provide a contact email but preemptively opt out of renewal reminders
As mentioned in #4694, I want to be able to provide a subscriber contact address for an ACME account, without signing up to renewal emails.

Reasoning is that the renewal emails are unhelpful (confusing for end-users -> support burn for us) if the ACME client is such that it automatically adjusts what names are included on a certificate. And if the ACME client is otherwise taking responsibility for notifying the user about problematic domains etc.

For the last 4 years we have just been registering the accounts (probably >100K) without any email address, but in light of the revocation emails, it would have been really nice for those users to be included. And we can still do so, if we can figure out how to make such an account update.

But as I mentioned in the other issue, finding a way to sneak this into registration in a conforming way is looking pretty hard.

e: One extra thought, if the contact URL scheme does not have to be registered with IETF (no idea if that's the case), maybe using `mailto-no-reminders:` or `mailto-only-administrative:`  or something. Shouldn't cause interop problems if clients handle `unsupportedContact` properly.

Discussion:
@jsha: Thanks for bringing this up! I see the use case, and support this in theory, but it seems like one of the main motivating problems is "renewal notifications are not useful for people who frequently add and remove names from certificates." This is a longstanding problem, and I feel bad we haven't properly addressed it yet. I actually went looking for a relevant issue and didn't find one, so I filed it: https://github.com/letsencrypt/boulder/issues/4702.

I'd like to take a stab at addressing the underlying issue before we implement this, because I think implementing this proposal first will likely wind up with many people choosing not to receive expiration emails at all, when we have the potential to make them much more useful.

If we do go ahead with this, I think rather than trying to overload existing semantics in the Contact field, we should add an optional field to the new-account request, like contactPreferences.@aarongable: We have [announced](https://letsencrypt.org/2025/01/22/ending-expiration-emails/) that we will no longer be providing expiration notification emails. Closing this ticket as WontFix, since we do not plan to make any changes to the expiration notification system prior to decommissioning it.

Solution patch:
diff --git a/ra/ra.go b/ra/ra.go
index fbdf7a4b1..f3b66555c 100644
--- a/ra/ra.go
+++ b/ra/ra.go
@@ -358,6 +358,7 @@ func (ra *RegistrationAuthorityImpl) NewRegistration(ctx context.Context, init c
 // * A list containing an empty contact
 // * A list containing a contact that does not parse as a URL
 // * A list containing a contact that has a URL scheme other than mailto
+// * A list containing a mailto contact that contains hfields
 // * A list containing a contact that has non-ascii characters
 // * A list containing a contact that doesn't pass `validateEmail`
 func (ra *RegistrationAuthorityImpl) validateContacts(ctx context.Context, contacts *[]string) error {
@@ -383,6 +384,9 @@ func (ra *RegistrationAuthorityImpl) validateContacts(ctx context.Context, conta
 		if parsed.Scheme != "mailto" {
 			return berrors.InvalidEmailError("contact method %q is not supported", parsed.Scheme)
 		}
+		if parsed.RawQuery != "" {
+			return berrors.InvalidEmailError("contact email [%q] contains hfields", contact)
+		}
 		if !core.IsASCII(contact) {
 			return berrors.InvalidEmailError(
 				"contact email [%q] contains non-ASCII characters",
diff --git a/sa/database.go b/sa/database.go
index 8c8970f2a..5d43db458 100644
--- a/sa/database.go
+++ b/sa/database.go
@@ -136,7 +136,7 @@ func initTables(dbMap *gorp.DbMap) {
 	dbMap.AddTableWithName(requestedNameModel{}, "requestedNames").SetKeys(false, "OrderID")
 	dbMap.AddTableWithName(orderFQDNSet{}, "orderFqdnSets").SetKeys(true, "ID")
 	dbMap.AddTableWithName(authzModel{}, "authz2").SetKeys(true, "ID")
-	dbMap.AddTableWithName(orderToAuthz2Model{}, "orderToAuthz2").SetKeys(false, "OrderID", "AuthzID")
+	dbMap.AddTableWithName(orderToAuthzModel{}, "orderToAuthz2").SetKeys(false, "OrderID", "AuthzID")
 	dbMap.AddTableWithName(recordedSerialModel{}, "serials").SetKeys(true, "ID")
 	dbMap.AddTableWithName(precertificateModel{}, "precertificates").SetKeys(true, "ID")
 }
diff --git a/sa/model.go b/sa/model.go
index 9a79c744b..4a8376e03 100644
--- a/sa/model.go
+++ b/sa/model.go
@@ -350,11 +350,6 @@ type requestedNameModel struct {
 }
 
 type orderToAuthzModel struct {
-	OrderID int64
-	AuthzID string
-}
-
-type orderToAuthz2Model struct {
 	OrderID int64
 	AuthzID int64
 }
@@ -449,7 +444,7 @@ func statusUint(status core.AcmeStatus) uint8 {
 	return statusToUint[string(status)]
 }
 
-const authz2Fields = "id, identifierType, identifierValue, registrationID, status, expires, challenges, attempted, token, validationError, validationRecord"
+const authzFields = "id, identifierType, identifierValue, registrationID, status, expires, challenges, attempted, token, validationError, validationRecord"
 
 type authzModel struct {
 	ID               int64     `db:"id"`
diff --git a/sa/sa.go b/sa/sa.go
index 36120acf6..50ce59b90 100644
--- a/sa/sa.go
+++ b/sa/sa.go
@@ -903,7 +903,7 @@ func (ssa *SQLStorageAuthority) NewOrder(ctx context.Context, req *corepb.Order)
 		}
 
 		for _, id := range req.V2Authorizations {
-			otoa := &orderToAuthz2Model{
+			otoa := &orderToAuthzModel{
 				OrderID: order.ID,
 				AuthzID: id,
 			}
@@ -1443,7 +1443,7 @@ func (ssa *SQLStorageAuthority) GetAuthorizations2(ctx context.Context, req *sap
 			expires > ? AND
 			identifierType = ? AND
 			identifierValue IN (%s)`,
-		authz2Fields,
+		authzFields,
 		strings.Join(qmarks, ","),
 	)
 	_, err := ssa.dbMap.Select(
@@ -1624,7 +1624,7 @@ func (ssa *SQLStorageAuthority) GetPendingAuthorization2(ctx context.Context, re
 			identifierType = :dnsType AND
 			identifierValue = :ident
 			ORDER BY expires ASC
-			LIMIT 1 `, authz2Fields),
+			LIMIT 1 `, authzFields),
 		map[string]interface{}{
 			"regID":      *req.RegistrationID,
 			"status":     statusUint(core.StatusPending),
@@ -1676,7 +1676,7 @@ func (ssa *SQLStorageAuthority) GetValidOrderAuthorizations2(ctx context.Context
 			authz2.expires > :expires AND
 			authz2.status = :status AND
 			orderToAuthz2.orderID = :orderID`,
-			authz2Fields,
+			authzFields,
 		),
 		map[string]interface{}{
 			"regID":   *req.AcctID,
@@ -1758,7 +1758,7 @@ func (ssa *SQLStorageAuthority) GetValidAuthorizations2(ctx context.Context, req
 			expires > ? AND
 			identifierType = ? AND
 			identifierValue IN (%s)`,
-			authz2Fields,
+			authzFields,
 			strings.Join(qmarks, ","),
 		),
 		params...,



Test patch:
diff --git a/ra/ra_test.go b/ra/ra_test.go
index f02177090..d5d1f6385 100644
--- a/ra/ra_test.go
+++ b/ra/ra_test.go
@@ -419,6 +419,9 @@ func TestValidateContacts(t *testing.T) {
 	err = ra.validateContacts(context.Background(), &[]string{"mailto:admin@[1.2.3.4]"})
 	test.AssertError(t, err, "Forbidden email")
 
+	err = ra.validateContacts(context.Background(), &[]string{"mailto:admin@a.com?no-reminder-emails"})
+	test.AssertError(t, err, "No hfields in email")
+
 	// The registrations.contact field is VARCHAR(191). 175 'a' characters plus
 	// the prefix "mailto:" and the suffix "@a.com" makes exactly 191 bytes of
 	// encoded JSON. The correct size to hit our maximum DB field length.
diff --git a/sa/sa_test.go b/sa/sa_test.go
index 6077ebef5..bf7e3bf16 100644
--- a/sa/sa_test.go
+++ b/sa/sa_test.go
@@ -1140,17 +1140,17 @@ func TestGetAuthorizations2(t *testing.T) {
 
 	// Associate authorizations with an order so that GetAuthorizations2 thinks
 	// they are WFE2 authorizations.
-	err := sa.dbMap.Insert(&orderToAuthz2Model{
+	err := sa.dbMap.Insert(&orderToAuthzModel{
 		OrderID: 1,
 		AuthzID: authzIDA,
 	})
 	test.AssertNotError(t, err, "sa.dbMap.Insert failed")
-	err = sa.dbMap.Insert(&orderToAuthz2Model{
+	err = sa.dbMap.Insert(&orderToAuthzModel{
 		OrderID: 1,
 		AuthzID: authzIDB,
 	})
 	test.AssertNotError(t, err, "sa.dbMap.Insert failed")
-	err = sa.dbMap.Insert(&orderToAuthz2Model{
+	err = sa.dbMap.Insert(&orderToAuthzModel{
 		OrderID: 1,
 		AuthzID: authzIDC,
 	})