Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 34eb546e authored by Jonathan Klee's avatar Jonathan Klee
Browse files

feat(branding): ship as Murena Mail under foundation.e.mail.ng

parent d132f0bb
Loading
Loading
Loading
Loading
Loading

.gitlab-ci.yml

0 → 100644
+83 −0
Original line number Diff line number Diff line
image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest"

stages:
  - build
  - rebase-on-upstream

variables:
  UPSTREAM_URL: https://github.com/thunderbird/thunderbird-android.git
  UPSTREAM_DEFAULT_BRANCH: main
  NG_BRANCH: main-ng
  GRADLE_BUILD_TASKS: ":app-k9mail:assembleFossDebug :app-thunderbird:assembleFossDebug"
  JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64

before_script:
  - export PATH="$JAVA_HOME/bin:$PATH"
  - export GRADLE_USER_HOME=$(pwd)/.gradle
  - chmod +x ./gradlew
  - java -version

cache:
  key: ${CI_PROJECT_ID}
  paths:
    - .gradle/

build:
  stage: build
  rules:
    - if: '$CI_PIPELINE_SOURCE != "schedule"'
  script:
    - ./gradlew $GRADLE_BUILD_TASKS --no-daemon

rebase-on-upstream:
  stage: rebase-on-upstream
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_REF_NAME == $NG_BRANCH'
  variables:
    CI_PROJECT_SSH_URL: git@gitlab.e.foundation:$CI_PROJECT_PATH
    GIT_STRATEGY: none
  before_script:
    - eval $(ssh-agent -s)
    - echo "${SSH_E_ROBOT_PRIVATE_KEY}" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - git config --global user.email $GITLAB_USER_EMAIL
    - git config --global user.name "$GITLAB_USER_NAME"
    - cd $CI_BUILDS_DIR
    - rm -rf $CI_PROJECT_DIR
    - git clone $CI_PROJECT_SSH_URL $CI_PROJECT_DIR
    - cd $CI_PROJECT_DIR
    - export PATH="$JAVA_HOME/bin:$PATH"
    - export GRADLE_USER_HOME=$(pwd)/.gradle
    - chmod +x ./gradlew
    - java -version
  script:
    - git checkout $NG_BRANCH
    - PRE_REBASE_SHA="$(git rev-parse HEAD)"
    - git remote add upstream $UPSTREAM_URL
    - git fetch upstream $UPSTREAM_DEFAULT_BRANCH
    - UPSTREAM_SHA="$(git rev-parse upstream/$UPSTREAM_DEFAULT_BRANCH)"
    - |
      if ! git rebase upstream/$UPSTREAM_DEFAULT_BRANCH; then
        git rebase --abort
        echo "Rebase onto $UPSTREAM_SHA failed; main-ng untouched."
        exit 1
      fi
    - |
      if ! ./gradlew $GRADLE_BUILD_TASKS --no-daemon; then
        echo "Build failed after rebase; not force-pushing main-ng."
        exit 1
      fi
    - POST_REBASE_SHA="$(git rev-parse HEAD)"
    - git push --force-with-lease origin "$NG_BRANCH"
    - |
      if [ "$POST_REBASE_SHA" != "$PRE_REBASE_SHA" ]; then
        SNAPSHOT_TAG="main-ng-snapshots/$(date -u +%Y-%m-%d_%H%M%S)"
        git tag -a "$SNAPSHOT_TAG" -m "main-ng after rebase onto upstream/$UPSTREAM_DEFAULT_BRANCH @ $UPSTREAM_SHA"
        git push origin "$SNAPSHOT_TAG"
      else
        echo "main-ng unchanged after rebase (upstream had no new commits); no snapshot tag created."
      fi
    - git remote remove upstream
+2 −0
Original line number Diff line number Diff line
@@ -43,6 +43,8 @@ dependencies {
    implementation(projects.feature.account.avatar.impl)
    implementation(projects.feature.account.settings.api)
    implementation(projects.feature.account.setup)
    implementation(projects.feature.account.common)
    implementation(projects.core.common)
    implementation(projects.feature.mail.account.api)
    implementation(projects.feature.mail.message.composer)
    implementation(projects.feature.migration.provider)
+85 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 */

package net.thunderbird.app.common.murena

import android.accounts.AccountManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.fsck.k9.Preferences
import com.fsck.k9.account.AccountRemoverWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class MurenaAccountReceiver : BroadcastReceiver(), KoinComponent {

    private val preferences: Preferences by inject()
    private val provisioner: MurenaMailAccountProvisioner by inject()
    private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onReceive(context: Context?, intent: Intent?) {
        if (context == null || intent == null) {
            return
        }

        val accountName = intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) ?: return
        val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)
        if (accountType != MURENA_NG_ACCOUNT_TYPE) {
            return
        }

        when (intent.action) {
            ACTION_MURENA_ACCOUNT_ADDED -> {
                Log.i(TAG, "Murena Workspace account added: $accountName")
                val pendingResult = goAsync()
                coroutineScope.launch {
                    try {
                        provisioner.provisionIfMissing(accountName)
                    } finally {
                        pendingResult.finish()
                    }
                }
            }

            ACTION_ACCOUNT_REMOVED -> {
                removeMatchingMailAccount(context.applicationContext, accountName)
            }
        }
    }

    private fun removeMatchingMailAccount(appContext: Context, email: String) {
        val matches = preferences.getAccounts().filter { it.email.equals(email, ignoreCase = true) }
        if (matches.isEmpty()) {
            Log.i(TAG, "No K-9 account matches Murena account $email")
            return
        }

        matches.forEach { account ->
            Log.i(TAG, "Removing K-9 account ${account.uuid} that mirrored Murena account $email")
            AccountRemoverWorker.enqueueRemoveAccountWorker(appContext, account.uuid)
        }
    }

    private companion object {
        const val TAG = "MurenaAccountReceiver"
        const val MURENA_NG_ACCOUNT_TYPE = "e.foundation.webdav.eelo.ng"
        const val ACTION_MURENA_ACCOUNT_ADDED = "foundation.e.accountmanager.action.MURENA_ACCOUNT_ADDED"
        const val ACTION_ACCOUNT_REMOVED = "foundation.e.accountmanager.action.ACCOUNT_REMOVED"
    }
}
+125 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 */

package net.thunderbird.app.common.murena

import android.accounts.AccountManager
import android.content.Context
import android.util.Log
import app.k9mail.feature.account.common.domain.entity.Account
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.setup.AccountSetupExternalContract
import com.fsck.k9.Preferences
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import net.thunderbird.core.common.mail.Protocols
import java.util.UUID

class MurenaMailAccountProvisioner(
    private val context: Context,
    private val accountCreator: AccountSetupExternalContract.AccountCreator,
    private val preferences: Preferences,
    private val messagingController: MessagingController,
) {

    suspend fun provisionExistingMurenaAccounts() {
        val accountManager = AccountManager.get(context)
        val ngAccounts = accountManager.getAccountsByType(MURENA_NG_ACCOUNT_TYPE)
        if (ngAccounts.isEmpty()) {
            return
        }

        Log.i(TAG, "Found ${ngAccounts.size} Murena Workspace account(s); ensuring K-9 mirror exists")
        ngAccounts.forEach { ngAccount -> provisionIfMissing(ngAccount.name) }
    }

    suspend fun provisionIfMissing(email: String) {
        val existing = preferences.getAccounts().firstOrNull { it.email.equals(email, ignoreCase = true) }
        if (existing != null) {
            Log.i(TAG, "K-9 account already exists for $email; skipping provisioning")
            return
        }

        val account = buildMurenaAccount(email)
        when (val result = accountCreator.createAccount(account)) {
            is AccountSetupExternalContract.AccountCreator.AccountCreatorResult.Success -> {
                Log.i(TAG, "Provisioned K-9 account ${result.accountUuid} for $email")
                triggerInitialFetch(result.accountUuid)
            }

            is AccountSetupExternalContract.AccountCreator.AccountCreatorResult.Error ->
                Log.w(TAG, "Failed to provision K-9 account for $email: ${result.message}")
        }
    }

    private fun triggerInitialFetch(accountUuid: String) {
        val account = preferences.getAccount(accountUuid) ?: return
        Log.i(TAG, "Kicking off initial mail check for $accountUuid")
        messagingController.checkMail(
            /* account = */ account,
            /* ignoreLastCheckedTime = */ true,
            /* useManualWakeLock = */ true,
            /* notify = */ false,
            /* listener = */ null,
        )
    }

    private fun buildMurenaAccount(email: String): Account {
        return Account(
            uuid = UUID.randomUUID().toString(),
            emailAddress = email,
            incomingServerSettings = ServerSettings(
                type = Protocols.IMAP,
                host = MURENA_MAIL_HOST,
                port = MURENA_IMAP_PORT,
                connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
                authenticationType = AuthType.XOAUTH2,
                username = email,
                password = null,
                clientCertificateAlias = null,
            ),
            outgoingServerSettings = ServerSettings(
                type = Protocols.SMTP,
                host = MURENA_MAIL_HOST,
                port = MURENA_SMTP_PORT,
                connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
                authenticationType = AuthType.XOAUTH2,
                username = email,
                password = null,
                clientCertificateAlias = null,
            ),
            authorizationState = null,
            specialFolderSettings = null,
            options = AccountOptions(
                accountName = email,
                displayName = email.substringBefore("@"),
                emailSignature = null,
                checkFrequencyInMinutes = DEFAULT_CHECK_FREQUENCY_MINUTES,
                messageDisplayCount = DEFAULT_MESSAGE_DISPLAY_COUNT,
                showNotification = true,
            ),
        )
    }

    private companion object {
        const val TAG = "MurenaMailProvisioner"
        const val MURENA_NG_ACCOUNT_TYPE = "e.foundation.webdav.eelo.ng"
        const val MURENA_MAIL_HOST = "mail.ecloud.global"
        const val MURENA_IMAP_PORT = 993
        const val MURENA_SMTP_PORT = 465
        const val DEFAULT_CHECK_FREQUENCY_MINUTES = 15
        const val DEFAULT_MESSAGE_DISPLAY_COUNT = 25
    }
}
+28 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2026
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 */

package net.thunderbird.app.common.murena

import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

val murenaModule = module {
    single {
        MurenaMailAccountProvisioner(
            context = androidContext(),
            accountCreator = get(),
            preferences = get(),
            messagingController = get(),
        )
    }
}
Loading