Code
·
216 lines
·
8502 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216using MailSharp.MailClient.Models;
using MailSharp.MailClient.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace MailSharp.MailClient.Controllers.Api;
[ApiController]
[Route("api/settings")]
public class SettingsApiController(
IAccountStore accountStore,
SessionAccountManager sessionManager,
IImapService imapService,
IFolderSettingsStore folderSettingsStore,
IOptions<MailSettings> mailSettings,
LocalizationService localizer,
ILogger<SettingsApiController> logger) : ControllerBase
{
private readonly MailSettings _settings = mailSettings.Value;
private (Account account, string password)? GetActive()
{
var id = sessionManager.GetActiveAccountId();
if (id == null) return null;
var account = accountStore.Get(id.Value);
var password = sessionManager.GetPassword(id.Value);
if (account == null || password == null) return null;
return (account, password);
}
[HttpGet]
public IActionResult Get()
{
var active = GetActive();
if (active == null) return Unauthorized();
var settings = AccountSettingsDto.From(active.Value.account);
// Accounts created before these fields existed (or otherwise still at their zero default)
// show the app-wide default instead of a blank input - see also AuthApiController.Login,
// which sets these explicitly on brand-new accounts so this fallback rarely kicks in there.
if (settings.MessagesPerPage <= 0) settings.MessagesPerPage = _settings.MessageListPageSize;
if (settings.ContactsPerPage <= 0) settings.ContactsPerPage = _settings.ContactMailsPageSize;
return Ok(new SettingsStateDto
{
Settings = settings,
TimeZones = [.. TimeZoneInfo.GetSystemTimeZones().Select(ToIanaId).Distinct().OrderBy(x => x, StringComparer.OrdinalIgnoreCase)],
Languages = [.. localizer.AvailableLanguages().Select(l => new LanguageOptionDto { Code = l.Code, Name = l.Name })]
});
}
[HttpPost]
public IActionResult Save(AccountSettingsDto request)
{
var active = GetActive();
if (active == null) return Unauthorized();
var account = active.Value.account;
account.DisplayName = string.IsNullOrWhiteSpace(request.FriendlyName) ? account.EmailAddress : request.FriendlyName;
account.Signature = request.Signature;
account.MessagesPerPage = Math.Max(0, request.MessagesPerPage);
account.ContactsPerPage = Math.Max(0, request.ContactsPerPage);
account.AutoCheckIntervalMinutes = Math.Max(0, request.AutoCheckIntervalMinutes);
account.TimeZoneId = request.TimeZoneId;
account.Language = request.Language;
accountStore.Update(account);
if (localizer.LanguageExists(request.Language))
{
Response.Cookies.Append("lang", request.Language, new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddYears(1),
IsEssential = true,
SameSite = SameSiteMode.Lax,
HttpOnly = true
});
}
return Ok(AccountSettingsDto.From(account));
}
// The client (browser Intl API) only understands IANA ids; TimeZoneInfo.GetSystemTimeZones()
// returns Windows ids on Windows, so this is the single conversion point for the list exposed
// to the UI (see also Account.ResolveDefaultTimeZoneId, which does the same for the default).
private static string ToIanaId(TimeZoneInfo z) =>
OperatingSystem.IsWindows() && TimeZoneInfo.TryConvertWindowsIdToIanaId(z.Id, out var iana) ? iana : z.Id;
[HttpGet("folders")]
public async Task<IActionResult> Folders(bool refresh = false, CancellationToken ct = default)
{
var active = GetActive();
if (active == null) return Unauthorized();
var (account, password) = active.Value;
// Unlike the Mail app's folder sidebar, Mappen beheren's whole point is to let the user see
// and toggle folders they haven't subscribed to yet (e.g. one just created - see
// CreateFolderAsync) - so this is the one caller that needs every folder, not just LSUB's
// subscribed-only view.
return Ok(await imapService.GetFoldersAsync(account, password, includeSizes: true, forceRefreshSizes: refresh, includeUnsubscribed: true, ct: ct));
}
// The array's own sequence doubles as the new display order (see IFolderSettingsStore.
// SaveOrder) - the client always submits every row it currently shows, in its current
// (possibly drag-reordered) on-screen order, so there's no separate "save order" step.
[HttpPost("folders")]
public async Task<IActionResult> SaveFolders(SaveFolderSettingsRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
var (account, password) = active.Value;
var accountId = account.Id;
foreach (var f in request.Folders)
{
if (string.IsNullOrWhiteSpace(f.FullName)) continue;
folderSettingsStore.Save(accountId, f.FullName, f.SyncMode, f.Subscribed);
}
// The checkbox drives real IMAP subscription state (see GetFoldersAsync's includeUnsubscribed/
// SetFolderSubscriptionsAsync) - folderSettingsStore.Save above only records the app's own
// per-folder sync-mode preference now, Subscribed there is vestigial but left alone rather
// than migrating/removing it for a handful of leftover reads elsewhere.
var subscriptions = request.Folders
.Where(f => !string.IsNullOrWhiteSpace(f.FullName))
.ToDictionary(f => f.FullName, f => f.Subscribed);
if (subscriptions.Count > 0) await imapService.SetFolderSubscriptionsAsync(account, password, subscriptions, ct);
folderSettingsStore.SaveOrder(accountId, [.. request.Folders.Select(f => f.FullName).Where(n => !string.IsNullOrWhiteSpace(n))]);
return Ok();
}
[HttpPost("folders/add")]
public async Task<IActionResult> AddFolder(AddFolderRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest();
var (account, password) = active.Value;
await imapService.CreateFolderAsync(account, password, request.Name, ct);
return Ok();
}
[HttpPost("folders/rename")]
public async Task<IActionResult> RenameFolder(RenameFolderRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Folder) || string.IsNullOrWhiteSpace(request.NewName)) return BadRequest();
var (account, password) = active.Value;
try
{
await imapService.RenameFolderAsync(account, password, request.Folder, request.NewName, ct);
return Ok();
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("folders/move")]
public async Task<IActionResult> MoveFolder(MoveFolderRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Folder)) return BadRequest();
var (account, password) = active.Value;
try
{
await imapService.MoveFolderAsync(account, password, request.Folder, request.NewParent, ct);
return Ok();
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("folders/delete")]
public async Task<IActionResult> DeleteFolders(DeleteFoldersRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
var (account, password) = active.Value;
var failed = await imapService.DeleteFoldersAsync(account, password, request.FolderNames, ct);
foreach (var name in request.FolderNames.Except(failed))
folderSettingsStore.Remove(account.Id, name);
return Ok(new { failed });
}
// One-time bootstrap escape hatch for installs that already have accounts but no admin yet
// (e.g. upgraded from before IsAdmin existed). Only works while zero admins exist system-wide -
// once any account is an admin, this always 403s and further promotion is an admin-only action.
[HttpPost("claim-admin")]
public async Task<IActionResult> ClaimAdmin()
{
var active = GetActive();
if (active == null) return Unauthorized();
var account = active.Value.account;
if (accountStore.GetAll().Any(a => a.IsAdmin)) return Forbid();
account.IsAdmin = true;
accountStore.Update(account);
logger.LogInformation("Account {AccountId} ({EmailAddress}) claimed admin via bootstrap (no prior admin existed)", account.Id, account.EmailAddress);
// Re-issue the auth cookie so the admin claim takes effect immediately, without a full logout.
await sessionManager.SetActiveAccountIdAsync(account.Id);
return Ok(AccountDto.From(account));
}
}