Compare commits

..
Author SHA1 Message Date
Abdullah Atta bdd5017394 s3: add x-object-size header alongwith content-length header 2026-06-06 08:57:53 +05:00
01zulfiandGitHub 7ad70c63ee api: move inboxitemhistory collection sync in RequestFetchV4 (#101) 2026-05-19 13:39:52 +05:00
Abdullah Atta 0367ab6f80 sync: get rid of ._id fallback for inbox items 2026-05-15 08:58:21 +05:00
01zulfiandGitHub f3bfe0957b inbox: create InboxItemsHistory synced collection (#96) 2026-05-14 11:20:17 +05:00
01zulfiandGitHub 7f614f6954 inbox: remove 'Default' api key creation (#100) 2026-05-14 11:09:28 +05:00
Abdullah Atta 6663778e3e identity: fix email templates 2026-05-14 10:45:10 +05:00
14f0a3b37e inbox: improve http response status codes (#99)
* inbox: improve http response status codes
* inbox api: respond with 401 unauthorized for invalid inbox api key
* notesnook api (get public encryption key): respond with 404 if not found

* Update Notesnook.Inbox.API/src/index.ts

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-14 09:37:28 +05:00
01zulfiandGitHub 82a1152f9f inbox: fix expiry date validation check (#97) 2026-05-13 09:51:24 +05:00
01zulfiandGitHub 580524b855 inbox: require non-empty source in inbox item (#98) 2026-05-13 09:50:18 +05:00
13 changed files with 82 additions and 67 deletions
@@ -42,6 +42,7 @@ namespace Notesnook.API.Accessors
public SyncItemsRepository Colors { get; } public SyncItemsRepository Colors { get; }
public SyncItemsRepository Vaults { get; } public SyncItemsRepository Vaults { get; }
public SyncItemsRepository Tags { get; } public SyncItemsRepository Tags { get; }
public SyncItemsRepository InboxItemsHistory { get; }
public Repository<UserSettings> UsersSettings { get; } public Repository<UserSettings> UsersSettings { get; }
public Repository<Monograph> Monographs { get; } public Repository<Monograph> Monographs { get; }
public Repository<InboxApiKey> InboxApiKey { get; } public Repository<InboxApiKey> InboxApiKey { get; }
@@ -75,6 +76,8 @@ namespace Notesnook.API.Accessors
IMongoCollection<SyncItem> vaults, IMongoCollection<SyncItem> vaults,
[FromKeyedServices(Collections.TagsKey)] [FromKeyedServices(Collections.TagsKey)]
IMongoCollection<SyncItem> tags, IMongoCollection<SyncItem> tags,
[FromKeyedServices(Collections.InboxItemsHistoryKey)]
IMongoCollection<SyncItem> inboxItemsHistory,
Repository<UserSettings> usersSettings, Repository<UserSettings> usersSettings,
Repository<Monograph> monographs, Repository<Monograph> monographs,
@@ -102,6 +105,7 @@ namespace Notesnook.API.Accessors
Colors = new SyncItemsRepository(dbContext, colors, logger); Colors = new SyncItemsRepository(dbContext, colors, logger);
Vaults = new SyncItemsRepository(dbContext, vaults, logger); Vaults = new SyncItemsRepository(dbContext, vaults, logger);
Tags = new SyncItemsRepository(dbContext, tags, logger); Tags = new SyncItemsRepository(dbContext, tags, logger);
InboxItemsHistory = new SyncItemsRepository(dbContext, inboxItemsHistory, logger);
} }
} }
} }
+1
View File
@@ -18,5 +18,6 @@ namespace Notesnook.API
public const string InboxApiKeysKey = "inbox_api_keys"; public const string InboxApiKeysKey = "inbox_api_keys";
public const string SyncDevicesKey = "sync_devices"; public const string SyncDevicesKey = "sync_devices";
public const string DeviceIdsChunksKey = "device_ids_chunks"; public const string DeviceIdsChunksKey = "device_ids_chunks";
public const string InboxItemsHistoryKey = "inbox_items_history";
} }
} }
+4 -3
View File
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Collections.Generic;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -72,9 +73,9 @@ namespace Notesnook.API.Controllers
{ {
return BadRequest(new { error = "Api key name is required." }); return BadRequest(new { error = "Api key name is required." });
} }
if (request.ExpiryDate <= -1) if (request.ExpiryDate == null)
{ {
return BadRequest(new { error = "Valid expiry date is required." }); return BadRequest(new { error = "Expiry date is required." });
} }
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId); var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
@@ -133,7 +134,7 @@ namespace Notesnook.API.Controllers
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId); var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public)) if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
{ {
return BadRequest(new { error = "Inbox public key is not configured." }); return NotFound(new { error = "Inbox public key is not configured." });
} }
return Ok(new { key = userSetting.InboxKeys.Public }); return Ok(new { key = userSetting.InboxKeys.Public });
} }
+2 -2
View File
@@ -183,8 +183,8 @@ namespace Notesnook.API.Controllers
try try
{ {
var userId = this.User.GetUserId(); var userId = this.User.GetUserId();
var size = await s3Service.GetObjectSizeAsync(userId, name); var size = await s3Service.GetObjectSizeAsync(userId, name); Response.Headers.ContentLength = size;
HttpContext.Response.Headers.ContentLength = size; Response.Headers["X-Object-Size"] = size.ToString();
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)
+50 -36
View File
@@ -57,21 +57,9 @@ namespace Notesnook.API.Hubs
private ISyncItemsRepositoryAccessor Repositories { get; } private ISyncItemsRepositoryAccessor Repositories { get; }
private SyncDeviceService SyncDeviceService { get; } private SyncDeviceService SyncDeviceService { get; }
private readonly IUnitOfWork unit; private readonly IUnitOfWork unit;
private static readonly string[] CollectionKeys = [
"settingitem",
"attachment",
"note",
"notebook",
"content",
"shortcut",
"reminder",
"color",
"tag",
"vault",
"relation", // relations must sync at the end to prevent invalid state
];
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap; private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
private readonly Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections; private readonly CollectionDef[] BaseCollectionDefs;
private readonly CollectionDef[] V4CollectionDefs;
ILogger<SyncV2Hub> Logger { get; } ILogger<SyncV2Hub> Logger { get; }
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger) public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
@@ -81,18 +69,32 @@ namespace Notesnook.API.Hubs
unit = unitOfWork; unit = unitOfWork;
SyncDeviceService = syncDeviceService; SyncDeviceService = syncDeviceService;
Collections = [ BaseCollectionDefs = [
Repositories.Settings.FindItemsById, new("settingitem", Repositories.Settings.FindItemsById),
Repositories.Attachments.FindItemsById, new("attachment", Repositories.Attachments.FindItemsById),
Repositories.Notes.FindItemsById, new("note", Repositories.Notes.FindItemsById),
Repositories.Notebooks.FindItemsById, new("notebook", Repositories.Notebooks.FindItemsById),
Repositories.Contents.FindItemsById, new("content", Repositories.Contents.FindItemsById),
Repositories.Shortcuts.FindItemsById, new("shortcut", Repositories.Shortcuts.FindItemsById),
Repositories.Reminders.FindItemsById, new("reminder", Repositories.Reminders.FindItemsById),
Repositories.Colors.FindItemsById, new("color", Repositories.Colors.FindItemsById),
Repositories.Tags.FindItemsById, new("tag", Repositories.Tags.FindItemsById),
Repositories.Vaults.FindItemsById, new("vault", Repositories.Vaults.FindItemsById),
Repositories.Relations.FindItemsById, new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
];
V4CollectionDefs = [
new("settingitem", Repositories.Settings.FindItemsById),
new("attachment", Repositories.Attachments.FindItemsById),
new("note", Repositories.Notes.FindItemsById),
new("notebook", Repositories.Notebooks.FindItemsById),
new("content", Repositories.Contents.FindItemsById),
new("shortcut", Repositories.Shortcuts.FindItemsById),
new("reminder", Repositories.Reminders.FindItemsById),
new("color", Repositories.Colors.FindItemsById),
new("tag", Repositories.Tags.FindItemsById),
new("vault", Repositories.Vaults.FindItemsById),
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
]; ];
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> { UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
{ "settingitem", Repositories.Settings.UpsertMany }, { "settingitem", Repositories.Settings.UpsertMany },
@@ -106,6 +108,7 @@ namespace Notesnook.API.Hubs
{ "color", Repositories.Colors.UpsertMany }, { "color", Repositories.Colors.UpsertMany },
{ "vault", Repositories.Vaults.UpsertMany }, { "vault", Repositories.Vaults.UpsertMany },
{ "tag", Repositories.Tags.UpsertMany }, { "tag", Repositories.Tags.UpsertMany },
{ "inboxitemhistory", Repositories.InboxItemsHistory.UpsertMany },
}.ToFrozenDictionary(); }.ToFrozenDictionary();
} }
@@ -181,17 +184,17 @@ namespace Notesnook.API.Hubs
return true; return true;
} }
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes) private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes, CollectionDef[] collectionDefs)
{ {
var itemsProcessed = 0; var itemsProcessed = 0;
for (int i = 0; i < Collections.Length; i++) foreach (var def in collectionDefs)
{ {
var type = CollectionKeys[i]; var type = def.Key;
var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray(); var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
if (!resetSync && filteredIds.Length == 0) continue; if (!resetSync && filteredIds.Length == 0) continue;
using var cursor = await Collections[i](userId, filteredIds, resetSync, size); using var cursor = await def.FindItems(userId, filteredIds, resetSync, size);
var chunk = new List<SyncItem>(); var chunk = new List<SyncItem>();
long totalBytes = 0; long totalBytes = 0;
@@ -233,20 +236,25 @@ namespace Notesnook.API.Hubs
public async Task<SyncV2Metadata> RequestFetch(string deviceId) public async Task<SyncV2Metadata> RequestFetch(string deviceId)
{ {
return await HandleRequestFetch(deviceId, false, false); return await HandleRequestFetch(deviceId, false, false, BaseCollectionDefs);
} }
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId) public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
{ {
return await HandleRequestFetch(deviceId, true, false); return await HandleRequestFetch(deviceId, true, false, BaseCollectionDefs);
} }
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId) public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
{ {
return await HandleRequestFetch(deviceId, true, true); return await HandleRequestFetch(deviceId, true, true, BaseCollectionDefs);
} }
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems) public async Task<SyncV2Metadata> RequestFetchV4(string deviceId)
{
return await HandleRequestFetch(deviceId, true, true, V4CollectionDefs);
}
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems, CollectionDef[] collectionDefs)
{ {
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync."); var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
@@ -270,7 +278,8 @@ namespace Notesnook.API.Hubs
ids, ids,
size: 100, size: 100,
resetSync: device.IsSyncReset, resetSync: device.IsSyncReset,
maxBytes: 3 * 1024 * 1024 maxBytes: 3 * 1024 * 1024,
collectionDefs
); );
await foreach (var chunk in chunks) await foreach (var chunk in chunks)
@@ -325,7 +334,7 @@ namespace Notesnook.API.Hubs
var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId); var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
var userInboxItems = device.IsSyncReset var userInboxItems = device.IsSyncReset
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId) ? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId ?? m.Id.ToString())); : await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId));
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10))) if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
{ {
throw new HubException("Client rejected inbox items."); throw new HubException("Client rejected inbox items.");
@@ -344,6 +353,11 @@ namespace Notesnook.API.Hubs
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds); SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
} }
} }
private record CollectionDef(
string Key,
Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>> FindItems
);
} }
[MessagePack.MessagePackObject] [MessagePack.MessagePackObject]
@@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces
SyncItemsRepository Colors { get; } SyncItemsRepository Colors { get; }
SyncItemsRepository Vaults { get; } SyncItemsRepository Vaults { get; }
SyncItemsRepository Tags { get; } SyncItemsRepository Tags { get; }
SyncItemsRepository InboxItemsHistory { get; }
Repository<UserSettings> UsersSettings { get; } Repository<UserSettings> UsersSettings { get; }
Repository<Monograph> Monographs { get; } Repository<Monograph> Monographs { get; }
Repository<InboxApiKey> InboxApiKey { get; } Repository<InboxApiKey> InboxApiKey { get; }
+2 -9
View File
@@ -174,15 +174,6 @@ namespace Notesnook.API.Services
else else
{ {
userSettings.InboxKeys = keys.InboxKeys; userSettings.InboxKeys = keys.InboxKeys;
var defaultInboxKey = new InboxApiKey
{
UserId = userId,
Name = "Default",
DateCreated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryDate = DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds(),
LastUsedAt = 0
};
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
} }
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId); await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
@@ -208,6 +199,7 @@ namespace Notesnook.API.Services
Repositories.Colors.DeleteByUserId(userId); Repositories.Colors.DeleteByUserId(userId);
Repositories.Tags.DeleteByUserId(userId); Repositories.Tags.DeleteByUserId(userId);
Repositories.Vaults.DeleteByUserId(userId); Repositories.Vaults.DeleteByUserId(userId);
Repositories.InboxItemsHistory.DeleteByUserId(userId);
Repositories.UsersSettings.Delete((u) => u.UserId == userId); Repositories.UsersSettings.Delete((u) => u.UserId == userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId); Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId); Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
@@ -269,6 +261,7 @@ namespace Notesnook.API.Services
Repositories.Colors.DeleteByUserId(userId); Repositories.Colors.DeleteByUserId(userId);
Repositories.Tags.DeleteByUserId(userId); Repositories.Tags.DeleteByUserId(userId);
Repositories.Vaults.DeleteByUserId(userId); Repositories.Vaults.DeleteByUserId(userId);
Repositories.InboxItemsHistory.DeleteByUserId(userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId); Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId); Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
if (!await unit.Commit()) return false; if (!await unit.Commit()) return false;
+2 -1
View File
@@ -197,7 +197,8 @@ namespace Notesnook.API
.AddMongoCollection(Collections.ColorsKey) .AddMongoCollection(Collections.ColorsKey)
.AddMongoCollection(Collections.VaultsKey) .AddMongoCollection(Collections.VaultsKey)
.AddMongoCollection(Collections.InboxItemsKey) .AddMongoCollection(Collections.InboxItemsKey)
.AddMongoCollection(Collections.InboxApiKeysKey); .AddMongoCollection(Collections.InboxApiKeysKey)
.AddMongoCollection(Collections.InboxItemsHistoryKey);
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>(); services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
services.AddScoped<SyncDeviceService>(); services.AddScoped<SyncDeviceService>();
+15 -6
View File
@@ -17,7 +17,7 @@ const RawInboxItemSchema = z.object({
notebookIds: z.array(z.string()).optional(), notebookIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(), tagIds: z.array(z.string()).optional(),
type: z.enum(["note"]), type: z.enum(["note"]),
source: z.string(), source: z.string().min(1, "Source is required"),
version: z.literal(1), version: z.literal(1),
content: z content: z
.object({ .object({
@@ -56,7 +56,9 @@ async function encrypt(
}; };
} }
async function getInboxPublicEncryptionKey(apiKey: string) { async function getInboxPublicEncryptionKey(
apiKey: string,
): Promise<{ status: "unauthorized" } | { status: "ok"; key: string | null }> {
const response = await fetch( const response = await fetch(
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`, `${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
{ {
@@ -65,6 +67,9 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
}, },
}, },
); );
if (response.status === 401) {
return { status: "unauthorized" };
}
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`failed to fetch inbox public encryption key: ${await response.text()}`, `failed to fetch inbox public encryption key: ${await response.text()}`,
@@ -72,7 +77,7 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
} }
const data = (await response.json()) as unknown as any; const data = (await response.json()) as unknown as any;
return (data?.key as string) || null; return { status: "ok", key: (data?.key as string) || null };
} }
async function postEncryptedInboxItem( async function postEncryptedInboxItem(
@@ -110,10 +115,14 @@ app.post("/", async (req, res) => {
return res.status(401).json({ error: "unauthorized" }); return res.status(401).json({ error: "unauthorized" });
} }
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey); const encryptionKeyResult = await getInboxPublicEncryptionKey(apiKey);
if (!inboxPublicKey) { if (encryptionKeyResult.status === "unauthorized") {
return res.status(403).json({ error: "inbox public key not found" }); return res.status(401).json({ error: "unauthorized" });
} }
if (!encryptionKeyResult.key) {
return res.status(404).json({ error: "inbox public key not found" });
}
const inboxPublicKey = encryptionKeyResult.key;
console.log("[info] fetched inbox public key"); console.log("[info] fetched inbox public key");
const validationResult = RawInboxItemSchema.safeParse(req.body); const validationResult = RawInboxItemSchema.safeParse(req.body);
@@ -504,7 +504,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
widows: 2; widows: 2;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
@@ -536,7 +535,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
widows: 2; widows: 2;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
@@ -567,7 +565,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
widows: 2; widows: 2;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
@@ -309,7 +309,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
widows: 2; widows: 2;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
@@ -401,7 +401,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb( background-color: rgb(
@@ -467,7 +467,6 @@
text-align: start; text-align: start;
text-indent: 0px; text-indent: 0px;
text-transform: none; text-transform: none;
white-space: pre-wrap;
widows: 2; widows: 2;
word-spacing: 0px; word-spacing: 0px;
-webkit-text-stroke-width: 0px; -webkit-text-stroke-width: 0px;
@@ -483,9 +482,7 @@
display: inline; display: inline;
" "
><em ><em
>If you did not request to reset >If you did not request to reset your account password, you can safely ignore this email.</em
your account password, you can
safely ignore this email.</em
></span ></span
> >
</div> </div>
@@ -554,7 +551,6 @@
> >
<tbody> <tbody>
<tr> <tr>
.
<td <td
style=" style="
padding: 18px 0px 18px 0px; padding: 18px 0px 18px 0px;