feat(accountsetup): add AOL provider with app-password help and IMAP/SMTP presets #190

Merged
JMR-dev merged 4 commits from feat-156-154-aol-provider into main 2026-07-03 02:37:39 +00:00
4 changed files with 58 additions and 9 deletions
@@ -8,18 +8,19 @@ private const val SMTP_SUBMISSION_PORT = 587
private const val SMTPS_PORT = 465
/**
* Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud).
* Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud, AOL).
*
* These mirror [Account.outlook]: each entry knows its servers so onboarding only has to collect an
* email + app password (see the app-password setup screen). Outlook is intentionally NOT here — it
* uses interactive OAuth, not an app password.
*
* Port / security rationale (verified against current vendor docs):
* - IMAP is implicit TLS on 993 for all three: none of these vendors document a STARTTLS IMAP
* - IMAP is implicit TLS on 993 for all four: none of these vendors document a STARTTLS IMAP
* endpoint, so 993/[MailSecurity.SSL_TLS] is the only correct choice.
* - SMTP biases toward STARTTLS on 587 where the vendor documents it (Gmail, iCloud), matching the
* epic's "prefer STARTTLS where supported" guidance. Yahoo documents implicit TLS on 465 as its
* outgoing server, so it keeps 465/[MailSecurity.SSL_TLS].
* epic's "prefer STARTTLS where supported" guidance. Yahoo and AOL each document only implicit
* TLS on 465 for their outgoing server (no STARTTLS/587 alternative is documented), so both keep
* 465/[MailSecurity.SSL_TLS].
* - [MailSecurity.NONE] is never used — every path here is encrypted end to end.
*/
enum class MailProvider(
@@ -32,7 +33,7 @@ enum class MailProvider(
/**
* Setup instructions for the provider's two-factor prerequisite, or null when there isn't one.
* Gmail and iCloud both refuse to issue app passwords until two-factor auth is on, so both
* link their own setup article; Yahoo gates nothing on it.
* link their own setup article; Yahoo and AOL gate nothing on it.
*/
val twoFactorHelpUrl: String? = null,
private val imapHost: String,
@@ -91,6 +92,20 @@ enum class MailProvider(
smtpPort = SMTP_SUBMISSION_PORT,
smtpSecurity = MailSecurity.STARTTLS,
),
AOL(
key = "aol",
displayName = "AOL Mail",
// AOL's own "Create and manage 3rd-party app passwords" article. Unlike Gmail/iCloud, it
// never lists two-step verification as a prerequisite for generating an app password (nor
// does AOL's separate two-step-verification article call itself one), so there is no
// twoFactorHelpUrl below — same as Yahoo (issue #156).
appPasswordHelpUrl = "https://help.aol.com/articles/Create-and-manage-app-password",
imapHost = "imap.aol.com",
smtpHost = "smtp.aol.com",
// AOL documents smtp.aol.com:465 with implicit SSL/TLS as its outgoing server (issue #154).
smtpPort = SMTPS_PORT,
smtpSecurity = MailSecurity.SSL_TLS,
),
;
/**
@@ -268,16 +268,18 @@ private fun providerIntro(provider: MailProvider): Int = when (provider) {
MailProvider.GMAIL -> R.string.app_password_intro_gmail
MailProvider.YAHOO -> R.string.app_password_intro_yahoo
MailProvider.ICLOUD -> R.string.app_password_intro_icloud
MailProvider.AOL -> R.string.app_password_intro_aol
}
/**
* Button copy for the two-factor prerequisite link, in each provider's own terminology — Google
* calls it "2-Step Verification", Apple "Two-Factor Authentication". Only reached for providers
* that expose [MailProvider.twoFactorHelpUrl]; Yahoo has none, so its branch here is unused.
* that expose [MailProvider.twoFactorHelpUrl]; Yahoo and AOL have none, so their branches here are
* unused.
*/
private fun twoFactorHelpLabel(provider: MailProvider): Int = when (provider) {
MailProvider.ICLOUD -> R.string.app_password_2fa_help_icloud
MailProvider.GMAIL, MailProvider.YAHOO -> R.string.app_password_2fa_help
MailProvider.GMAIL, MailProvider.YAHOO, MailProvider.AOL -> R.string.app_password_2fa_help
}
private fun MailSecurity.label(): String = when (this) {
+2 -1
View File
@@ -189,12 +189,13 @@
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
<!-- App-password guided setup (Gmail/Yahoo/iCloud) -->
<!-- App-password guided setup (Gmail/Yahoo/iCloud/AOL) -->
<string name="app_password_title">Connect %1$s</string>
<string name="app_password_unknown_provider">Unknown email provider.</string>
<string name="app_password_intro_gmail">To connect Gmail, create an app password in your Google Account. Gmail requires 2-Step Verification to be turned on before you can create one.</string>
<string name="app_password_intro_yahoo">To connect Yahoo Mail, generate an app password from your Yahoo Account security settings.</string>
<string name="app_password_intro_icloud">To connect iCloud Mail, create an app-specific password from your Apple ID account page.</string>
<string name="app_password_intro_aol">To connect AOL Mail, generate an app password from your AOL Account security settings.</string>
<string name="app_password_what_is">An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code.</string>
<string name="app_password_warning">Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.</string>
<string name="app_password_open_page">Create an app password for %1$s</string>
@@ -54,6 +54,19 @@ class MailProviderTest {
assertEquals(MailSecurity.STARTTLS, account.smtp.security)
}
@Test
fun `aol preset uses documented imap and implicit-tls smtp endpoints`() {
val account = MailProvider.AOL.createAccount("user@aol.com")
assertEquals("imap.aol.com", account.imap.host)
assertEquals(993, account.imap.port)
assertEquals(MailSecurity.SSL_TLS, account.imap.security)
assertEquals("smtp.aol.com", account.smtp.host)
assertEquals(465, account.smtp.port)
assertEquals(MailSecurity.SSL_TLS, account.smtp.security)
}
@Test
fun `no provider ever uses insecure transport`() {
MailProvider.entries.forEach { provider ->
@@ -82,7 +95,7 @@ class MailProviderTest {
}
@Test
fun `gmail and icloud link two-factor setup help over https, yahoo does not`() {
fun `gmail and icloud link two-factor setup help over https, yahoo and aol do not`() {
// Gmail's app-passwords page rejects accounts without 2-Step Verification, so its setup
// screen must offer the setup article as a way out (issue #98).
val gmailUrl = assertNotNull(
@@ -100,6 +113,11 @@ class MailProviderTest {
// which never list two-step verification as a prerequisite for generating an app password
// (issue #155) — so it must not grow the extra link.
assertNull(MailProvider.YAHOO.twoFactorHelpUrl)
// AOL gates nothing on two-factor either — its app-password article never lists two-step
// verification as a prerequisite, and its separate two-step-verification article doesn't
// call itself one either (issue #156) — so it must not grow the extra link.
assertNull(MailProvider.AOL.twoFactorHelpUrl)
}
@Test
@@ -115,6 +133,14 @@ class MailProviderTest {
)
}
@Test
fun `aol app-password help points at AOL's dedicated app-password article`() {
assertEquals(
"https://help.aol.com/articles/Create-and-manage-app-password",
MailProvider.AOL.appPasswordHelpUrl,
)
}
@Test
fun `createAccount trims the email and derives a stable id and display name`() {
val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ")
@@ -136,6 +162,7 @@ class MailProviderTest {
assertEquals(MailProvider.GMAIL, MailProvider.fromKey("gmail"))
assertEquals(MailProvider.YAHOO, MailProvider.fromKey("YAHOO"))
assertEquals(MailProvider.ICLOUD, MailProvider.fromKey("iCloud"))
assertEquals(MailProvider.AOL, MailProvider.fromKey("AOL"))
assertNull(MailProvider.fromKey("outlook"))
assertNull(MailProvider.fromKey(""))
}
@@ -163,6 +190,9 @@ class MailProviderTest {
assertEquals(MailProvider.GMAIL, MailProvider.forImapHost("imap.googlemail.com"))
assertEquals(MailProvider.GMAIL, MailProvider.forImapHost("IMAP.GMAIL.COM"))
assertEquals(MailProvider.ICLOUD, MailProvider.forImapHost("imap.mail.me.com"))
// A manually configured AOL account (set up before this preset existed) must still resolve
// to the AOL brand (issue #156).
assertEquals(MailProvider.AOL, MailProvider.forImapHost("imap.aol.com"))
assertNull(MailProvider.forImapHost("imap.example.org"))
}
@@ -176,6 +206,7 @@ class MailProviderTest {
fun `brandFor centralizes host to brand matching, including Outlook`() {
assertEquals("Gmail", MailProvider.brandFor(account("imap.googlemail.com")))
assertEquals("iCloud Mail", MailProvider.brandFor(account("imap.mail.me.com")))
assertEquals("AOL Mail", MailProvider.brandFor(account("imap.aol.com")))
// A manually configured Office 365 host is Outlook even without the OAuth auth type.
assertEquals(MailProvider.OUTLOOK_BRAND, MailProvider.brandFor(account("outlook.office365.com")))
// The OAuth auth type brands as Outlook regardless of host.