Added admin resource manager
This commit is contained in:
145
Moonlight/App/Models/Files/Accesses/HostFileAccess.cs
Normal file
145
Moonlight/App/Models/Files/Accesses/HostFileAccess.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Logging.Net;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Helpers;
|
||||
|
||||
namespace Moonlight.App.Models.Files.Accesses;
|
||||
|
||||
public class HostFileAccess : IFileAccess
|
||||
{
|
||||
private readonly string BasePath;
|
||||
private string RealPath => BasePath + Path;
|
||||
private string Path = "/";
|
||||
|
||||
public HostFileAccess(string bp)
|
||||
{
|
||||
BasePath = bp;
|
||||
}
|
||||
|
||||
public Task<FileManagerObject[]> GetDirectoryContent()
|
||||
{
|
||||
var x = new List<FileManagerObject>();
|
||||
|
||||
foreach (var directory in Directory.EnumerateDirectories(RealPath))
|
||||
{
|
||||
x.Add(new ()
|
||||
{
|
||||
Name = System.IO.Path.GetFileName(directory)!,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Size = 0,
|
||||
IsFile = false
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(RealPath))
|
||||
{
|
||||
x.Add(new ()
|
||||
{
|
||||
Name = System.IO.Path.GetFileName(file)!,
|
||||
CreatedAt = File.GetCreationTimeUtc(file),
|
||||
UpdatedAt = File.GetLastWriteTimeUtc(file),
|
||||
Size = new System.IO.FileInfo(file).Length,
|
||||
IsFile = File.Exists(file)
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(x.ToArray());
|
||||
}
|
||||
|
||||
public Task ChangeDirectory(string s)
|
||||
{
|
||||
var x = System.IO.Path.Combine(Path, s).Replace("\\", "/") + "/";
|
||||
x = x.Replace("//", "/");
|
||||
Path = x;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetDirectory(string s)
|
||||
{
|
||||
Path = s;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task GoUp()
|
||||
{
|
||||
Path = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, "..")).Replace("\\", "/").Replace("C:", "");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> ReadFile(FileManagerObject fileManagerObject)
|
||||
{
|
||||
return Task.FromResult(File.ReadAllText(RealPath + fileManagerObject.Name));
|
||||
}
|
||||
|
||||
public Task WriteFile(FileManagerObject fileManagerObject, string content)
|
||||
{
|
||||
File.WriteAllText(RealPath + fileManagerObject.Name, content);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task UploadFile(string name, Stream stream, Action<int>? progressUpdated = null)
|
||||
{
|
||||
var fs = File.OpenWrite(RealPath + name);
|
||||
|
||||
var dataStream = new StreamProgressHelper(stream)
|
||||
{
|
||||
Progress = i =>
|
||||
{
|
||||
if (progressUpdated != null)
|
||||
progressUpdated.Invoke(i);
|
||||
}
|
||||
};
|
||||
|
||||
await dataStream.CopyToAsync(fs);
|
||||
await fs.FlushAsync();
|
||||
fs.Close();
|
||||
}
|
||||
|
||||
public Task CreateDirectory(string name)
|
||||
{
|
||||
Directory.CreateDirectory(RealPath + name);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GetCurrentPath()
|
||||
{
|
||||
return Task.FromResult(Path);
|
||||
}
|
||||
|
||||
public Task<Stream> GetDownloadStream(FileManagerObject managerObject)
|
||||
{
|
||||
var stream = new FileStream(RealPath + managerObject.Name, FileMode.Open, FileAccess.Read);
|
||||
return Task.FromResult<Stream>(stream);
|
||||
}
|
||||
|
||||
public Task<string> GetDownloadUrl(FileManagerObject managerObject)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task Delete(FileManagerObject managerObject)
|
||||
{
|
||||
if(managerObject.IsFile)
|
||||
File.Delete(RealPath + managerObject.Name);
|
||||
else
|
||||
Directory.Delete(RealPath + managerObject.Name, true);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Move(FileManagerObject managerObject, string newPath)
|
||||
{
|
||||
if(managerObject.IsFile)
|
||||
File.Move(RealPath + managerObject.Name, BasePath + newPath);
|
||||
else
|
||||
Directory.Move(RealPath + managerObject.Name, BasePath + newPath);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GetLaunchUrl()
|
||||
{
|
||||
throw new DisplayException("WinSCP cannot be launched here");
|
||||
}
|
||||
}
|
||||
90
Moonlight/App/Services/MailService.cs
Normal file
90
Moonlight/App/Services/MailService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Logging.Net;
|
||||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Exceptions;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
public class MailService
|
||||
{
|
||||
private readonly string Server;
|
||||
private readonly string Password;
|
||||
private readonly string Email;
|
||||
private readonly int Port;
|
||||
|
||||
public MailService(ConfigService configService)
|
||||
{
|
||||
var mailConfig = configService
|
||||
.GetSection("Moonlight")
|
||||
.GetSection("Mail");
|
||||
|
||||
Server = mailConfig.GetValue<string>("Server");
|
||||
Password = mailConfig.GetValue<string>("Password");
|
||||
Email = mailConfig.GetValue<string>("Email");
|
||||
Port = mailConfig.GetValue<int>("Port");
|
||||
}
|
||||
|
||||
public async Task SendMail(
|
||||
User user,
|
||||
string name,
|
||||
Action<Dictionary<string, string>> values
|
||||
)
|
||||
{
|
||||
if (!File.Exists($"resources/mail/{name}.html"))
|
||||
{
|
||||
Logger.Warn($"Mail template '{name}' not found. Make sure to place one in the resources folder");
|
||||
throw new DisplayException("Mail template not found");
|
||||
}
|
||||
|
||||
var rawHtml = await File.ReadAllTextAsync($"resources/mail/{name}.html");
|
||||
|
||||
var val = new Dictionary<string, string>();
|
||||
values.Invoke(val);
|
||||
|
||||
val.Add("FirstName", user.FirstName);
|
||||
val.Add("LastName", user.LastName);
|
||||
|
||||
var parsed = ParseMail(rawHtml, val);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
|
||||
client.Host = Server;
|
||||
client.Port = Port;
|
||||
client.EnableSsl = true;
|
||||
client.Credentials = new NetworkCredential(Email, Password);
|
||||
|
||||
await client.SendMailAsync(new MailMessage()
|
||||
{
|
||||
From = new MailAddress(Email),
|
||||
Sender = new MailAddress(Email),
|
||||
Body = parsed,
|
||||
IsBodyHtml = true,
|
||||
Subject = $"Hey {user.FirstName}, there are news from moonlight",
|
||||
To = { new MailAddress(user.Email) }
|
||||
});
|
||||
|
||||
Logger.Debug("Send!");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Warn("Error sending mail");
|
||||
Logger.Warn(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private string ParseMail(string html, Dictionary<string, string> values)
|
||||
{
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
html = html.Replace("{{" + kvp.Key + "}}", kvp.Value);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Models.Misc;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services.LogServices;
|
||||
using Moonlight.App.Services.Sessions;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
@@ -14,6 +15,8 @@ public class UserService
|
||||
private readonly TotpService TotpService;
|
||||
private readonly SecurityLogService SecurityLogService;
|
||||
private readonly AuditLogService AuditLogService;
|
||||
private readonly MailService MailService;
|
||||
private readonly IdentityService IdentityService;
|
||||
|
||||
private readonly string JwtSecret;
|
||||
|
||||
@@ -22,12 +25,16 @@ public class UserService
|
||||
TotpService totpService,
|
||||
ConfigService configService,
|
||||
SecurityLogService securityLogService,
|
||||
AuditLogService auditLogService)
|
||||
AuditLogService auditLogService,
|
||||
MailService mailService,
|
||||
IdentityService identityService)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
TotpService = totpService;
|
||||
SecurityLogService = securityLogService;
|
||||
AuditLogService = auditLogService;
|
||||
MailService = mailService;
|
||||
IdentityService = identityService;
|
||||
|
||||
JwtSecret = configService
|
||||
.GetSection("Moonlight")
|
||||
@@ -37,14 +44,17 @@ public class UserService
|
||||
|
||||
public async Task<string> Register(string email, string password, string firstname, string lastname)
|
||||
{
|
||||
// Check if the email is already taken
|
||||
var emailTaken = UserRepository.Get().FirstOrDefault(x => x.Email == email) != null;
|
||||
|
||||
if (emailTaken)
|
||||
{
|
||||
//AuditLogService.Log("register:fail", $"Invalid email: {email}");
|
||||
throw new DisplayException("The email is already in use");
|
||||
}
|
||||
|
||||
//TODO: Validation
|
||||
|
||||
// Add user
|
||||
var user = UserRepository.Add(new()
|
||||
{
|
||||
Address = "",
|
||||
@@ -67,11 +77,7 @@ public class UserService
|
||||
TokenValidTime = DateTime.Now.AddDays(-5)
|
||||
});
|
||||
|
||||
//AuditLogService.Log("register:done", $"A new user has registered: Email: {email}");
|
||||
|
||||
//var mail = new WelcomeMail(user);
|
||||
//await MailService.Send(mail, user);
|
||||
|
||||
await MailService.SendMail(user!, "register", values => {});
|
||||
await AuditLogService.Log(AuditLogType.Register, user.Email);
|
||||
|
||||
return await GenerateToken(user);
|
||||
@@ -101,6 +107,7 @@ public class UserService
|
||||
|
||||
public async Task<string> Login(string email, string password, string totpCode = "")
|
||||
{
|
||||
// First password check and check if totp is enabled
|
||||
var needTotp = await CheckTotp(email, password);
|
||||
|
||||
var user = UserRepository.Get()
|
||||
@@ -120,7 +127,7 @@ public class UserService
|
||||
if (totpCodeValid)
|
||||
{
|
||||
await AuditLogService.Log(AuditLogType.Login, email);
|
||||
return await GenerateToken(user);
|
||||
return await GenerateToken(user, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -131,7 +138,7 @@ public class UserService
|
||||
else
|
||||
{
|
||||
await AuditLogService.Log(AuditLogType.Login, email);
|
||||
return await GenerateToken(user!);
|
||||
return await GenerateToken(user!, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +148,12 @@ public class UserService
|
||||
user.TokenValidTime = DateTime.Now;
|
||||
UserRepository.Update(user);
|
||||
|
||||
//var mail = new NewPasswordMail(user);
|
||||
//await MailService.Send(mail, user);
|
||||
await MailService.SendMail(user!, "passwordChange", values =>
|
||||
{
|
||||
values.Add("Ip", IdentityService.GetIp());
|
||||
values.Add("Device", IdentityService.GetDevice());
|
||||
values.Add("Location", "In your walls");
|
||||
});
|
||||
|
||||
await AuditLogService.Log(AuditLogType.ChangePassword, user.Email);
|
||||
}
|
||||
@@ -167,8 +178,15 @@ public class UserService
|
||||
throw new Exception("Invalid userid or password");
|
||||
}
|
||||
|
||||
public Task<string> GenerateToken(User user)
|
||||
public async Task<string> GenerateToken(User user, bool sendMail = false)
|
||||
{
|
||||
await MailService.SendMail(user!, "login", values =>
|
||||
{
|
||||
values.Add("Ip", IdentityService.GetIp());
|
||||
values.Add("Device", IdentityService.GetDevice());
|
||||
values.Add("Location", "In your walls");
|
||||
});
|
||||
|
||||
var token = JwtBuilder.Create()
|
||||
.WithAlgorithm(new HMACSHA256Algorithm())
|
||||
.WithSecret(JwtSecret)
|
||||
@@ -177,6 +195,6 @@ public class UserService
|
||||
.AddClaim("userid", user.Id)
|
||||
.Encode();
|
||||
|
||||
return Task.FromResult(token);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
||||
<PackageReference Include="Blazor.ContextMenu" Version="1.15.0" />
|
||||
<PackageReference Include="BlazorDownloadFile" Version="2.4.0.2" />
|
||||
<PackageReference Include="Blazored.Typeahead" Version="4.7.0" />
|
||||
<PackageReference Include="BlazorMonaco" Version="2.1.0" />
|
||||
<PackageReference Include="BlazorTable" Version="1.17.0" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using BlazorDownloadFile;
|
||||
using BlazorTable;
|
||||
using CurrieTechnologies.Razor.SweetAlert2;
|
||||
using Logging.Net;
|
||||
@@ -34,8 +35,9 @@ namespace Moonlight
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Switch to logging.net injection
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddProvider(new LogMigratorProvider());
|
||||
// TODO: Enable in production
|
||||
//builder.Logging.ClearProviders();
|
||||
//builder.Logging.AddProvider(new LogMigratorProvider());
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
@@ -97,6 +99,7 @@ namespace Moonlight
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<ErrorLogService>();
|
||||
builder.Services.AddScoped<LogService>();
|
||||
builder.Services.AddScoped<MailService>();
|
||||
|
||||
// Support
|
||||
builder.Services.AddSingleton<SupportServerService>();
|
||||
@@ -120,6 +123,7 @@ namespace Moonlight
|
||||
builder.Services.AddBlazorTable();
|
||||
builder.Services.AddSweetAlert2(options => { options.Theme = SweetAlertTheme.Dark; });
|
||||
builder.Services.AddBlazorContextMenu();
|
||||
builder.Services.AddBlazorDownloadFile();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using Moonlight.App.Helpers
|
||||
@using BlazorContextMenu
|
||||
@using BlazorDownloadFile
|
||||
@using Logging.Net
|
||||
@using Moonlight.App.Models.Files
|
||||
@using Moonlight.App.Services
|
||||
@@ -9,6 +10,7 @@
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject SmartTranslateService TranslationService
|
||||
@inject IBlazorDownloadFileService FileService
|
||||
|
||||
<div class="card card-flush">
|
||||
@if (Editing == null)
|
||||
@@ -347,8 +349,15 @@ else
|
||||
case "download":
|
||||
if (data.IsFile)
|
||||
{
|
||||
// First we try to download via stream
|
||||
|
||||
try
|
||||
{
|
||||
var stream = await FileAccess.GetDownloadStream(data);
|
||||
await ToastService.Info(TranslationService.Translate("Starting download"));
|
||||
await FileService.AddBuffer(stream);
|
||||
await FileService.DownloadBinaryBuffers(data.Name);
|
||||
}
|
||||
catch (NotImplementedException)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = await FileAccess.GetDownloadUrl(data);
|
||||
@@ -362,6 +371,13 @@ else
|
||||
Logger.Error(exception.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await ToastService.Error(TranslationService.Translate("Error starting download"));
|
||||
Logger.Error("Error downloading file stream");
|
||||
Logger.Error(exception.Message);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "rename":
|
||||
var newName = await AlertService.Text(TranslationService.Translate("Rename"), TranslationService.Translate("Enter a new name"), data.Name);
|
||||
@@ -462,7 +478,7 @@ else
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async void Launch()
|
||||
private async Task Launch()
|
||||
{
|
||||
NavigationManager.NavigateTo(await FileAccess.GetLaunchUrl());
|
||||
}
|
||||
|
||||
@@ -6,17 +6,32 @@
|
||||
<ul class="nav nav-stretch nav-line-tabs nav-line-tabs-2x border-transparent fs-5 fw-bold">
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 0 ? "active" : "")" href="/admin/system">
|
||||
Overview
|
||||
<TL>Overview</TL>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 1 ? "active" : "")" href="/admin/system/logs">
|
||||
Logs
|
||||
<TL>Logs</TL>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 2 ? "active" : "")" href="/admin/system/auditlog">
|
||||
AuditLog
|
||||
<TL>AuditLog</TL>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 3 ? "active" : "")" href="/admin/system/auditlog">
|
||||
<TL>SecurityLog</TL>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 4 ? "active" : "")" href="/admin/system/auditlog">
|
||||
<TL>ErrorLog</TL>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 5 ? "active" : "")" href="/admin/system/resources">
|
||||
<TL>Resources</TL>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
14
Moonlight/Shared/Views/Admin/Sys/Resources.razor
Normal file
14
Moonlight/Shared/Views/Admin/Sys/Resources.razor
Normal file
@@ -0,0 +1,14 @@
|
||||
@page "/admin/system/resources"
|
||||
|
||||
@using Moonlight.Shared.Components.Navigations
|
||||
@using Moonlight.Shared.Components.FileManagerPartials
|
||||
@using Moonlight.App.Models.Files.Accesses
|
||||
|
||||
<OnlyAdmin>
|
||||
<AdminSystemNavigation Index="5" />
|
||||
|
||||
<div class="card card-body">
|
||||
<FileManager FileAccess="@(new HostFileAccess("resources"))">
|
||||
</FileManager>
|
||||
</div>
|
||||
</OnlyAdmin>
|
||||
@@ -290,3 +290,19 @@ Enter the message to send;Enter the message to send
|
||||
Confirm;Confirm
|
||||
Are you sure?;Are you sure?
|
||||
Enter url;Enter url
|
||||
An unknown error occured while starting backup deletion;An unknown error occured while starting backup deletion
|
||||
Success;Success
|
||||
Backup URL successfully copied to your clipboard;Backup URL successfully copied to your clipboard
|
||||
Backup restore started;Backup restore started
|
||||
Backup successfully restored;Backup successfully restored
|
||||
Register for;Register for
|
||||
Core;Core
|
||||
Logs;Logs
|
||||
AuditLog;AuditLog
|
||||
SecurityLog;SecurityLog
|
||||
ErrorLog;ErrorLog
|
||||
Resources;Resources
|
||||
WinSCP cannot be launched here;WinSCP cannot be launched here
|
||||
Create a new folder;Create a new folder
|
||||
Enter a name;Enter a name
|
||||
File upload complete;File upload complete
|
||||
|
||||
53
Moonlight/resources/mail/login.html
Normal file
53
Moonlight/resources/mail/login.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>New moonlight login</title>
|
||||
</head>
|
||||
<body>
|
||||
<div style="background-color:#ffffff; padding: 45px 0 34px 0; border-radius: 24px; margin:40px auto; max-width: 600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" height="auto"
|
||||
style="border-collapse:collapse">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="center" style="text-align:center; padding-bottom: 10px">
|
||||
<div style="text-align:center; margin:0 15px 34px 15px">
|
||||
<div style="margin-bottom: 10px">
|
||||
<a href="https://endelon-hosting.de" rel="noopener" target="_blank">
|
||||
<img alt="Logo" src="https://moonlight.endelon-hosting.de/assets/media/logo/MoonFullText.png" style="height: 35px">
|
||||
</a>
|
||||
</div>
|
||||
<div style="font-size: 14px; font-weight: 500; margin-bottom: 27px; font-family:Arial,Helvetica,sans-serif;">
|
||||
<p style="margin-bottom:9px; color:#181C32; font-size: 22px; font-weight:700">Hey {{FirstName}}, there is a new login in your moonlight account</p>
|
||||
<p style="margin-bottom:2px; color:#7E8299">Here is all the data we collected</p>
|
||||
<p style="margin-bottom:2px; color:#7E8299">IP: {{Ip}}</p>
|
||||
<p style="margin-bottom:2px; color:#7E8299">Device: {{Device}}</p>
|
||||
<p style="margin-bottom:2px; color:#7E8299">Location: {{Location}}</p>
|
||||
</div>
|
||||
<a href="https://moonlight.endelon-hosting.de" target="_blank"
|
||||
style="background-color:#50cd89; border-radius:6px;display:inline-block; padding:11px 19px; color: #FFFFFF; font-size: 14px; font-weight:500;">Open Moonlight
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="center"
|
||||
style="font-size: 13px; text-align:center; padding: 0 10px 10px 10px; font-weight: 500; color: #A1A5B7; font-family:Arial,Helvetica,sans-serif">
|
||||
<p style="color:#181C32; font-size: 16px; font-weight: 600; margin-bottom:9px">You need help?</p>
|
||||
<p style="margin-bottom:2px">We are happy to help!</p>
|
||||
<p style="margin-bottom:4px">More information at
|
||||
<a href="https://endelon.link/support" rel="noopener" target="_blank" style="font-weight: 600">endelon.link/support</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="center"
|
||||
style="font-size: 13px; padding:0 15px; text-align:center; font-weight: 500; color: #A1A5B7;font-family:Arial,Helvetica,sans-serif">
|
||||
<p>Copyright 2022 Endelon Hosting </p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user