Added Outlook graph send

pull/212/head
M66B 2 years ago
parent 106f976f1b
commit ad83451220

@ -19,6 +19,7 @@ package eu.faircode.email;
Copyright 2018-2023 by Marcel Bokhorst (M66B) Copyright 2018-2023 by Marcel Bokhorst (M66B)
*/ */
import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_OAUTH;
import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_PASSWORD; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_PASSWORD;
import android.content.Context; import android.content.Context;
@ -137,6 +138,9 @@ public class AdapterIdentity extends RecyclerView.Adapter<AdapterIdentity.ViewHo
ivSync.setContentDescription(context.getString(identity.synchronize ? R.string.title_legend_synchronize_on : R.string.title_legend_synchronize_off)); ivSync.setContentDescription(context.getString(identity.synchronize ? R.string.title_legend_synchronize_on : R.string.title_legend_synchronize_off));
ivOAuth.setVisibility(identity.auth_type == AUTH_TYPE_PASSWORD ? View.GONE : View.VISIBLE); ivOAuth.setVisibility(identity.auth_type == AUTH_TYPE_PASSWORD ? View.GONE : View.VISIBLE);
ivOAuth.setImageResource(identity.auth_type == AUTH_TYPE_OAUTH
? R.drawable.twotone_security_24
: R.drawable.twotone_show_chart_24);
ivPrimary.setVisibility(identity.primary ? View.VISIBLE : View.GONE); ivPrimary.setVisibility(identity.primary ? View.VISIBLE : View.GONE);
ivGroup.setVisibility(identity.self ? View.GONE : View.VISIBLE); ivGroup.setVisibility(identity.self ? View.GONE : View.VISIBLE);
tvName.setText(identity.getDisplayName()); tvName.setText(identity.getDisplayName());

@ -99,6 +99,7 @@ public class EmailProvider implements Parcelable {
public Server smtp = new Server(); public Server smtp = new Server();
public Server pop; public Server pop;
public OAuth oauth; public OAuth oauth;
public OAuth graph;
public UserType user = UserType.EMAIL; public UserType user = UserType.EMAIL;
public String username; public String username;
public StringBuilder documentation; // html public StringBuilder documentation; // html
@ -305,6 +306,19 @@ public class EmailProvider implements Parcelable {
provider.oauth.redirectUri = xml.getAttributeValue(null, "redirectUri"); provider.oauth.redirectUri = xml.getAttributeValue(null, "redirectUri");
provider.oauth.privacy = xml.getAttributeValue(null, "privacy"); provider.oauth.privacy = xml.getAttributeValue(null, "privacy");
provider.oauth.prompt = xml.getAttributeValue(null, "prompt"); provider.oauth.prompt = xml.getAttributeValue(null, "prompt");
} else if ("graph".equals(name)) {
provider.graph = new OAuth();
provider.graph.enabled = getAttributeBooleanValue(xml, "enabled", false);
provider.graph.askAccount = getAttributeBooleanValue(xml, "askAccount", false);
provider.graph.clientId = xml.getAttributeValue(null, "clientId");
provider.graph.clientSecret = xml.getAttributeValue(null, "clientSecret");
provider.graph.scopes = xml.getAttributeValue(null, "scopes").split(",");
provider.graph.authorizationEndpoint = xml.getAttributeValue(null, "authorizationEndpoint");
provider.graph.tokenEndpoint = xml.getAttributeValue(null, "tokenEndpoint");
provider.graph.tokenScopes = getAttributeBooleanValue(xml, "tokenScopes", false);
provider.graph.redirectUri = xml.getAttributeValue(null, "redirectUri");
provider.graph.privacy = xml.getAttributeValue(null, "privacy");
provider.graph.prompt = xml.getAttributeValue(null, "prompt");
} else if ("parameter".equals(name)) { } else if ("parameter".equals(name)) {
if (provider.oauth.parameters == null) if (provider.oauth.parameters == null)
provider.oauth.parameters = new LinkedHashMap<>(); provider.oauth.parameters = new LinkedHashMap<>();

@ -181,7 +181,7 @@ public class EntityAccount extends EntityOrder implements Serializable {
} }
static boolean isOutlook(String id) { static boolean isOutlook(String id) {
return ("office365".equals(id) || "office365pcke".equals(id) || "outlook".equals(id) || "outlooktest".equals(id)); return ("office365".equals(id) || "office365pcke".equals(id) || "outlook".equals(id) || "outlookgraph".equals(id));
} }
boolean isYahooJp() { boolean isYahooJp() {

@ -20,6 +20,7 @@ package eu.faircode.email;
*/ */
import static android.app.Activity.RESULT_OK; import static android.app.Activity.RESULT_OK;
import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_GRAPH;
import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_OAUTH; import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_OAUTH;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
@ -239,7 +240,7 @@ public class FragmentOAuth extends FragmentBase {
btnOAuth.setOnClickListener(new View.OnClickListener() { btnOAuth.setOnClickListener(new View.OnClickListener() {
@Override @Override
public void onClick(View v) { public void onClick(View v) {
onAuthorize(); onAuthorize(false);
} }
}); });
@ -309,7 +310,7 @@ public class FragmentOAuth extends FragmentBase {
} }
} }
private void onAuthorize() { private void onAuthorize(boolean graph) {
try { try {
if (askAccount) { if (askAccount) {
String name = etName.getText().toString().trim(); String name = etName.getText().toString().trim();
@ -350,6 +351,7 @@ public class FragmentOAuth extends FragmentBase {
final Context context = getContext(); final Context context = getContext();
PackageManager pm = context.getPackageManager(); PackageManager pm = context.getPackageManager();
EmailProvider provider = EmailProvider.getProvider(context, id); EmailProvider provider = EmailProvider.getProvider(context, id);
EmailProvider.OAuth oauth = (graph ? provider.graph : provider.oauth);
int flags = PackageManager.GET_RESOLVED_FILTER; int flags = PackageManager.GET_RESOLVED_FILTER;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
@ -416,8 +418,8 @@ public class FragmentOAuth extends FragmentBase {
AuthorizationService authService = new AuthorizationService(context, appAuthConfig); AuthorizationService authService = new AuthorizationService(context, appAuthConfig);
String authorizationEndpoint = provider.oauth.authorizationEndpoint; String authorizationEndpoint = oauth.authorizationEndpoint;
String tokenEndpoint = provider.oauth.tokenEndpoint; String tokenEndpoint = oauth.tokenEndpoint;
String tenant = etTenant.getText().toString().trim(); String tenant = etTenant.getText().toString().trim();
if (TextUtils.isEmpty(tenant)) if (TextUtils.isEmpty(tenant))
@ -432,14 +434,15 @@ public class FragmentOAuth extends FragmentBase {
AuthState authState = new AuthState(serviceConfig); AuthState authState = new AuthState(serviceConfig);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().putString("oauth." + provider.id, authState.jsonSerializeString()).apply(); String key = "oauth." + provider.id + (graph ? ":graph" : "");
prefs.edit().putString(key, authState.jsonSerializeString()).apply();
Map<String, String> params = (provider.oauth.parameters == null Map<String, String> params = (oauth.parameters == null
? new LinkedHashMap<>() ? new LinkedHashMap<>()
: provider.oauth.parameters); : oauth.parameters);
String clientId = provider.oauth.clientId; String clientId = oauth.clientId;
Uri redirectUri = Uri.parse(provider.oauth.redirectUri); Uri redirectUri = Uri.parse(oauth.redirectUri);
if ("gmail".equals(id) && BuildConfig.DEBUG && false) { if ("gmail".equals(id) && BuildConfig.DEBUG && false) {
clientId = "803253368361-hr8kelm53hqodj7c6brdjeb2ctn5jg3p.apps.googleusercontent.com"; clientId = "803253368361-hr8kelm53hqodj7c6brdjeb2ctn5jg3p.apps.googleusercontent.com";
redirectUri = Uri.parse("eu.faircode.email.debug:/"); redirectUri = Uri.parse("eu.faircode.email.debug:/");
@ -452,8 +455,8 @@ public class FragmentOAuth extends FragmentBase {
clientId, clientId,
ResponseTypeValues.CODE, ResponseTypeValues.CODE,
redirectUri) redirectUri)
.setScopes(provider.oauth.scopes) .setScopes(oauth.scopes)
.setState(provider.id) .setState(provider.id + (graph ? ":graph" : ""))
.setAdditionalParameters(params); .setAdditionalParameters(params);
if (askAccount) { if (askAccount) {
@ -465,8 +468,8 @@ public class FragmentOAuth extends FragmentBase {
authRequestBuilder.setLoginHint(address); authRequestBuilder.setLoginHint(address);
} }
if (!TextUtils.isEmpty(provider.oauth.prompt)) if (!TextUtils.isEmpty(oauth.prompt))
authRequestBuilder.setPrompt(provider.oauth.prompt); authRequestBuilder.setPrompt(oauth.prompt);
AuthorizationRequest authRequest = authRequestBuilder.build(); AuthorizationRequest authRequest = authRequestBuilder.build();
@ -509,26 +512,26 @@ public class FragmentOAuth extends FragmentBase {
throw ex; throw ex;
} }
final EmailProvider provider = EmailProvider.getProvider(getContext(), auth.state); String id = auth.state.split(":")[0];
final EmailProvider provider = EmailProvider.getProvider(getContext(), id);
EmailProvider.OAuth oauth = (auth.state.endsWith(":graph") ? provider.graph : provider.oauth);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
String json = prefs.getString("oauth." + provider.id, null); String json = prefs.getString("oauth." + auth.state, null);
prefs.edit().remove("oauth." + provider.id).apply(); prefs.edit().remove("oauth." + auth.state).apply();
final AuthState authState = AuthState.jsonDeserialize(json); final AuthState authState = AuthState.jsonDeserialize(json);
Log.i("OAuth get token provider=" + provider.id); Log.i("OAuth get token provider=" + provider.id + " state=" + auth.state);
authState.update(auth, null); authState.update(auth, null);
if (BuildConfig.DEBUG)
Log.i("OAuth response=" + authState.jsonSerializeString());
AuthorizationService authService = new AuthorizationService(getContext()); AuthorizationService authService = new AuthorizationService(getContext());
ClientAuthentication clientAuth; ClientAuthentication clientAuth;
if (provider.oauth.clientSecret == null) if (oauth.clientSecret == null)
clientAuth = NoClientAuthentication.INSTANCE; clientAuth = NoClientAuthentication.INSTANCE;
else else
clientAuth = new ClientSecretPost(provider.oauth.clientSecret); clientAuth = new ClientSecretPost(oauth.clientSecret);
TokenRequest.Builder builder = new TokenRequest.Builder( TokenRequest.Builder builder = new TokenRequest.Builder(
auth.request.configuration, auth.request.configuration,
@ -540,8 +543,8 @@ public class FragmentOAuth extends FragmentBase {
.setAdditionalParameters(Collections.<String, String>emptyMap()) .setAdditionalParameters(Collections.<String, String>emptyMap())
.setNonce(auth.request.nonce); .setNonce(auth.request.nonce);
if (provider.oauth.tokenScopes) if (oauth.tokenScopes)
builder.setScope(TextUtils.join(" ", provider.oauth.scopes)); builder.setScope(TextUtils.join(" ", oauth.scopes));
TokenRequest request = builder.build(); TokenRequest request = builder.build();
@ -555,17 +558,36 @@ public class FragmentOAuth extends FragmentBase {
if (access == null) if (access == null)
throw error; throw error;
Log.i("OAuth got token provider=" + provider.id); String[] scopes = access.getScopeSet().toArray(new String[0]);
if (BuildConfig.DEBUG) Log.i("OAuth got token provider=" + provider.id +
Log.i("TokenResponse=" + access.jsonSerializeString()); " state=" + auth.state +
authState.update(access, null); " scopes=" + TextUtils.join(",", scopes));
if (BuildConfig.DEBUG)
Log.i("OAuth response=" + authState.jsonSerializeString());
if (TextUtils.isEmpty(access.refreshToken)) if (TextUtils.isEmpty(access.refreshToken))
throw new IllegalStateException("No refresh token"); throw new IllegalStateException("No refresh token");
onOAuthorized(access.accessToken, access.idToken, authState); authState.update(access, null);
if (provider.graph == null || !provider.graph.enabled)
onOAuthorized(
new String[]{access.accessToken},
new String[]{access.idToken},
new AuthState[]{authState});
else {
if (auth.state.endsWith(":graph")) {
String key0 = "oauth." + provider.id;
String json0 = prefs.getString(key0, null);
prefs.edit().remove(key0).apply();
AuthState state0 = AuthState.jsonDeserialize(json0);
onOAuthorized(
new String[]{state0.getAccessToken(), authState.getAccessToken()},
new String[]{state0.getIdToken(), authState.getIdToken()},
new AuthState[]{state0, authState});
} else {
prefs.edit().putString("oauth." + provider.id, authState.jsonSerializeString()).apply();
onAuthorize(true);
}
}
} catch (Throwable ex) { } catch (Throwable ex) {
showError(ex); showError(ex);
} }
@ -576,18 +598,22 @@ public class FragmentOAuth extends FragmentBase {
} }
} }
private void onOAuthorized(String accessToken, String idToken, AuthState state) { private void onOAuthorized(String[] accessToken, String[] idToken, AuthState[] state) {
Log.breadcrumb("onOAuthorized", "id", id); Log.breadcrumb("onOAuthorized", "id", id);
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED))
return; return;
List<String> states = new ArrayList<>();
for (AuthState s : state)
states.add(s.jsonSerializeString());
Bundle args = new Bundle(); Bundle args = new Bundle();
args.putString("id", id); args.putString("id", id);
args.putString("name", name); args.putString("name", name);
args.putString("token", accessToken); args.putStringArray("token", accessToken);
args.putString("jwt", idToken); args.putStringArray("jwt", idToken);
args.putString("state", state.jsonSerializeString()); args.putStringArray("state", states.toArray(new String[0]));
args.putBoolean("askAccount", askAccount); args.putBoolean("askAccount", askAccount);
args.putString("personal", etName.getText().toString().trim()); args.putString("personal", etName.getText().toString().trim());
args.putString("address", etEmail.getText().toString().trim()); args.putString("address", etEmail.getText().toString().trim());
@ -611,9 +637,9 @@ public class FragmentOAuth extends FragmentBase {
protected Void onExecute(Context context, Bundle args) throws Throwable { protected Void onExecute(Context context, Bundle args) throws Throwable {
String id = args.getString("id"); String id = args.getString("id");
String name = args.getString("name"); String name = args.getString("name");
String token = args.getString("token"); String[] token = args.getStringArray("token");
String jwt = args.getString("jwt"); String[] jwt = args.getStringArray("jwt");
String state = args.getString("state"); String[] state = args.getStringArray("state");
boolean askAccount = args.getBoolean("askAccount", false); boolean askAccount = args.getBoolean("askAccount", false);
String personal = args.getString("personal"); String personal = args.getString("personal");
String address = args.getString("address"); String address = args.getString("address");
@ -653,12 +679,10 @@ public class FragmentOAuth extends FragmentBase {
usernames.add(sharedname == null ? username : sharedname); usernames.add(sharedname == null ? username : sharedname);
EntityLog.log(context, "OAuth id=" + id + " user=" + username + " shared=" + sharedname); EntityLog.log(context, "OAuth id=" + id + " user=" + username + " shared=" + sharedname);
EntityLog.log(context, "OAuth token=" + token);
EntityLog.log(context, "OAuth jwt=" + jwt);
if (token != null && sharedname == null && !"gmail".equals(id)) { if (token[0] != null && sharedname == null && !"gmail".equals(id)) {
// https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens // https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
String[] segments = token.split("\\."); String[] segments = token[0].split("\\.");
if (segments.length > 1) if (segments.length > 1)
try { try {
String payload = new String(Base64.decode(segments[1], Base64.DEFAULT)); String payload = new String(Base64.decode(segments[1], Base64.DEFAULT));
@ -687,9 +711,9 @@ public class FragmentOAuth extends FragmentBase {
} }
} }
if (jwt != null && sharedname == null) { if (jwt[0] != null && sharedname == null) {
// https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens // https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens
String[] segments = jwt.split("\\."); String[] segments = jwt[0].split("\\.");
if (segments.length > 1) if (segments.length > 1)
try { try {
// https://jwt.ms/ // https://jwt.ms/
@ -756,17 +780,19 @@ public class FragmentOAuth extends FragmentBase {
aservice.connect( aservice.connect(
inbound.host, inbound.port, inbound.host, inbound.port,
AUTH_TYPE_OAUTH, provider.id, AUTH_TYPE_OAUTH, provider.id,
alt, state, alt, state[0],
null, null); null, null);
} }
try (EmailService iservice = new EmailService( if (state.length == 1) {
context, iprotocol, null, iencryption, false, false, try (EmailService iservice = new EmailService(
EmailService.PURPOSE_CHECK, true)) { context, iprotocol, null, iencryption, false, false,
iservice.connect( EmailService.PURPOSE_CHECK, true)) {
provider.smtp.host, provider.smtp.port, iservice.connect(
AUTH_TYPE_OAUTH, provider.id, provider.smtp.host, provider.smtp.port,
alt, state, AUTH_TYPE_OAUTH, provider.id,
null, null); alt, state[0],
null, null);
}
} }
EntityLog.log(context, "Using username=" + alt); EntityLog.log(context, "Using username=" + alt);
username = alt; username = alt;
@ -781,7 +807,7 @@ public class FragmentOAuth extends FragmentBase {
if (askAccount) if (askAccount)
identities.add(new Pair<>(username, personal)); identities.add(new Pair<>(username, personal));
else if ("mailru".equals(id)) { else if ("mailru".equals(id)) {
URL url = new URL("https://oauth.mail.ru/userinfo?access_token=" + token); URL url = new URL("https://oauth.mail.ru/userinfo?access_token=" + token[0]);
Log.i("GET " + url); Log.i("GET " + url);
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("GET"); connection.setRequestMethod("GET");
@ -829,7 +855,7 @@ public class FragmentOAuth extends FragmentBase {
aservice.connect( aservice.connect(
inbound.host, inbound.port, inbound.host, inbound.port,
AUTH_TYPE_OAUTH, provider.id, AUTH_TYPE_OAUTH, provider.id,
sharedname == null ? username : sharedname, state, sharedname == null ? username : sharedname, state[0],
null, null); null, null);
if (pop) if (pop)
@ -839,7 +865,7 @@ public class FragmentOAuth extends FragmentBase {
} }
Long max_size = null; Long max_size = null;
if (!inbound_only) { if (!inbound_only && state.length == 1) {
EntityLog.log(context, "OAuth checking SMTP provider=" + provider.id); EntityLog.log(context, "OAuth checking SMTP provider=" + provider.id);
try (EmailService iservice = new EmailService( try (EmailService iservice = new EmailService(
@ -848,7 +874,7 @@ public class FragmentOAuth extends FragmentBase {
iservice.connect( iservice.connect(
provider.smtp.host, provider.smtp.port, provider.smtp.host, provider.smtp.port,
AUTH_TYPE_OAUTH, provider.id, AUTH_TYPE_OAUTH, provider.id,
username, state, username, state[0],
null, null); null, null);
max_size = iservice.getMaxSize(); max_size = iservice.getMaxSize();
} }
@ -881,7 +907,7 @@ public class FragmentOAuth extends FragmentBase {
account.auth_type = AUTH_TYPE_OAUTH; account.auth_type = AUTH_TYPE_OAUTH;
account.provider = provider.id; account.provider = provider.id;
account.user = (sharedname == null ? username : sharedname); account.user = (sharedname == null ? username : sharedname);
account.password = state; account.password = state[0];
int at = account.user.indexOf('@'); int at = account.user.indexOf('@');
String user = account.user.substring(0, at); String user = account.user.substring(0, at);
@ -943,10 +969,10 @@ public class FragmentOAuth extends FragmentBase {
ident.host = provider.smtp.host; ident.host = provider.smtp.host;
ident.encryption = iencryption; ident.encryption = iencryption;
ident.port = provider.smtp.port; ident.port = provider.smtp.port;
ident.auth_type = AUTH_TYPE_OAUTH; ident.auth_type = (state.length == 1 ? AUTH_TYPE_OAUTH : AUTH_TYPE_GRAPH);
ident.provider = provider.id; ident.provider = provider.id;
ident.user = username; ident.user = username;
ident.password = state; ident.password = state[state.length - 1];
ident.use_ip = provider.useip; ident.use_ip = provider.useip;
ident.synchronize = true; ident.synchronize = true;
ident.primary = ident.user.equals(ident.email); ident.primary = ident.user.equals(ident.email);
@ -961,8 +987,12 @@ public class FragmentOAuth extends FragmentBase {
args.putLong("account", update.id); args.putLong("account", update.id);
EntityLog.log(context, "OAuth update account=" + update.name); EntityLog.log(context, "OAuth update account=" + update.name);
db.account().setAccountSynchronize(update.id, true); db.account().setAccountSynchronize(update.id, true);
db.account().setAccountPassword(update.id, state, AUTH_TYPE_OAUTH, provider.id); db.account().setAccountPassword(update.id, state[0], AUTH_TYPE_OAUTH, provider.id);
db.identity().setIdentityPassword(update.id, username, state, update.auth_type, AUTH_TYPE_OAUTH, provider.id); db.identity().setIdentityPassword(update.id, username,
state[state.length - 1],
update.auth_type,
(state.length == 1 ? AUTH_TYPE_OAUTH : AUTH_TYPE_GRAPH),
provider.id);
} }
db.setTransactionSuccessful(); db.setTransactionSuccessful();

@ -2106,6 +2106,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
for (String key : prefs.getAll().keySet()) for (String key : prefs.getAll().keySet())
if ((!BuildConfig.DEBUG && if ((!BuildConfig.DEBUG &&
key.startsWith("translated_") && cbGeneral.isChecked()) || key.startsWith("translated_") && cbGeneral.isChecked()) ||
key.startsWith("oauth.") ||
(key.startsWith("announcement.") && cbGeneral.isChecked()) || (key.startsWith("announcement.") && cbGeneral.isChecked()) ||
(key.endsWith(".show_full") && cbFull.isChecked()) || (key.endsWith(".show_full") && cbFull.isChecked()) ||
(key.endsWith(".show_images") && cbImages.isChecked()) || (key.endsWith(".show_images") && cbImages.isChecked()) ||

@ -348,11 +348,6 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show(); .show();
return true; return true;
} else if (itemId == R.string.title_setup_other ||
itemId == R.string.title_setup_outlook) {
lbm.sendBroadcast(new Intent(ActivitySetup.ACTION_QUICK_SETUP)
.putExtra("title", itemId));
return true;
} else if (itemId == R.string.title_setup_classic) { } else if (itemId == R.string.title_setup_classic) {
ibManual.setPressed(true); ibManual.setPressed(true);
ibManual.setPressed(false); ibManual.setPressed(false);
@ -429,9 +424,6 @@ public class FragmentSetup extends FragmentBase implements SharedPreferences.OnS
int resid = res.getIdentifier("provider_" + provider.id, "drawable", pkg); int resid = res.getIdentifier("provider_" + provider.id, "drawable", pkg);
if (resid != 0) if (resid != 0)
item.setIcon(resid); item.setIcon(resid);
if ("office365pcke".equals(provider.id))
menu.add(alt ? Menu.FIRST : Menu.NONE, R.string.title_setup_outlook, order++, R.string.title_setup_outlook);
} }
return order; return order;

@ -63,6 +63,7 @@ public class ServiceAuthenticator extends Authenticator {
static final int AUTH_TYPE_PASSWORD = 1; static final int AUTH_TYPE_PASSWORD = 1;
static final int AUTH_TYPE_GMAIL = 2; static final int AUTH_TYPE_GMAIL = 2;
static final int AUTH_TYPE_OAUTH = 3; static final int AUTH_TYPE_OAUTH = 3;
static final int AUTH_TYPE_GRAPH = 4;
static final long MIN_REFRESH_INTERVAL = 15 * 60 * 1000L; static final long MIN_REFRESH_INTERVAL = 15 * 60 * 1000L;
static final long MIN_FORCE_REFRESH_INTERVAL = 15 * 60 * 1000L; static final long MIN_FORCE_REFRESH_INTERVAL = 15 * 60 * 1000L;
@ -155,7 +156,7 @@ public class ServiceAuthenticator extends Authenticator {
void onPasswordChanged(Context context, String newPassword); void onPasswordChanged(Context context, String newPassword);
} }
private static void OAuthRefresh(Context context, String id, String user, AuthState authState, boolean forceRefresh) static void OAuthRefresh(Context context, String id, String user, AuthState authState, boolean forceRefresh)
throws MessagingException { throws MessagingException {
try { try {
long now = new Date().getTime(); long now = new Date().getTime();

@ -19,6 +19,8 @@ package eu.faircode.email;
Copyright 2018-2023 by Marcel Bokhorst (M66B) Copyright 2018-2023 by Marcel Bokhorst (M66B)
*/ */
import static eu.faircode.email.ServiceAuthenticator.AUTH_TYPE_GRAPH;
import android.Manifest; import android.Manifest;
import android.app.AlarmManager; import android.app.AlarmManager;
import android.app.Notification; import android.app.Notification;
@ -37,6 +39,8 @@ import android.net.Uri;
import android.os.PowerManager; import android.os.PowerManager;
import android.os.SystemClock; import android.os.SystemClock;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Base64;
import android.util.Base64OutputStream;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
@ -47,10 +51,17 @@ import androidx.preference.PreferenceManager;
import com.sun.mail.smtp.SMTPSendFailedException; import com.sun.mail.smtp.SMTPSendFailedException;
import com.sun.mail.util.TraceOutputStream; import com.sun.mail.util.TraceOutputStream;
import net.openid.appauth.AuthState;
import org.json.JSONException;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
@ -91,6 +102,8 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar
private static final int RETRY_MAX = 3; private static final int RETRY_MAX = 3;
private static final int CONNECTIVITY_DELAY = 5000; // milliseconds private static final int CONNECTIVITY_DELAY = 5000; // milliseconds
private static final int PROGRESS_UPDATE_INTERVAL = 1000; // milliseconds private static final int PROGRESS_UPDATE_INTERVAL = 1000; // milliseconds
private static final int GRAPH_TIMEOUT = 20; // seconds
private static final String GRAPH_ENDPOINT = "https://graph.microsoft.com/v1.0/me/";
static final int PI_SEND = 1; static final int PI_SEND = 1;
static final int PI_FIX = 2; static final int PI_FIX = 2;
@ -555,7 +568,7 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar
ServiceSend.start(this); ServiceSend.start(this);
} }
private void onSend(EntityMessage message) throws MessagingException, IOException { private void onSend(EntityMessage message) throws JSONException, MessagingException, IOException {
DB db = DB.getInstance(this); DB db = DB.getInstance(this);
// Check if cancelled by user or by errors // Check if cancelled by user or by errors
@ -714,124 +727,181 @@ public class ServiceSend extends ServiceBase implements SharedPreferences.OnShar
// Create transport // Create transport
long start, end; long start, end;
Long max_size = null; Long max_size = null;
EmailService iservice = new EmailService( if (ident.auth_type == AUTH_TYPE_GRAPH) {
this, ident.getProtocol(), ident.realm, ident.encryption, ident.insecure, ident.unicode, debug); try {
try { // https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0
iservice.setUseIp(ident.use_ip, ident.ehlo); db.identity().setIdentityState(ident.id, "connecting");
if (!message.isSigned() && !message.isEncrypted())
iservice.set8BitMime(ident.octetmime); AuthState authState = AuthState.jsonDeserialize(ident.password);
ServiceAuthenticator.OAuthRefresh(ServiceSend.this, ident.provider, ident.user, authState, false);
// 0=Read receipt Long expiration = authState.getAccessTokenExpirationTime();
// 1=Delivery receipt if (expiration != null)
// 2=Read+delivery receipt EntityLog.log(ServiceSend.this, ident.user + " token expiration=" + new Date(expiration));
if (message.receipt_request != null && message.receipt_request) { String newPassword = authState.jsonSerializeString();
int receipt_type = prefs.getInt("receipt_type", 2); if (!Objects.equals(ident.password, newPassword))
if (receipt_type == 1 || receipt_type == 2) // Delivery receipt db.identity().setIdentityPassword(ident.id, newPassword);
iservice.setDsnNotify("SUCCESS,FAILURE,DELAY");
} URL url = new URL(GRAPH_ENDPOINT + "sendMail");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setReadTimeout(GRAPH_TIMEOUT * 1000);
connection.setConnectTimeout(GRAPH_TIMEOUT * 1000);
ConnectionHelper.setUserAgent(ServiceSend.this, connection);
connection.setRequestProperty("Authorization", "Bearer " + authState.getAccessToken());
connection.setRequestProperty("Content-Type", "text/plain");
connection.connect();
// Connect transport try {
db.identity().setIdentityState(ident.id, "connecting"); db.identity().setIdentityState(ident.id, "connected");
iservice.connect(ident);
if (BuildConfig.DEBUG && false) EntityLog.log(this, "Sending via Graph user=" + ident.user);
throw new IOException("Test");
db.identity().setIdentityState(ident.id, "connected"); start = new Date().getTime();
imessage.writeTo(new Base64OutputStream(connection.getOutputStream(), Base64.DEFAULT));
if (ident.max_size == null) end = new Date().getTime();
max_size = iservice.getMaxSize();
int status = connection.getResponseCode();
List<Address> recipients = new ArrayList<>(); if (status == HttpURLConnection.HTTP_ACCEPTED)
if (message.headers == null || !Boolean.TRUE.equals(message.resend)) { EntityLog.log(this, "Sent via Graph" + ident.user + " elapse=" + (end - start) + " ms");
Address[] all = imessage.getAllRecipients(); else {
if (all != null) String error = "Error " + status + ": " + connection.getResponseMessage();
recipients.addAll(Arrays.asList(all)); try {
} else { InputStream is = connection.getErrorStream();
String to = imessage.getHeader("Resent-To", ","); if (is != null)
if (to != null) error += "\n" + Helper.readStream(is);
for (Address a : InternetAddress.parse(to)) } catch (Throwable ex) {
recipients.add(a); Log.w(ex);
}
String cc = imessage.getHeader("Resent-Cc", ","); throw new IOException(error);
if (cc != null) }
for (Address a : InternetAddress.parse(cc)) } finally {
recipients.add(a); connection.disconnect();
}
String bcc = imessage.getHeader("Resent-Bcc", ","); } finally {
if (bcc != null) db.identity().setIdentityState(ident.id, null);
for (Address a : InternetAddress.parse(bcc))
recipients.add(a);
} }
} else {
EmailService iservice = new EmailService(
this, ident.getProtocol(), ident.realm, ident.encryption, ident.insecure, ident.unicode, debug);
try {
iservice.setUseIp(ident.use_ip, ident.ehlo);
if (!message.isSigned() && !message.isEncrypted())
iservice.set8BitMime(ident.octetmime);
// 0=Read receipt
// 1=Delivery receipt
// 2=Read+delivery receipt
if (message.receipt_request != null && message.receipt_request) {
int receipt_type = prefs.getInt("receipt_type", 2);
if (receipt_type == 1 || receipt_type == 2) // Delivery receipt
iservice.setDsnNotify("SUCCESS,FAILURE,DELAY");
}
if (protocol && BuildConfig.DEBUG) { // Connect transport
ByteArrayOutputStream bos = new ByteArrayOutputStream(); db.identity().setIdentityState(ident.id, "connecting");
imessage.writeTo(bos); iservice.connect(ident);
for (String line : bos.toString().split("\n")) if (BuildConfig.DEBUG && false)
EntityLog.log(this, line); throw new IOException("Test");
} db.identity().setIdentityState(ident.id, "connected");
if (ident.max_size == null)
max_size = iservice.getMaxSize();
List<Address> recipients = new ArrayList<>();
if (message.headers == null || !Boolean.TRUE.equals(message.resend)) {
Address[] all = imessage.getAllRecipients();
if (all != null)
recipients.addAll(Arrays.asList(all));
} else {
String to = imessage.getHeader("Resent-To", ",");
if (to != null)
for (Address a : InternetAddress.parse(to))
recipients.add(a);
String cc = imessage.getHeader("Resent-Cc", ",");
if (cc != null)
for (Address a : InternetAddress.parse(cc))
recipients.add(a);
String bcc = imessage.getHeader("Resent-Bcc", ",");
if (bcc != null)
for (Address a : InternetAddress.parse(bcc))
recipients.add(a);
}
if (protocol && BuildConfig.DEBUG) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
imessage.writeTo(bos);
for (String line : bos.toString().split("\n"))
EntityLog.log(this, line);
}
String via = "via " + ident.host + "/" + ident.user + String via = "via " + ident.host + "/" + ident.user +
" recipients=" + TextUtils.join(", ", recipients); " recipients=" + TextUtils.join(", ", recipients);
iservice.setReporter(new TraceOutputStream.IReport() { iservice.setReporter(new TraceOutputStream.IReport() {
private int progress = -1; private int progress = -1;
private long last = SystemClock.elapsedRealtime(); private long last = SystemClock.elapsedRealtime();
@Override @Override
public void report(int pos, int total) { public void report(int pos, int total) {
int p = (total == 0 ? 0 : 100 * pos / total); int p = (total == 0 ? 0 : 100 * pos / total);
if (p > progress) { if (p > progress) {
progress = p; progress = p;
long now = SystemClock.elapsedRealtime(); long now = SystemClock.elapsedRealtime();
if (now > last + PROGRESS_UPDATE_INTERVAL) { if (now > last + PROGRESS_UPDATE_INTERVAL) {
last = now; last = now;
lastProgress = progress; lastProgress = progress;
if (NotificationHelper.areNotificationsEnabled(nm)) if (NotificationHelper.areNotificationsEnabled(nm))
nm.notify(NotificationHelper.NOTIFICATION_SEND, getNotificationService(false)); nm.notify(NotificationHelper.NOTIFICATION_SEND, getNotificationService(false));
}
} }
} }
});
// Send message
EntityLog.log(this, "Sending " + via);
start = new Date().getTime();
iservice.getTransport().sendMessage(imessage, recipients.toArray(new Address[0]));
end = new Date().getTime();
EntityLog.log(this, "Sent " + via + " elapse=" + (end - start) + " ms");
} catch (MessagingException ex) {
iservice.dump(ident.email);
Log.e(ex);
if (ex instanceof SMTPSendFailedException) {
SMTPSendFailedException sem = (SMTPSendFailedException) ex;
ex = new SMTPSendFailedException(
sem.getCommand(),
sem.getReturnCode(),
getString(R.string.title_service_auth, sem.getMessage()),
sem.getNextException(),
sem.getValidSentAddresses(),
sem.getValidUnsentAddresses(),
sem.getInvalidAddresses());
} }
});
// Send message
EntityLog.log(this, "Sending " + via);
start = new Date().getTime();
iservice.getTransport().sendMessage(imessage, recipients.toArray(new Address[0]));
end = new Date().getTime();
EntityLog.log(this, "Sent " + via + " elapse=" + (end - start) + " ms");
} catch (MessagingException ex) {
iservice.dump(ident.email);
Log.e(ex);
if (ex instanceof SMTPSendFailedException) {
SMTPSendFailedException sem = (SMTPSendFailedException) ex;
ex = new SMTPSendFailedException(
sem.getCommand(),
sem.getReturnCode(),
getString(R.string.title_service_auth, sem.getMessage()),
sem.getNextException(),
sem.getValidSentAddresses(),
sem.getValidUnsentAddresses(),
sem.getInvalidAddresses());
}
if (sid != null) if (sid != null)
db.message().deleteMessage(sid); db.message().deleteMessage(sid);
db.identity().setIdentityError(ident.id, Log.formatThrowable(ex)); db.identity().setIdentityError(ident.id, Log.formatThrowable(ex));
throw ex; throw ex;
} catch (Throwable ex) { } catch (Throwable ex) {
iservice.dump(ident.email); iservice.dump(ident.email);
throw ex; throw ex;
} finally { } finally {
iservice.close(); iservice.close();
if (lastProgress >= 0) { if (lastProgress >= 0) {
lastProgress = -1; lastProgress = -1;
if (NotificationHelper.areNotificationsEnabled(nm)) if (NotificationHelper.areNotificationsEnabled(nm))
nm.notify(NotificationHelper.NOTIFICATION_SEND, getNotificationService(false)); nm.notify(NotificationHelper.NOTIFICATION_SEND, getNotificationService(false));
}
db.identity().setIdentityState(ident.id, null);
} }
db.identity().setIdentityState(ident.id, null);
} }
try { try {

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M13.5,13.48l-4,-4L2,16.99l1.5,1.5 6,-6.01 4,4L22,6.92l-1.41,-1.41z"/>
</vector>

@ -217,7 +217,6 @@
<string name="title_setup_oauth" translatable="false">%1$s (OAuth)</string> <string name="title_setup_oauth" translatable="false">%1$s (OAuth)</string>
<string name="title_setup_android" translatable="false">%1$s (Android)</string> <string name="title_setup_android" translatable="false">%1$s (Android)</string>
<string name="title_setup_google_sign_in">Sign in with Google</string> <string name="title_setup_google_sign_in">Sign in with Google</string>
<string name="title_setup_outlook" translatable="false">Outlook/Hotmail/Live</string>
<string name="title_setup_other">Other provider</string> <string name="title_setup_other">Other provider</string>
<string name="title_setup_inbound">Incoming email only (email cannot be sent!)</string> <string name="title_setup_inbound">Incoming email only (email cannot be sent!)</string>
<string name="title_setup_pop3">POP3 account</string> <string name="title_setup_pop3">POP3 account</string>

@ -199,14 +199,13 @@
<!-- https://learn.microsoft.com/en-us/azure/active-directory/develop/redirect-uris-ios --> <!-- https://learn.microsoft.com/en-us/azure/active-directory/develop/redirect-uris-ios -->
</provider> </provider>
<provider <provider
name="Outlook test" name="Outlook"
debug="true" description="Outlook/Hotmail/Live"
description="Office 365 test" id="outlookgraph"
id="outlooktest"
link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq14" link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq14"
maxtls="1.2" maxtls="1.2"
noop="true" noop="true"
order="5" order="6"
partial="false" partial="false"
useip="false"> useip="false">
<imap <imap
@ -223,19 +222,30 @@
starttls="false" /> starttls="false" />
<oauth <oauth
askAccount="true" askAccount="true"
authorizationEndpoint="https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" authorizationEndpoint="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"
clientId="835c3383-543c-4aa2-98ca-c465eb039987" clientId="3514cf2c-e7a3-45a2-80d4-6a3c3498eca0"
enabled="false" enabled="true"
privacy="https://privacy.microsoft.com/privacystatement" privacy="https://privacy.microsoft.com/privacystatement"
prompt="login" prompt="login"
redirectUri="msauth://eu.faircode.email/xSLzBBuLJunOQPB89rtzM54FXx4%3D" redirectUri="msauth.eu.faircode.email://auth"
scopes="profile,openid,email,offline_access,https://outlook.office.com/IMAP.AccessAsUser.All,https://outlook.office.com/SMTP.Send" scopes="profile,openid,email,offline_access,https://outlook.office.com/IMAP.AccessAsUser.All,https://outlook.office.com/SMTP.Send,https://outlook.office.com/POP.AccessAsUser.All"
tokenEndpoint="https://login.microsoftonline.com/consumers/oauth2/v2.0/token" tokenEndpoint="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
tokenScopes="true"> tokenScopes="true">
<!--parameter <!--parameter
key="domain_hint" key="domain_hint"
value="9188040d-6c67-4c5b-b112-36a304b66dad" /--> value="9188040d-6c67-4c5b-b112-36a304b66dad" /-->
</oauth> </oauth>
<graph
askAccount="true"
authorizationEndpoint="https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
clientId="835c3383-543c-4aa2-98ca-c465eb039987"
enabled="true"
privacy="https://privacy.microsoft.com/privacystatement"
prompt="login"
redirectUri="msauth://eu.faircode.email/xSLzBBuLJunOQPB89rtzM54FXx4%3D"
scopes="offline_access,https://graph.microsoft.com/Mail.Send"
tokenEndpoint="https://login.microsoftonline.com/common/oauth2/v2.0/token"
tokenScopes="true" />
<!-- https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow --> <!-- https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow -->
<!-- https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth --> <!-- https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth -->
<!-- https://learn.microsoft.com/en-us/azure/active-directory/develop/redirect-uris-ios --> <!-- https://learn.microsoft.com/en-us/azure/active-directory/develop/redirect-uris-ios -->
@ -293,7 +303,7 @@
<provider <provider
name="Yahoo.co.jp" name="Yahoo.co.jp"
domain="yahoo\\.co\\.jp" domain="yahoo\\.co\\.jp"
order="6" order="7"
user="local"> user="local">
<imap <imap
host="imap.mail.yahoo.co.jp" host="imap.mail.yahoo.co.jp"
@ -314,7 +324,7 @@
id="yahoo" id="yahoo"
link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq88" link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq88"
noop="true" noop="true"
order="7" order="8"
partial="true"> partial="true">
<imap <imap
host="imap.mail.yahoo.com" host="imap.mail.yahoo.com"
@ -348,7 +358,7 @@
id="yahoo2" id="yahoo2"
link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq88" link="https://github.com/M66B/FairEmail/blob/master/FAQ.md#user-content-faq88"
noop="true" noop="true"
order="7" order="8"
partial="true"> partial="true">
<imap <imap
host="imap.mail.yahoo.com" host="imap.mail.yahoo.com"

Loading…
Cancel
Save