Henning 1 week ago
committed by GitHub
parent
commit
4f79938e95
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 144
      src/api/admin.rs
  2. 59
      src/config.rs
  3. 6
      src/error.rs
  4. 127
      src/mail.rs
  5. 35
      src/static/scripts/admin_settings.js
  6. 10
      src/static/templates/admin/oauth2_success.hbs
  7. 30
      src/static/templates/admin/settings.hbs

144
src/api/admin.rs

@ -1,8 +1,13 @@
use data_encoding::BASE64URL_NOPAD;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use url::Url;
use reqwest::Method; use reqwest::Method;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap;
use std::env; use std::env;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::{ use rocket::{
@ -57,6 +62,9 @@ pub fn routes() -> Vec<Route> {
delete_config, delete_config,
backup_db, backup_db,
test_smtp, test_smtp,
refresh_oauth2_token_endpoint,
oauth2_authorize,
oauth2_callback,
users_overview, users_overview,
organizations_overview, organizations_overview,
delete_organization, delete_organization,
@ -88,6 +96,9 @@ static DB_TYPE: Lazy<&str> = Lazy::new(|| {
static CAN_BACKUP: Lazy<bool> = static CAN_BACKUP: Lazy<bool> =
Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false)); Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false));
// OAuth2 state storage for CSRF protection (state -> expiration timestamp)
static OAUTH2_STATES: Lazy<RwLock<HashMap<String, u64>>> = Lazy::new(|| RwLock::new(HashMap::new()));
#[get("/")] #[get("/")]
fn admin_disabled() -> &'static str { fn admin_disabled() -> &'static str {
"The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it" "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"
@ -329,6 +340,139 @@ async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
} }
} }
#[post("/test/oauth2")]
async fn refresh_oauth2_token_endpoint(_token: AdminToken) -> EmptyResult {
if CONFIG.smtp_oauth2_client_id().is_none() {
err!("OAuth2 is not configured")
}
mail::refresh_oauth2_token().await.map(|_| ())
}
#[get("/oauth2/authorize")]
fn oauth2_authorize(_token: AdminToken) -> Result<Redirect, Error> {
// Check if OAuth2 is configured
let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?;
let auth_url = CONFIG.smtp_oauth2_auth_url().ok_or("OAuth2 Authorization URL not configured")?;
let scopes = CONFIG.smtp_oauth2_scopes();
// Generate a random state token for CSRF protection
let state = crate::crypto::encode_random_bytes::<32>(BASE64URL_NOPAD);
// Store state with expiration (10 minutes from now)
let expiration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 600;
OAUTH2_STATES.write().unwrap().insert(state.clone(), expiration);
// Clean up expired states
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
OAUTH2_STATES.write().unwrap().retain(|_, &mut exp| exp > now);
// Construct redirect URI
let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain());
// Build authorization URL using url crate to ensure proper encoding
let mut url = Url::parse(&auth_url).map_err(|e| Error::new("Invalid OAuth2 Authorization URL", e.to_string()))?;
{
let mut qp = url.query_pairs_mut();
qp.append_pair("client_id", &client_id);
qp.append_pair("redirect_uri", &redirect_uri);
qp.append_pair("response_type", "code");
qp.append_pair("scope", &scopes);
qp.append_pair("state", &state);
qp.append_pair("access_type", "offline");
qp.append_pair("prompt", "consent");
}
let auth_url = url.to_string();
Ok(Redirect::to(auth_url))
}
#[derive(FromForm)]
struct OAuth2CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[get("/oauth2/callback?<params..>")]
async fn oauth2_callback(params: OAuth2CallbackParams) -> Result<Html<String>, Error> {
// Check for errors from OAuth2 provider
if let Some(error) = params.error {
let description = params.error_description.unwrap_or_else(|| "Unknown error".to_string());
return Err(Error::new("OAuth2 Authorization Failed", format!("{}: {}", error, description)));
}
// Validate required parameters
let code = params.code.ok_or("Authorization code not provided")?;
let state = params.state.ok_or("State parameter not provided")?;
// Validate state token
let valid_state = {
let states = OAUTH2_STATES.read().unwrap();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
states.get(&state).is_some_and(|&exp| exp > now)
};
if !valid_state {
return Err(Error::new("OAuth2 State Validation Failed", "Invalid or expired state token"));
}
// Remove used state
OAUTH2_STATES.write().unwrap().remove(&state);
// Exchange authorization code for tokens
let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?;
let client_secret = CONFIG.smtp_oauth2_client_secret().ok_or("OAuth2 Client Secret not configured")?;
let token_url = CONFIG.smtp_oauth2_token_url().ok_or("OAuth2 Token URL not configured")?;
let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain());
let form_params = [
("grant_type", "authorization_code"),
("code", &code),
("redirect_uri", &redirect_uri),
("client_id", &client_id),
("client_secret", &client_secret),
];
let response = make_http_request(Method::POST, &token_url)?
.form(&form_params)
.send()
.await
.map_err(|e| Error::new("OAuth2 Token Exchange Error", e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body"));
return Err(Error::new("OAuth2 Token Exchange Failed", format!("HTTP {}: {}", status, body)));
}
let token_response: Value =
response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?;
// Extract refresh_token from response
let refresh_token =
token_response.get("refresh_token").and_then(|v| v.as_str()).ok_or("No refresh_token in response")?;
// Save refresh_token to configuration
let config_builder: ConfigBuilder = serde_json::from_value(json!({
"smtp_oauth2_refresh_token": refresh_token
}))
.map_err(|e| Error::new("ConfigBuilder serialization error", e.to_string()))?;
CONFIG.update_config_partial(config_builder).await?;
// Return success page via template
let json = json!({
"page_content": "admin/oauth2_success",
"admin_url": admin_url(),
"urlpath": CONFIG.domain_path(),
});
let text = CONFIG.render_template(BASE_TEMPLATE, &json)?;
Ok(Html(text))
}
#[get("/logout")] #[get("/logout")]
fn logout(cookies: &CookieJar<'_>) -> Redirect { fn logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));

59
src/config.rs

@ -780,6 +780,18 @@ make_config! {
smtp_password: Pass, true, option; smtp_password: Pass, true, option;
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option; smtp_auth_mechanism: String, true, option;
/// SMTP OAuth2 Client ID |> OAuth2 Client ID for XOAUTH2 authentication
smtp_oauth2_client_id: String, true, option;
/// SMTP OAuth2 Client Secret |> OAuth2 Client Secret for XOAUTH2 authentication
smtp_oauth2_client_secret: Pass, true, option;
/// SMTP OAuth2 Authorization URL |> OAuth2 Authorization Server URL
smtp_oauth2_auth_url: String, true, option;
/// SMTP OAuth2 Token URL |> OAuth2 Token Server URL for refreshing access tokens
smtp_oauth2_token_url: String, true, option;
/// SMTP OAuth2 Refresh Token |> OAuth2 Refresh Token for obtaining new access tokens
smtp_oauth2_refresh_token: Pass, true, option;
/// SMTP OAuth2 Scopes |> Comma-separated list of OAuth2 scopes
smtp_oauth2_scopes: String, true, def, "https://mail.google.com/".to_string();
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u64, true, def, 15; smtp_timeout: u64, true, def, 15;
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
@ -1036,8 +1048,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`")
} }
// Require both username and password for traditional auth, unless OAuth2 is configured
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`") // Allow username without password if OAuth2 is configured
if cfg.smtp_oauth2_client_id.is_none() {
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`, unless using OAuth2")
}
} }
} }
@ -1129,6 +1145,44 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression")
} }
// OAuth2 validation - triggered when SMTP Auth mechanism includes xoauth2
let uses_xoauth2 = cfg.smtp_auth_mechanism.as_ref().map(|m| m.to_lowercase().contains("xoauth2")).unwrap_or(false);
if uses_xoauth2 {
if cfg.smtp_oauth2_client_id.is_none() {
err!("`SMTP_OAUTH2_CLIENT_ID` must be set when SMTP_AUTH_MECHANISM includes xoauth2");
}
if cfg.smtp_oauth2_client_secret.is_none() {
err!("`SMTP_OAUTH2_CLIENT_SECRET` must be set when SMTP_AUTH_MECHANISM includes xoauth2");
}
if cfg.smtp_oauth2_auth_url.is_none() {
err!("`SMTP_OAUTH2_AUTH_URL` must be set when SMTP_AUTH_MECHANISM includes xoauth2");
}
if cfg.smtp_oauth2_token_url.is_none() {
err!("`SMTP_OAUTH2_TOKEN_URL` must be set when SMTP_AUTH_MECHANISM includes xoauth2");
}
if cfg.smtp_oauth2_scopes.is_empty() {
err!("`SMTP_OAUTH2_SCOPES` must be set when SMTP_AUTH_MECHANISM includes xoauth2");
}
if cfg.smtp_username.is_none() {
err!("`SMTP_USERNAME` must be set for OAuth2 authentication");
}
// Validate that auth URL is a valid URL
if let Some(ref auth_url) = cfg.smtp_oauth2_auth_url {
if !auth_url.starts_with("http://") && !auth_url.starts_with("https://") {
err!("`SMTP_OAUTH2_AUTH_URL` must be a valid URL starting with http:// or https://");
}
}
// Validate that token URL is a valid URL
if let Some(ref token_url) = cfg.smtp_oauth2_token_url {
if !token_url.starts_with("http://") && !token_url.starts_with("https://") {
err!("`SMTP_OAUTH2_TOKEN_URL` must be a valid URL starting with http:// or https://");
}
}
}
if !cfg.disable_admin_token { if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() { match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => { Some(t) if t.starts_with("$argon2") => {
@ -1418,7 +1472,7 @@ impl Config {
Ok(()) Ok(())
} }
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { pub async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = { let builder = {
let usr = &self.inner.read().unwrap()._usr; let usr = &self.inner.read().unwrap()._usr;
let mut _overrides = Vec::new(); let mut _overrides = Vec::new();
@ -1678,6 +1732,7 @@ where
reg!("admin/users"); reg!("admin/users");
reg!("admin/organizations"); reg!("admin/organizations");
reg!("admin/diagnostics"); reg!("admin/diagnostics");
reg!("admin/oauth2_success");
reg!("404"); reg!("404");

6
src/error.rs

@ -123,6 +123,12 @@ impl std::fmt::Debug for Error {
} }
} }
impl From<&str> for Error {
fn from(err: &str) -> Self {
Error::new(err, "")
}
}
impl Error { impl Error {
pub fn new<M: Into<String>, N: Into<String>>(usr_msg: M, log_msg: N) -> Self { pub fn new<M: Into<String>, N: Into<String>>(usr_msg: M, log_msg: N) -> Self {
(usr_msg, log_msg.into()).into() (usr_msg, log_msg.into()).into()

127
src/mail.rs

@ -1,5 +1,8 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use std::sync::{LazyLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env::consts::EXE_SUFFIX, str::FromStr}; use std::{env::consts::EXE_SUFFIX, str::FromStr};
use lettre::{ use lettre::{
@ -21,6 +24,87 @@ use crate::{
CONFIG, CONFIG,
}; };
use crate::http_client::make_http_request;
// OAuth2 Token structures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2Token {
access_token: String,
refresh_token: Option<String>,
expires_at: Option<u64>,
token_type: String,
}
#[derive(Debug, Deserialize)]
struct TokenRefreshResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
token_type: String,
}
pub async fn refresh_oauth2_token() -> Result<OAuth2Token, Error> {
let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?;
let client_secret = CONFIG.smtp_oauth2_client_secret().ok_or("OAuth2 Client Secret not configured")?;
let refresh_token = CONFIG.smtp_oauth2_refresh_token().ok_or("OAuth2 Refresh Token not configured")?;
let token_url = CONFIG.smtp_oauth2_token_url().ok_or("OAuth2 Token URL not configured")?;
let form_params = [
("grant_type", "refresh_token"),
("refresh_token", &refresh_token),
("client_id", &client_id),
("client_secret", &client_secret),
];
let response = make_http_request(reqwest::Method::POST, &token_url)?
.form(&form_params)
.send()
.await
.map_err(|e| Error::new("OAuth2 Token Refresh Error", e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body"));
return Err(Error::new("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}")));
}
let token_response: TokenRefreshResponse =
response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?;
let expires_at = token_response
.expires_in
.map(|expires_in| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + expires_in);
Ok(OAuth2Token {
access_token: token_response.access_token,
refresh_token: token_response.refresh_token.or(Some(refresh_token)),
expires_at,
token_type: token_response.token_type,
})
}
async fn get_valid_oauth2_token() -> Result<OAuth2Token, Error> {
static TOKEN_CACHE: LazyLock<RwLock<Option<OAuth2Token>>> = LazyLock::new(|| RwLock::new(None));
let cached_token = TOKEN_CACHE.read().unwrap().clone();
if let Some(token) = cached_token {
// Check if token is still valid (with 5 min buffer)
if let Some(expires_at) = token.expires_at {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
if now + 300 < expires_at {
return Ok(token);
}
}
}
// Refresh token
let new_token = refresh_oauth2_token().await?;
*TOKEN_CACHE.write().unwrap() = Some(new_token.clone());
Ok(new_token)
}
fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> { fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> {
if let Some(command) = CONFIG.sendmail_command() { if let Some(command) = CONFIG.sendmail_command() {
AsyncSendmailTransport::new_with_command(command) AsyncSendmailTransport::new_with_command(command)
@ -29,7 +113,7 @@ fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> {
} }
} }
fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> { async fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
use std::time::Duration; use std::time::Duration;
let host = CONFIG.smtp_host().unwrap(); let host = CONFIG.smtp_host().unwrap();
@ -57,8 +141,43 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
smtp_client smtp_client
}; };
let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password()) { // Handle authentication - OAuth2 or traditional
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user, pass)), let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password(), CONFIG.smtp_oauth2_client_id()) {
(Some(user), Some(pass), None) => {
// Traditional authentication with username and password
smtp_client.credentials(Credentials::new(user, pass))
}
(Some(user), None, Some(_)) => {
// OAuth2 authentication
match get_valid_oauth2_token().await {
Ok(token) => {
// Pass the access token directly as password - lettre's Xoauth2 mechanism
// will format it correctly as: user={user}\x01auth=Bearer {token}\x01\x01
smtp_client.credentials(Credentials::new(user, token.access_token))
}
Err(e) => {
error!("Error fetching OAuth2 token: {}", e);
warn!("Failed to get OAuth2 token, SMTP transport may not work properly");
smtp_client
}
}
}
(Some(user), Some(pass), Some(_)) => {
// Both password and OAuth2 configured - prefer OAuth2
warn!("Both SMTP_PASSWORD and SMTP_OAUTH2_CLIENT_ID are set. Using OAuth2 authentication.");
match get_valid_oauth2_token().await {
Ok(token) => {
// Pass the access token directly as password - lettre's Xoauth2 mechanism
// will format it correctly as: user={user}\x01auth=Bearer {token}\x01\x01
smtp_client.credentials(Credentials::new(user, token.access_token))
}
Err(e) => {
error!("Error fetching OAuth2 token: {}", e);
warn!("Falling back to password authentication");
smtp_client.credentials(Credentials::new(user, pass))
}
}
}
_ => smtp_client, _ => smtp_client,
}; };
@ -671,7 +790,7 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
} }
} }
} else { } else {
match smtp_transport().send(email).await { match smtp_transport().await.send(email).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
// Match some common errors and make them more user friendly // Match some common errors and make them more user friendly
Err(e) => { Err(e) => {

35
src/static/scripts/admin_settings.js

@ -26,6 +26,33 @@ function smtpTest(event) {
); );
} }
function oauth2RefreshToken(event) {
event.preventDefault();
event.stopPropagation();
if (formHasChanges(config_form)) {
alert("Config has been changed but not yet saved.\nPlease save the changes first before refreshing the OAuth2 token.");
return false;
}
_post(`${BASE_URL}/admin/test/oauth2`,
"OAuth2 token refreshed successfully",
"Error refreshing OAuth2 token",
null, false
);
}
function oauth2Authorize(event) {
event.preventDefault();
event.stopPropagation();
if (formHasChanges(config_form)) {
alert("Config has been changed but not yet saved.\nPlease save the changes first before starting OAuth2 authorization.");
return false;
}
// Redirect to the OAuth2 authorization endpoint
window.location.href = `${BASE_URL}/admin/oauth2/authorize`;
}
function getFormData() { function getFormData() {
let data = {}; let data = {};
@ -225,6 +252,14 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
if (btnSmtpTest) { if (btnSmtpTest) {
btnSmtpTest.addEventListener("click", smtpTest); btnSmtpTest.addEventListener("click", smtpTest);
} }
const btnOAuth2Refresh = document.getElementById("oauth2RefreshTest");
if (btnOAuth2Refresh) {
btnOAuth2Refresh.addEventListener("click", oauth2RefreshToken);
}
const btnOAuth2Authorize = document.getElementById("oauth2Authorize");
if (btnOAuth2Authorize) {
btnOAuth2Authorize.addEventListener("click", oauth2Authorize);
}
config_form.addEventListener("submit", saveConfig); config_form.addEventListener("submit", saveConfig);

10
src/static/templates/admin/oauth2_success.hbs

@ -0,0 +1,10 @@
<main class="container-xl">
<div class="alert alert-success mt-5" role="alert">
<h4 class="alert-heading">OAuth2 Authorization Successful</h4>
<p>The refresh token has been saved to your configuration.</p>
<hr>
<p class="mb-0">
<a href="{{admin_url}}" class="btn btn-primary">Return to Admin Settings</a>
</p>
</div>
</main>

30
src/static/templates/admin/settings.hbs

@ -50,6 +50,36 @@
{{/if}} {{/if}}
{{/each}} {{/each}}
{{#case group "smtp"}} {{#case group "smtp"}}
<div class="row my-2 pt-3 border-top">
<div class="col-sm-11">
<div class="alert alert-info mb-0" role="alert">
<h6 class="alert-heading"><i class="bi bi-info-circle"></i> OAuth2/XOAUTH2 Authentication</h6>
<p class="mb-2 small">
OAuth2 authentication allows secure email sending without storing your email password.
Configure the OAuth2 fields below to use token-based authentication (recommended for Gmail and Microsoft 365).
</p>
<p class="mb-0 small">
<strong>Note:</strong> If both password and OAuth2 credentials are configured, OAuth2 will be preferred with automatic fallback to password authentication.
See the <a href="https://github.com/dani-garcia/vaultwarden/wiki/SMTP-Configuration#oauth2-authentication" target="_blank" rel="noopener noreferrer" class="alert-link">documentation</a> for detailed setup instructions.
</p>
</div>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Start OAuth2 authorization flow to obtain refresh token">
<label class="col-sm-3 col-form-label">Obtain OAuth2 Refresh Token</label>
<div class="col-sm-8">
<button type="button" class="btn btn-primary" id="oauth2Authorize">Start Authorization</button>
<small class="form-text text-muted d-block mt-1">
Click to authenticate with your email provider and obtain a refresh token.
</small>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Manually refresh OAuth2 token">
<label class="col-sm-3 col-form-label">Refresh OAuth2 Token</label>
<div class="col-sm-8">
<button type="button" class="btn btn-outline-primary" id="oauth2RefreshTest">Refresh Token</button>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Send a test email to given email address"> <div class="row my-2 align-items-center pt-3 border-top" title="Send a test email to given email address">
<label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label> <label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
<div class="col-sm-8 input-group"> <div class="col-sm-8 input-group">

Loading…
Cancel
Save