Add mail command and API client for mail app management
- Add `mail` command to CLI with subcommands for mail apps, inboxes, and messages - Implement `smbcloud-mail` crate for mail API calls - Add mail data models to `smbcloud-model` - Wire mail command into CLI entrypoint - Update workspace and crate dependencies - Remove unused `smbcloud-mail-sdk` crate
keypair34 committed
May 2, 2026 at 19:17 UTC
0e1d1034c07310ba091c2f50f57437151e93d412
22 files changed
+1391
-6
Cargo.lock
+13
@@ -2543,6 +2543,7 @@ dependencies = [
2543
"serde_json",
2544
"serde_repr",
2545
"smbcloud-auth",
2546
+ "smbcloud-mail",
2547
"smbcloud-model",
2548
"smbcloud-network",
2549
"smbcloud-networking",
@@ -2588,6 +2589,18 @@ dependencies = [
2589
"tokio",
2590
]
2591
2592
+[[package]]
2593
+name = "smbcloud-mail"
2594
+version = "0.3.38"
2595
+dependencies = [
2596
+ "reqwest",
2597
+ "serde",
2598
+ "serde_json",
2599
+ "smbcloud-model",
2600
+ "smbcloud-network",
2601
+ "smbcloud-networking",
2602
+]
2603
+
2604
[[package]]
2605
name = "smbcloud-model"
2606
version = "0.3.35"
Cargo.toml
+1
@@ -29,6 +29,7 @@ serde_repr = "0.1"
29
smbcloud-auth = { version = "0.3", path = "crates/smbcloud-auth" }
30
smbcloud-auth-sdk = { version = "0.3", path = "crates/smbcloud-auth-sdk" }
31
smbcloud-gresiq-sdk = { version = "0.3", path = "crates/smbcloud-gresiq-sdk" }
32
+smbcloud-mail = { version = "0.3", path = "crates/smbcloud-mail" }
33
smbcloud-model = { version = "0.3", path = "crates/smbcloud-model" }
34
smbcloud-network = { version = "0.3", path = "crates/smbcloud-network" }
35
smbcloud-networking = { version = "0.3", path = "crates/smbcloud-networking" }
crates/cli/Cargo.toml
+1
@@ -44,6 +44,7 @@ serde = { workspace = true, features = ["derive"] }
44
serde_json = { workspace = true }
45
serde_repr = { workspace = true }
46
smbcloud-auth = { workspace = true }
47
+smbcloud-mail = { workspace = true }
48
smbcloud-model = { workspace = true }
49
smbcloud-network = { workspace = true }
50
smbcloud-networking = { workspace = true }
crates/cli/src/cli/mod.rs
+6
-1
@@ -1,5 +1,5 @@
1
use {
2
- crate::{account, project},
2
+ crate::{account, mail, project},
3
clap::{Parser, Subcommand},
4
smbcloud_network::environment::Environment,
5
spinners::Spinner,
@@ -65,4 +65,9 @@ pub enum Commands {
65
#[clap(subcommand)]
66
command: project::cli::Commands,
67
},
68
+ #[clap(about = "Manage smbCloud Mail.")]
69
+ Mail {
70
+ #[clap(subcommand)]
71
+ command: mail::cli::Commands,
72
+ },
73
}
crates/cli/src/lib.rs
+1
@@ -3,6 +3,7 @@ use smbcloud_networking::smb_client::SmbClient;
3
pub mod account;
4
pub mod cli;
5
pub mod deploy;
6
+pub mod mail;
7
pub mod project;
8
mod token;
9
mod ui;
crates/cli/src/mail.rs
new
+4
@@ -0,0 +1,4 @@
1
+pub mod cli;
2
+mod current_project;
3
+pub mod process;
4
+mod render;
crates/cli/src/mail/cli.rs
new
+122
@@ -0,0 +1,122 @@
1
+use clap::Subcommand;
2
+
3
+#[derive(Subcommand)]
4
+pub enum Commands {
5
+ #[clap(about = "List your mail apps.")]
6
+ List {
7
+ #[clap(long)]
8
+ project_id: Option<String>,
9
+ },
10
+ #[clap(about = "Show a mail app.")]
11
+ Show {
12
+ #[clap(long, required = true)]
13
+ id: String,
14
+ },
15
+ #[clap(about = "Create a mail app.")]
16
+ New {
17
+ #[clap(long, required = true)]
18
+ name: String,
19
+ #[clap(long, required = true)]
20
+ domain: String,
21
+ #[clap(long)]
22
+ project_id: Option<String>,
23
+ #[clap(long)]
24
+ aws_region: Option<String>,
25
+ },
26
+ #[clap(about = "Update a mail app.")]
27
+ Update {
28
+ #[clap(long, required = true)]
29
+ id: String,
30
+ #[clap(long)]
31
+ name: Option<String>,
32
+ #[clap(long)]
33
+ domain: Option<String>,
34
+ #[clap(long)]
35
+ aws_region: Option<String>,
36
+ },
37
+ #[clap(about = "Delete a mail app.")]
38
+ Delete {
39
+ #[clap(long, required = true)]
40
+ id: String,
41
+ },
42
+ #[clap(about = "Manage mail inbox routes.")]
43
+ Inbox {
44
+ #[clap(subcommand)]
45
+ command: InboxCommands,
46
+ },
47
+ #[clap(about = "Inspect inbound mail messages.")]
48
+ Message {
49
+ #[clap(subcommand)]
50
+ command: MessageCommands,
51
+ },
52
+}
53
+
54
+#[derive(Subcommand)]
55
+pub enum InboxCommands {
56
+ #[clap(about = "Create a mail inbox route.")]
57
+ New {
58
+ #[clap(long, required = true)]
59
+ app_id: String,
60
+ #[clap(long, required = true)]
61
+ local_part: String,
62
+ #[clap(long, required = true)]
63
+ forward_to: String,
64
+ #[clap(long)]
65
+ sender_email: Option<String>,
66
+ },
67
+ #[clap(about = "Update a mail inbox route.")]
68
+ Update {
69
+ #[clap(long, required = true)]
70
+ app_id: String,
71
+ #[clap(long, required = true)]
72
+ id: String,
73
+ #[clap(long)]
74
+ local_part: Option<String>,
75
+ #[clap(long)]
76
+ forward_to: Option<String>,
77
+ #[clap(long)]
78
+ sender_email: Option<String>,
79
+ },
80
+ #[clap(about = "Delete a mail inbox route.")]
81
+ Delete {
82
+ #[clap(long, required = true)]
83
+ app_id: String,
84
+ #[clap(long, required = true)]
85
+ id: String,
86
+ },
87
+ #[clap(about = "Send a test email for a mail inbox route.")]
88
+ Test {
89
+ #[clap(long, required = true)]
90
+ app_id: String,
91
+ #[clap(long, required = true)]
92
+ id: String,
93
+ #[clap(long)]
94
+ recipient_email: Option<String>,
95
+ #[clap(long)]
96
+ subject: Option<String>,
97
+ #[clap(long)]
98
+ body: Option<String>,
99
+ },
100
+}
101
+
102
+#[derive(Subcommand)]
103
+pub enum MessageCommands {
104
+ #[clap(about = "List inbound mail messages for an inbox.")]
105
+ List {
106
+ #[clap(long, required = true)]
107
+ app_id: String,
108
+ #[clap(long, required = true)]
109
+ inbox_id: String,
110
+ #[clap(long, default_value_t = 10)]
111
+ limit: u32,
112
+ },
113
+ #[clap(about = "Show one inbound mail message.")]
114
+ Show {
115
+ #[clap(long, required = true)]
116
+ app_id: String,
117
+ #[clap(long, required = true)]
118
+ inbox_id: String,
119
+ #[clap(long, required = true)]
120
+ id: String,
121
+ },
122
+}
crates/cli/src/mail/current_project.rs
new
+43
@@ -0,0 +1,43 @@
1
+use anyhow::{anyhow, Result};
2
+use smbcloud_model::project::Config;
3
+use smbcloud_network::environment::Environment;
4
+
5
+pub(crate) fn resolve_optional_project_id(
6
+ env: Environment,
7
+ project_id: Option<String>,
8
+) -> Result<Option<String>> {
9
+ match project_id {
10
+ Some(project_id) => Ok(Some(project_id)),
11
+ None => current_project_id(env),
12
+ }
13
+}
14
+
15
+pub(crate) fn resolve_required_project_id(
16
+ env: Environment,
17
+ project_id: Option<String>,
18
+) -> Result<String> {
19
+ match resolve_optional_project_id(env, project_id)? {
20
+ Some(project_id) => Ok(project_id),
21
+ None => Err(anyhow!(
22
+ "No project selected. Pass `--project-id` or run `smb project use --id <project-id>`."
23
+ )),
24
+ }
25
+}
26
+
27
+fn current_project_id(env: Environment) -> Result<Option<String>> {
28
+ let home_directory =
29
+ home::home_dir().ok_or_else(|| anyhow!("Failed to resolve your home directory."))?;
30
+ let config_path = home_directory.join(env.smb_dir()).join("config.json");
31
+
32
+ if !config_path.exists() {
33
+ return Ok(None);
34
+ }
35
+
36
+ let config_content = std::fs::read_to_string(&config_path)
37
+ .map_err(|error| anyhow!("Failed to read `{}`: {}", config_path.display(), error))?;
38
+
39
+ let config: Config = serde_json::from_str(&config_content)
40
+ .map_err(|error| anyhow!("Failed to parse `{}`: {}", config_path.display(), error))?;
41
+
42
+ Ok(config.current_project.map(|project| project.id.to_string()))
43
+}
crates/cli/src/mail/process.rs
new
+442
@@ -0,0 +1,442 @@
1
+use crate::{
2
+ cli::CommandResult,
3
+ client,
4
+ mail::{
5
+ cli::{Commands, InboxCommands, MessageCommands},
6
+ current_project::{resolve_optional_project_id, resolve_required_project_id},
7
+ render::{
8
+ print_mail_app_detail, print_mail_apps, print_mail_inbox_detail,
9
+ print_mail_message_detail, print_mail_messages, print_mail_test_delivery,
10
+ },
11
+ },
12
+ token::get_smb_token::get_smb_token,
13
+ ui::{
14
+ confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
15
+ succeed_symbol,
16
+ },
17
+};
18
+use anyhow::{anyhow, Result};
19
+use smbcloud_mail::{
20
+ mail_app::{create_mail_app, delete_mail_app, get_mail_app, get_mail_apps, update_mail_app},
21
+ mail_inbox::{create_mail_inbox, delete_mail_inbox, send_test_email, update_mail_inbox},
22
+ mail_message::{get_mail_message, get_mail_messages},
23
+};
24
+use smbcloud_model::mail::{
25
+ MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailTestEmailRequest,
26
+};
27
+use smbcloud_network::environment::Environment;
28
+use spinners::Spinner;
29
+
30
+pub async fn process_mail(env: Environment, commands: Commands) -> Result<CommandResult> {
31
+ match commands {
32
+ Commands::List { project_id } => process_mail_list(env, project_id).await,
33
+ Commands::Show { id } => process_mail_show(env, id).await,
34
+ Commands::New {
35
+ name,
36
+ domain,
37
+ project_id,
38
+ aws_region,
39
+ } => process_mail_new(env, name, domain, project_id, aws_region).await,
40
+ Commands::Update {
41
+ id,
42
+ name,
43
+ domain,
44
+ aws_region,
45
+ } => process_mail_update(env, id, name, domain, aws_region).await,
46
+ Commands::Delete { id } => process_mail_delete(env, id).await,
47
+ Commands::Inbox { command } => process_mail_inbox(env, command).await,
48
+ Commands::Message { command } => process_mail_message(env, command).await,
49
+ }
50
+}
51
+
52
+async fn process_mail_inbox(env: Environment, commands: InboxCommands) -> Result<CommandResult> {
53
+ match commands {
54
+ InboxCommands::New {
55
+ app_id,
56
+ local_part,
57
+ forward_to,
58
+ sender_email,
59
+ } => process_mail_inbox_new(env, app_id, local_part, forward_to, sender_email).await,
60
+ InboxCommands::Update {
61
+ app_id,
62
+ id,
63
+ local_part,
64
+ forward_to,
65
+ sender_email,
66
+ } => process_mail_inbox_update(env, app_id, id, local_part, forward_to, sender_email).await,
67
+ InboxCommands::Delete { app_id, id } => process_mail_inbox_delete(env, app_id, id).await,
68
+ InboxCommands::Test {
69
+ app_id,
70
+ id,
71
+ recipient_email,
72
+ subject,
73
+ body,
74
+ } => process_mail_inbox_test(env, app_id, id, recipient_email, subject, body).await,
75
+ }
76
+}
77
+
78
+async fn process_mail_message(
79
+ env: Environment,
80
+ commands: MessageCommands,
81
+) -> Result<CommandResult> {
82
+ match commands {
83
+ MessageCommands::List {
84
+ app_id,
85
+ inbox_id,
86
+ limit,
87
+ } => process_mail_message_list(env, app_id, inbox_id, limit).await,
88
+ MessageCommands::Show {
89
+ app_id,
90
+ inbox_id,
91
+ id,
92
+ } => process_mail_message_show(env, app_id, inbox_id, id).await,
93
+ }
94
+}
95
+
96
+async fn process_mail_list(env: Environment, project_id: Option<String>) -> Result<CommandResult> {
97
+ let access_token = get_smb_token(env)?;
98
+ let resolved_project_id = resolve_optional_project_id(env, normalize_optional(project_id))?;
99
+ let mut spinner = loading_spinner("Loading mail apps");
100
+
101
+ let mail_apps = get_mail_apps(env, client(), access_token, resolved_project_id)
102
+ .await
103
+ .map_err(api_error)?;
104
+
105
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
106
+ print_mail_apps(&mail_apps);
107
+
108
+ Ok(done_result(if mail_apps.is_empty() {
109
+ "No mail apps found."
110
+ } else {
111
+ "Done."
112
+ }))
113
+}
114
+
115
+async fn process_mail_show(env: Environment, id: String) -> Result<CommandResult> {
116
+ let access_token = get_smb_token(env)?;
117
+ let mail_app_id = normalize_required("mail app id", id)?;
118
+ let mut spinner = loading_spinner("Loading mail app");
119
+
120
+ let mail_app = get_mail_app(env, client(), access_token, mail_app_id)
121
+ .await
122
+ .map_err(api_error)?;
123
+
124
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
125
+ print_mail_app_detail(&mail_app);
126
+
127
+ Ok(done_result("Done."))
128
+}
129
+
130
+async fn process_mail_new(
131
+ env: Environment,
132
+ name: String,
133
+ domain: String,
134
+ project_id: Option<String>,
135
+ aws_region: Option<String>,
136
+) -> Result<CommandResult> {
137
+ let access_token = get_smb_token(env)?;
138
+ let mail_app = MailAppCreate {
139
+ name: normalize_required("name", name)?,
140
+ project_id: resolve_required_project_id(env, normalize_optional(project_id))?,
141
+ domain: normalize_required("domain", domain)?,
142
+ aws_region: normalize_optional(aws_region),
143
+ };
144
+ let mut spinner = loading_spinner("Creating mail app");
145
+
146
+ let mail_app = create_mail_app(env, client(), access_token, mail_app)
147
+ .await
148
+ .map_err(api_error)?;
149
+
150
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Created."));
151
+ print_mail_app_detail(&mail_app);
152
+
153
+ Ok(done_result("Mail app created."))
154
+}
155
+
156
+async fn process_mail_update(
157
+ env: Environment,
158
+ id: String,
159
+ name: Option<String>,
160
+ domain: Option<String>,
161
+ aws_region: Option<String>,
162
+) -> Result<CommandResult> {
163
+ let access_token = get_smb_token(env)?;
164
+ let mail_app_id = normalize_required("mail app id", id)?;
165
+ let update = MailAppUpdate {
166
+ name: normalize_optional(name),
167
+ domain: normalize_optional(domain),
168
+ aws_region: normalize_optional(aws_region),
169
+ };
170
+
171
+ if update.is_empty() {
172
+ return Err(anyhow!(
173
+ "Specify at least one of `--name`, `--domain`, or `--aws-region`."
174
+ ));
175
+ }
176
+
177
+ let mut spinner = loading_spinner("Updating mail app");
178
+ let mail_app = update_mail_app(env, client(), access_token, mail_app_id, update)
179
+ .await
180
+ .map_err(api_error)?;
181
+
182
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Updated."));
183
+ print_mail_app_detail(&mail_app);
184
+
185
+ Ok(done_result("Mail app updated."))
186
+}
187
+
188
+async fn process_mail_delete(env: Environment, id: String) -> Result<CommandResult> {
189
+ let mail_app_id = normalize_required("mail app id", id)?;
190
+ let confirmed = confirm_delete_tui(&format!("Delete mail app #{mail_app_id}"))
191
+ .map_err(|error| anyhow!(error))?;
192
+
193
+ if !confirmed {
194
+ return Ok(done_result("Cancelled."));
195
+ }
196
+
197
+ let access_token = get_smb_token(env)?;
198
+ let spinner = loading_spinner("Deleting mail app");
199
+
200
+ match delete_mail_app(env, client(), access_token, mail_app_id).await {
201
+ Ok(()) => Ok(CommandResult {
202
+ spinner,
203
+ symbol: succeed_symbol(),
204
+ msg: succeed_message("Done. Mail app has been deleted."),
205
+ }),
206
+ Err(error) => Ok(CommandResult {
207
+ spinner,
208
+ symbol: fail_symbol(),
209
+ msg: fail_message(&error.to_string()),
210
+ }),
211
+ }
212
+}
213
+
214
+async fn process_mail_inbox_new(
215
+ env: Environment,
216
+ app_id: String,
217
+ local_part: String,
218
+ forward_to: String,
219
+ sender_email: Option<String>,
220
+) -> Result<CommandResult> {
221
+ let access_token = get_smb_token(env)?;
222
+ let mail_inbox = MailInboxCreate {
223
+ local_part: normalize_required("local part", local_part)?,
224
+ forward_to_email: normalize_required("forward target", forward_to)?,
225
+ sender_email: normalize_optional(sender_email),
226
+ };
227
+ let mut spinner = loading_spinner("Creating mail inbox");
228
+
229
+ let mail_inbox = create_mail_inbox(
230
+ env,
231
+ client(),
232
+ access_token,
233
+ normalize_required("mail app id", app_id)?,
234
+ mail_inbox,
235
+ )
236
+ .await
237
+ .map_err(api_error)?;
238
+
239
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Created."));
240
+ print_mail_inbox_detail(&mail_inbox);
241
+
242
+ Ok(done_result("Mail inbox created."))
243
+}
244
+
245
+async fn process_mail_inbox_update(
246
+ env: Environment,
247
+ app_id: String,
248
+ id: String,
249
+ local_part: Option<String>,
250
+ forward_to: Option<String>,
251
+ sender_email: Option<String>,
252
+) -> Result<CommandResult> {
253
+ let access_token = get_smb_token(env)?;
254
+ let update = MailInboxUpdate {
255
+ local_part: normalize_optional(local_part),
256
+ forward_to_email: normalize_optional(forward_to),
257
+ sender_email: normalize_optional(sender_email),
258
+ };
259
+
260
+ if update.is_empty() {
261
+ return Err(anyhow!(
262
+ "Specify at least one of `--local-part`, `--forward-to`, or `--sender-email`."
263
+ ));
264
+ }
265
+
266
+ let mut spinner = loading_spinner("Updating mail inbox");
267
+ let mail_inbox = update_mail_inbox(
268
+ env,
269
+ client(),
270
+ access_token,
271
+ normalize_required("mail app id", app_id)?,
272
+ normalize_required("mail inbox id", id)?,
273
+ update,
274
+ )
275
+ .await
276
+ .map_err(api_error)?;
277
+
278
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Updated."));
279
+ print_mail_inbox_detail(&mail_inbox);
280
+
281
+ Ok(done_result("Mail inbox updated."))
282
+}
283
+
284
+async fn process_mail_inbox_delete(
285
+ env: Environment,
286
+ app_id: String,
287
+ id: String,
288
+) -> Result<CommandResult> {
289
+ let mail_app_id = normalize_required("mail app id", app_id)?;
290
+ let mail_inbox_id = normalize_required("mail inbox id", id)?;
291
+ let confirmed = confirm_delete_tui(&format!(
292
+ "Delete mail inbox #{mail_inbox_id} from mail app #{mail_app_id}"
293
+ ))
294
+ .map_err(|error| anyhow!(error))?;
295
+
296
+ if !confirmed {
297
+ return Ok(done_result("Cancelled."));
298
+ }
299
+
300
+ let access_token = get_smb_token(env)?;
301
+ let spinner = loading_spinner("Deleting mail inbox");
302
+
303
+ match delete_mail_inbox(env, client(), access_token, mail_app_id, mail_inbox_id).await {
304
+ Ok(()) => Ok(CommandResult {
305
+ spinner,
306
+ symbol: succeed_symbol(),
307
+ msg: succeed_message("Done. Mail inbox has been deleted."),
308
+ }),
309
+ Err(error) => Ok(CommandResult {
310
+ spinner,
311
+ symbol: fail_symbol(),
312
+ msg: fail_message(&error.to_string()),
313
+ }),
314
+ }
315
+}
316
+
317
+async fn process_mail_inbox_test(
318
+ env: Environment,
319
+ app_id: String,
320
+ id: String,
321
+ recipient_email: Option<String>,
322
+ subject: Option<String>,
323
+ body: Option<String>,
324
+) -> Result<CommandResult> {
325
+ let access_token = get_smb_token(env)?;
326
+ let request = MailTestEmailRequest {
327
+ recipient_email: normalize_optional(recipient_email),
328
+ subject: normalize_optional(subject),
329
+ body: normalize_optional(body),
330
+ };
331
+ let mut spinner = loading_spinner("Sending mail test email");
332
+
333
+ let delivery = send_test_email(
334
+ env,
335
+ client(),
336
+ access_token,
337
+ normalize_required("mail app id", app_id)?,
338
+ normalize_required("mail inbox id", id)?,
339
+ request,
340
+ )
341
+ .await
342
+ .map_err(api_error)?;
343
+
344
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Sent."));
345
+ print_mail_test_delivery(&delivery);
346
+
347
+ Ok(done_result("Mail test email sent."))
348
+}
349
+
350
+async fn process_mail_message_list(
351
+ env: Environment,
352
+ app_id: String,
353
+ inbox_id: String,
354
+ limit: u32,
355
+) -> Result<CommandResult> {
356
+ let access_token = get_smb_token(env)?;
357
+ let mut spinner = loading_spinner("Loading mail messages");
358
+
359
+ let messages = get_mail_messages(
360
+ env,
361
+ client(),
362
+ access_token,
363
+ normalize_required("mail app id", app_id)?,
364
+ normalize_required("mail inbox id", inbox_id)?,
365
+ Some(limit.clamp(1, 100)),
366
+ )
367
+ .await
368
+ .map_err(api_error)?;
369
+
370
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
371
+ print_mail_messages(&messages);
372
+
373
+ Ok(done_result(if messages.is_empty() {
374
+ "No mail messages found."
375
+ } else {
376
+ "Done."
377
+ }))
378
+}
379
+
380
+async fn process_mail_message_show(
381
+ env: Environment,
382
+ app_id: String,
383
+ inbox_id: String,
384
+ id: String,
385
+) -> Result<CommandResult> {
386
+ let access_token = get_smb_token(env)?;
387
+ let mut spinner = loading_spinner("Loading mail message");
388
+
389
+ let message = get_mail_message(
390
+ env,
391
+ client(),
392
+ access_token,
393
+ normalize_required("mail app id", app_id)?,
394
+ normalize_required("mail inbox id", inbox_id)?,
395
+ normalize_required("mail message id", id)?,
396
+ )
397
+ .await
398
+ .map_err(api_error)?;
399
+
400
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
401
+ print_mail_message_detail(&message);
402
+
403
+ Ok(done_result("Done."))
404
+}
405
+
406
+fn loading_spinner(message: &str) -> Spinner {
407
+ Spinner::new(
408
+ spinners::Spinners::SimpleDotsScrolling,
409
+ succeed_message(message),
410
+ )
411
+}
412
+
413
+fn done_result(message: &str) -> CommandResult {
414
+ CommandResult {
415
+ spinner: loading_spinner("Done"),
416
+ symbol: succeed_symbol(),
417
+ msg: succeed_message(message),
418
+ }
419
+}
420
+
421
+fn normalize_required(label: &str, value: String) -> Result<String> {
422
+ let normalized_value = value.trim().to_string();
423
+ if normalized_value.is_empty() {
424
+ return Err(anyhow!("{} cannot be empty.", label));
425
+ }
426
+ Ok(normalized_value)
427
+}
428
+
429
+fn normalize_optional(value: Option<String>) -> Option<String> {
430
+ value.and_then(|value| {
431
+ let normalized_value = value.trim().to_string();
432
+ if normalized_value.is_empty() {
433
+ None
434
+ } else {
435
+ Some(normalized_value)
436
+ }
437
+ })
438
+}
439
+
440
+fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
441
+ anyhow!(error.to_string())
442
+}
crates/cli/src/mail/render.rs
new
+184
@@ -0,0 +1,184 @@
1
+use console::style;
2
+use smbcloud_model::mail::{MailApp, MailInbox, MailMessage, MailTestEmailDelivery};
3
+use std::collections::HashMap;
4
+
5
+fn print_heading(title: &str) {
6
+ println!("\n{}", style(title).bold().underlined());
7
+}
8
+
9
+fn print_field(label: &str, value: impl AsRef<str>) {
10
+ println!("{} {}", style(format!("{label}:")).cyan(), value.as_ref());
11
+}
12
+
13
+fn print_optional_field(label: &str, value: Option<&str>) {
14
+ if let Some(value) = value {
15
+ print_field(label, value);
16
+ }
17
+}
18
+
19
+fn print_string_map(title: &str, values: &HashMap<String, String>) {
20
+ if values.is_empty() {
21
+ return;
22
+ }
23
+
24
+ print_heading(title);
25
+ let mut pairs: Vec<_> = values.iter().collect();
26
+ pairs.sort_by(|left, right| left.0.cmp(right.0));
27
+
28
+ for (key, value) in pairs {
29
+ print_field(key, value);
30
+ }
31
+}
32
+
33
+pub(crate) fn print_mail_apps(mail_apps: &[MailApp]) {
34
+ print_heading("Mail apps");
35
+
36
+ if mail_apps.is_empty() {
37
+ println!("No mail apps found.");
38
+ return;
39
+ }
40
+
41
+ for mail_app in mail_apps {
42
+ println!(
43
+ "#{} {} ({}) [{}] project={} inboxes={}",
44
+ mail_app.id,
45
+ style(&mail_app.name).bold(),
46
+ mail_app.domain,
47
+ mail_app.status,
48
+ mail_app.project_id,
49
+ mail_app.inboxes.len()
50
+ );
51
+ }
52
+}
53
+
54
+pub(crate) fn print_mail_app_detail(mail_app: &MailApp) {
55
+ print_heading("Mail app");
56
+ print_field("ID", mail_app.id.to_string());
57
+ print_field("Name", &mail_app.name);
58
+ print_field("Domain", &mail_app.domain);
59
+ print_field("Status", mail_app.status.to_string());
60
+ print_field("AWS region", &mail_app.aws_region);
61
+ print_field("Project ID", mail_app.project_id.to_string());
62
+ print_field("Tenant ID", mail_app.tenant_id.to_string());
63
+ print_field("SES inbound MX", &mail_app.ses_inbound_mx_value);
64
+ print_field("Inbound bucket", &mail_app.inbound_bucket_name);
65
+ print_field("Inbound prefix", &mail_app.inbound_email_key_prefix);
66
+ print_field("Created at", mail_app.created_at.to_rfc3339());
67
+ print_field("Updated at", mail_app.updated_at.to_rfc3339());
68
+ print_string_map("Lambda config", &mail_app.lambda_config);
69
+
70
+ print_heading("Inboxes");
71
+ if mail_app.inboxes.is_empty() {
72
+ println!("No inboxes configured.");
73
+ return;
74
+ }
75
+
76
+ for inbox in &mail_app.inboxes {
77
+ println!(
78
+ "#{} {} -> {} [{}]",
79
+ inbox.id,
80
+ style(&inbox.full_address).bold(),
81
+ inbox.forward_to_email,
82
+ inbox.status
83
+ );
84
+ }
85
+}
86
+
87
+pub(crate) fn print_mail_inbox_detail(mail_inbox: &MailInbox) {
88
+ print_heading("Mail inbox");
89
+ print_field("ID", mail_inbox.id.to_string());
90
+ print_field("Mail app ID", mail_inbox.mail_app_id.to_string());
91
+ print_field("Project ID", mail_inbox.project_id.to_string());
92
+ print_field("Tenant ID", mail_inbox.tenant_id.to_string());
93
+ print_field("Local part", &mail_inbox.local_part);
94
+ print_field("Inbox email", &mail_inbox.inbox_email);
95
+ print_field("Forward target", &mail_inbox.forward_to_email);
96
+ print_field("Sender email", &mail_inbox.sender_email);
97
+ print_field("Status", mail_inbox.status.to_string());
98
+ print_field("Email key prefix", &mail_inbox.email_key_prefix);
99
+ print_field("Created at", mail_inbox.created_at.to_rfc3339());
100
+ print_field("Updated at", mail_inbox.updated_at.to_rfc3339());
101
+ print_optional_field(
102
+ "Last test email sent at",
103
+ mail_inbox
104
+ .last_test_email_sent_at
105
+ .as_ref()
106
+ .map(|value| value.to_rfc3339())
107
+ .as_deref(),
108
+ );
109
+ print_string_map("Lambda config", &mail_inbox.lambda_config);
110
+}
111
+
112
+pub(crate) fn print_mail_test_delivery(delivery: &MailTestEmailDelivery) {
113
+ print_heading("Mail test delivery");
114
+ print_field("Message ID", &delivery.message_id);
115
+ print_field("Recipient", &delivery.recipient_email);
116
+ print_field("Delivery method", &delivery.delivery_method);
117
+ print_field("Sent at", &delivery.sent_at);
118
+}
119
+
120
+pub(crate) fn print_mail_messages(messages: &[MailMessage]) {
121
+ print_heading("Mail messages");
122
+
123
+ if messages.is_empty() {
124
+ println!("No mail messages found.");
125
+ return;
126
+ }
127
+
128
+ for message in messages {
129
+ println!(
130
+ "#{} {} from {} [{}]",
131
+ message.id,
132
+ message
133
+ .subject
134
+ .clone()
135
+ .unwrap_or_else(|| "(no subject)".to_string()),
136
+ message.from_email,
137
+ message.status
138
+ );
139
+ if let Some(text_preview) = &message.text_preview {
140
+ println!(" {}", text_preview);
141
+ }
142
+ }
143
+}
144
+
145
+pub(crate) fn print_mail_message_detail(message: &MailMessage) {
146
+ print_heading("Mail message");
147
+ print_field("ID", message.id.to_string());
148
+ print_field("Inbox ID", message.mail_inbox_id.to_string());
149
+ print_field("Status", message.status.to_string());
150
+ print_field("Provider message ID", &message.provider_message_id);
151
+ print_field("Original recipient", &message.original_recipient_email);
152
+ print_field("From email", &message.from_email);
153
+ print_optional_field("From name", message.from_name.as_deref());
154
+ print_optional_field("Subject", message.subject.as_deref());
155
+ print_optional_field("Text preview", message.text_preview.as_deref());
156
+ print_optional_field("Forward error", message.forward_error.as_deref());
157
+ print_field("Received at", message.received_at.to_rfc3339());
158
+ print_optional_field(
159
+ "Forwarded at",
160
+ message
161
+ .forwarded_at
162
+ .as_ref()
163
+ .map(|value| value.to_rfc3339())
164
+ .as_deref(),
165
+ );
166
+ print_optional_field(
167
+ "Size bytes",
168
+ message.size_bytes.map(|value| value.to_string()).as_deref(),
169
+ );
170
+ if !message.to_emails.is_empty() {
171
+ print_field("To", message.to_emails.join(", "));
172
+ }
173
+ if !message.cc_emails.is_empty() {
174
+ print_field("CC", message.cc_emails.join(", "));
175
+ }
176
+ if !message.forward_recipients.is_empty() {
177
+ print_field("Forward recipients", message.forward_recipients.join(", "));
178
+ }
179
+ print_optional_field("S3 bucket", message.s3_bucket.as_deref());
180
+ print_optional_field("S3 key", message.s3_key.as_deref());
181
+ print_optional_field("Text body", message.text_body.as_deref());
182
+ print_optional_field("HTML body", message.html_body.as_deref());
183
+ print_string_map("Headers", &message.headers_json);
184
+}
crates/cli/src/main.rs
+3
@@ -7,6 +7,7 @@ use {
7
clear_smb_token,
8
cli::{Cli, CommandResult, Commands},
9
deploy::process_deploy::process_deploy,
10
+ mail::process::process_mail,
11
project::{crud_create::process_project_init, process::process_project},
12
},
13
smbcloud_network::{environment::Environment, network::check_internet_connection},
@@ -125,6 +126,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
126
| Some(Commands::Login {})
127
| Some(Commands::Logout {})
128
| Some(Commands::Account { .. })
129
+ | Some(Commands::Mail { .. })
130
| Some(Commands::Project { .. })
131
| None => true,
132
Some(Commands::Init {}) => true,
@@ -144,6 +146,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
146
Some(Commands::Account { command }) => process_account(cli.environment, command).await,
147
Some(Commands::Login {}) => process_login(cli.environment, None).await,
148
Some(Commands::Logout {}) => process_logout(cli.environment).await,
149
+ Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
150
Some(Commands::Project { command }) => process_project(cli.environment, command).await,
151
None => process_deploy(cli.environment, None).await,
152
}
crates/cli/src/project/crud_read.rs
+4
-2
@@ -105,12 +105,14 @@ pub(crate) async fn process_project_use(env: Environment, id: String) -> Result<
105
);
106
match home::home_dir() {
107
Some(path) => {
108
- debug!("{}", path.to_str().unwrap());
108
+ let config_directory = path.join(env.smb_dir());
109
+ std::fs::create_dir_all(&config_directory)?;
110
+ debug!("{}", config_directory.display());
111
let mut file = OpenOptions::new()
112
.create(true)
113
.truncate(true)
114
.write(true)
113
- .open([path.to_str().unwrap(), "/.smb/config.json"].join(""))?;
115
+ .open(config_directory.join("config.json"))?;
116
let json = serde_json::to_string(&config)?;
117
file.write_all(json.as_bytes())?;
118
crates/smbcloud-mail-sdk/README.md
deleted
-3
@@ -1,3 +0,0 @@
1
-# smbcloud-mail-sdk
2
-
3
-smbCloud [Mail](https://upinipin.fandom.com/ms/wiki/Mail) native SDK.
crates/smbcloud-mail/Cargo.toml
new
+26
@@ -0,0 +1,26 @@
1
+[package]
2
+name = "smbcloud-mail"
3
+version = "0.3.38"
4
+edition = "2021"
5
+authors = ["Seto Elkahfi <hej@setoelkahfi.se>"]
6
+description = "smbCloud mail API client for the CLI."
7
+license = "Apache-2.0"
8
+repository = "https://github.com/smbcloudXYZ/smbcloud-cli"
9
+documentation = "https://smbcloud.xyz/posts"
10
+keywords = ["cli", "cloud", "mail", "smbcloud", "email"]
11
+categories = [
12
+ "command-line-utilities",
13
+ "api-bindings",
14
+]
15
+readme = "README.md"
16
+
17
+[lib]
18
+path = "src/smbcloud_mail.rs"
19
+
20
+[dependencies]
21
+reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
22
+serde = { workspace = true, features = ["derive"] }
23
+serde_json = { workspace = true }
24
+smbcloud-model = { workspace = true }
25
+smbcloud-network = { workspace = true }
26
+smbcloud-networking = { workspace = true }
crates/smbcloud-mail/src/mail_app.rs
new
+85
@@ -0,0 +1,85 @@
1
+use crate::{
2
+ request::request_empty,
3
+ url_builder::{build_mail_app_url, build_mail_apps_url},
4
+};
5
+use reqwest::Client;
6
+use serde::Serialize;
7
+use smbcloud_model::{
8
+ error_codes::ErrorResponse,
9
+ mail::{MailApp, MailAppCreate, MailAppUpdate},
10
+};
11
+use smbcloud_network::{environment::Environment, network::request};
12
+use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
13
+
14
+#[derive(Serialize)]
15
+struct MailAppEnvelope<T> {
16
+ mail_app: T,
17
+}
18
+
19
+pub async fn get_mail_apps(
20
+ env: Environment,
21
+ client: (&SmbClient, &str),
22
+ access_token: String,
23
+ project_id: Option<String>,
24
+) -> Result<Vec<MailApp>, ErrorResponse> {
25
+ let builder = Client::new()
26
+ .get(build_mail_apps_url(env, client, project_id.as_deref()))
27
+ .header("Authorization", access_token)
28
+ .header("User-agent", SMB_USER_AGENT);
29
+ request(builder).await
30
+}
31
+
32
+pub async fn get_mail_app(
33
+ env: Environment,
34
+ client: (&SmbClient, &str),
35
+ access_token: String,
36
+ mail_app_id: String,
37
+) -> Result<MailApp, ErrorResponse> {
38
+ let builder = Client::new()
39
+ .get(build_mail_app_url(env, client, &mail_app_id))
40
+ .header("Authorization", access_token)
41
+ .header("User-agent", SMB_USER_AGENT);
42
+ request(builder).await
43
+}
44
+
45
+pub async fn create_mail_app(
46
+ env: Environment,
47
+ client: (&SmbClient, &str),
48
+ access_token: String,
49
+ mail_app: MailAppCreate,
50
+) -> Result<MailApp, ErrorResponse> {
51
+ let builder = Client::new()
52
+ .post(build_mail_apps_url(env, client, None))
53
+ .json(&MailAppEnvelope { mail_app })
54
+ .header("Authorization", access_token)
55
+ .header("User-agent", SMB_USER_AGENT);
56
+ request(builder).await
57
+}
58
+
59
+pub async fn update_mail_app(
60
+ env: Environment,
61
+ client: (&SmbClient, &str),
62
+ access_token: String,
63
+ mail_app_id: String,
64
+ mail_app: MailAppUpdate,
65
+) -> Result<MailApp, ErrorResponse> {
66
+ let builder = Client::new()
67
+ .put(build_mail_app_url(env, client, &mail_app_id))
68
+ .json(&MailAppEnvelope { mail_app })
69
+ .header("Authorization", access_token)
70
+ .header("User-agent", SMB_USER_AGENT);
71
+ request(builder).await
72
+}
73
+
74
+pub async fn delete_mail_app(
75
+ env: Environment,
76
+ client: (&SmbClient, &str),
77
+ access_token: String,
78
+ mail_app_id: String,
79
+) -> Result<(), ErrorResponse> {
80
+ let builder = Client::new()
81
+ .delete(build_mail_app_url(env, client, &mail_app_id))
82
+ .header("Authorization", access_token)
83
+ .header("User-agent", SMB_USER_AGENT);
84
+ request_empty(builder).await
85
+}
crates/smbcloud-mail/src/mail_inbox.rs
new
+85
@@ -0,0 +1,85 @@
1
+use crate::{
2
+ request::request_empty,
3
+ url_builder::{build_mail_inbox_test_url, build_mail_inbox_url, build_mail_inboxes_url},
4
+};
5
+use reqwest::Client;
6
+use serde::Serialize;
7
+use smbcloud_model::{
8
+ error_codes::ErrorResponse,
9
+ mail::{
10
+ MailInbox, MailInboxCreate, MailInboxUpdate, MailTestEmailDelivery, MailTestEmailRequest,
11
+ },
12
+};
13
+use smbcloud_network::{environment::Environment, network::request};
14
+use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
15
+
16
+#[derive(Serialize)]
17
+struct MailInboxEnvelope<T> {
18
+ mail_inbox: T,
19
+}
20
+
21
+pub async fn create_mail_inbox(
22
+ env: Environment,
23
+ client: (&SmbClient, &str),
24
+ access_token: String,
25
+ mail_app_id: String,
26
+ mail_inbox: MailInboxCreate,
27
+) -> Result<MailInbox, ErrorResponse> {
28
+ let builder = Client::new()
29
+ .post(build_mail_inboxes_url(env, client, &mail_app_id))
30
+ .json(&MailInboxEnvelope { mail_inbox })
31
+ .header("Authorization", access_token)
32
+ .header("User-agent", SMB_USER_AGENT);
33
+ request(builder).await
34
+}
35
+
36
+pub async fn update_mail_inbox(
37
+ env: Environment,
38
+ client: (&SmbClient, &str),
39
+ access_token: String,
40
+ mail_app_id: String,
41
+ inbox_id: String,
42
+ mail_inbox: MailInboxUpdate,
43
+) -> Result<MailInbox, ErrorResponse> {
44
+ let builder = Client::new()
45
+ .put(build_mail_inbox_url(env, client, &mail_app_id, &inbox_id))
46
+ .json(&MailInboxEnvelope { mail_inbox })
47
+ .header("Authorization", access_token)
48
+ .header("User-agent", SMB_USER_AGENT);
49
+ request(builder).await
50
+}
51
+
52
+pub async fn delete_mail_inbox(
53
+ env: Environment,
54
+ client: (&SmbClient, &str),
55
+ access_token: String,
56
+ mail_app_id: String,
57
+ inbox_id: String,
58
+) -> Result<(), ErrorResponse> {
59
+ let builder = Client::new()
60
+ .delete(build_mail_inbox_url(env, client, &mail_app_id, &inbox_id))
61
+ .header("Authorization", access_token)
62
+ .header("User-agent", SMB_USER_AGENT);
63
+ request_empty(builder).await
64
+}
65
+
66
+pub async fn send_test_email(
67
+ env: Environment,
68
+ client: (&SmbClient, &str),
69
+ access_token: String,
70
+ mail_app_id: String,
71
+ inbox_id: String,
72
+ mail_inbox: MailTestEmailRequest,
73
+) -> Result<MailTestEmailDelivery, ErrorResponse> {
74
+ let builder = Client::new()
75
+ .post(build_mail_inbox_test_url(
76
+ env,
77
+ client,
78
+ &mail_app_id,
79
+ &inbox_id,
80
+ ))
81
+ .json(&MailInboxEnvelope { mail_inbox })
82
+ .header("Authorization", access_token)
83
+ .header("User-agent", SMB_USER_AGENT);
84
+ request(builder).await
85
+}
crates/smbcloud-mail/src/mail_message.rs
new
+47
@@ -0,0 +1,47 @@
1
+use crate::url_builder::{build_mail_message_url, build_mail_messages_url};
2
+use reqwest::Client;
3
+use smbcloud_model::{error_codes::ErrorResponse, mail::MailMessage};
4
+use smbcloud_network::{environment::Environment, network::request};
5
+use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
6
+
7
+pub async fn get_mail_messages(
8
+ env: Environment,
9
+ client: (&SmbClient, &str),
10
+ access_token: String,
11
+ mail_app_id: String,
12
+ inbox_id: String,
13
+ limit: Option<u32>,
14
+) -> Result<Vec<MailMessage>, ErrorResponse> {
15
+ let builder = Client::new()
16
+ .get(build_mail_messages_url(
17
+ env,
18
+ client,
19
+ &mail_app_id,
20
+ &inbox_id,
21
+ limit,
22
+ ))
23
+ .header("Authorization", access_token)
24
+ .header("User-agent", SMB_USER_AGENT);
25
+ request(builder).await
26
+}
27
+
28
+pub async fn get_mail_message(
29
+ env: Environment,
30
+ client: (&SmbClient, &str),
31
+ access_token: String,
32
+ mail_app_id: String,
33
+ inbox_id: String,
34
+ message_id: String,
35
+) -> Result<MailMessage, ErrorResponse> {
36
+ let builder = Client::new()
37
+ .get(build_mail_message_url(
38
+ env,
39
+ client,
40
+ &mail_app_id,
41
+ &inbox_id,
42
+ &message_id,
43
+ ))
44
+ .header("Authorization", access_token)
45
+ .header("User-agent", SMB_USER_AGENT);
46
+ request(builder).await
47
+}
crates/smbcloud-mail/src/request.rs
new
+30
@@ -0,0 +1,30 @@
1
+use reqwest::{RequestBuilder, StatusCode};
2
+use serde_json::Value;
3
+use smbcloud_model::error_codes::{ErrorCode, ErrorResponse};
4
+use smbcloud_network::network::{check_internet_connection, parse_error_response};
5
+
6
+pub(crate) async fn request_empty(builder: RequestBuilder) -> Result<(), ErrorResponse> {
7
+ if !check_internet_connection().await {
8
+ return Err(ErrorResponse::Error {
9
+ error_code: ErrorCode::NetworkError,
10
+ message: "No internet connection. Please check your network settings and try again."
11
+ .to_string(),
12
+ });
13
+ }
14
+
15
+ let response = builder.send().await.map_err(|_| ErrorResponse::Error {
16
+ error_code: ErrorCode::NetworkError,
17
+ message: ErrorCode::NetworkError.message(None).to_string(),
18
+ })?;
19
+
20
+ match response.status() {
21
+ StatusCode::OK | StatusCode::CREATED | StatusCode::NO_CONTENT => Ok(()),
22
+ _ => match parse_error_response::<Value>(response).await {
23
+ Ok(_) => Err(ErrorResponse::Error {
24
+ error_code: ErrorCode::ParseError,
25
+ message: ErrorCode::ParseError.message(None).to_string(),
26
+ }),
27
+ Err(error_response) => Err(error_response),
28
+ },
29
+ }
30
+}
crates/smbcloud-mail/src/smbcloud_mail.rs
new
+5
@@ -0,0 +1,5 @@
1
+pub mod mail_app;
2
+pub mod mail_inbox;
3
+pub mod mail_message;
4
+mod request;
5
+mod url_builder;
crates/smbcloud-mail/src/url_builder.rs
new
+106
@@ -0,0 +1,106 @@
1
+use {
2
+ smbcloud_network::environment::Environment,
3
+ smbcloud_networking::{smb_base_url_builder, smb_client::SmbClient},
4
+};
5
+
6
+pub(crate) fn build_mail_apps_url(
7
+ env: Environment,
8
+ client: (&SmbClient, &str),
9
+ project_id: Option<&str>,
10
+) -> String {
11
+ let mut url_builder = smb_base_url_builder(env, client);
12
+ url_builder.add_route("v1/mail_apps");
13
+ if let Some(project_id) = project_id {
14
+ url_builder.add_param("project_id", project_id);
15
+ }
16
+ url_builder.build()
17
+}
18
+
19
+pub(crate) fn build_mail_app_url(
20
+ env: Environment,
21
+ client: (&SmbClient, &str),
22
+ mail_app_id: &str,
23
+) -> String {
24
+ let mut url_builder = smb_base_url_builder(env, client);
25
+ url_builder.add_route("v1/mail_apps");
26
+ url_builder.add_route(mail_app_id);
27
+ url_builder.build()
28
+}
29
+
30
+pub(crate) fn build_mail_inboxes_url(
31
+ env: Environment,
32
+ client: (&SmbClient, &str),
33
+ mail_app_id: &str,
34
+) -> String {
35
+ let mut url_builder = smb_base_url_builder(env, client);
36
+ url_builder.add_route("v1/mail_apps");
37
+ url_builder.add_route(mail_app_id);
38
+ url_builder.add_route("inboxes");
39
+ url_builder.build()
40
+}
41
+
42
+pub(crate) fn build_mail_inbox_url(
43
+ env: Environment,
44
+ client: (&SmbClient, &str),
45
+ mail_app_id: &str,
46
+ inbox_id: &str,
47
+) -> String {
48
+ let mut url_builder = smb_base_url_builder(env, client);
49
+ url_builder.add_route("v1/mail_apps");
50
+ url_builder.add_route(mail_app_id);
51
+ url_builder.add_route("inboxes");
52
+ url_builder.add_route(inbox_id);
53
+ url_builder.build()
54
+}
55
+
56
+pub(crate) fn build_mail_inbox_test_url(
57
+ env: Environment,
58
+ client: (&SmbClient, &str),
59
+ mail_app_id: &str,
60
+ inbox_id: &str,
61
+) -> String {
62
+ let mut url_builder = smb_base_url_builder(env, client);
63
+ url_builder.add_route("v1/mail_apps");
64
+ url_builder.add_route(mail_app_id);
65
+ url_builder.add_route("inboxes");
66
+ url_builder.add_route(inbox_id);
67
+ url_builder.add_route("send_test_email");
68
+ url_builder.build()
69
+}
70
+
71
+pub(crate) fn build_mail_messages_url(
72
+ env: Environment,
73
+ client: (&SmbClient, &str),
74
+ mail_app_id: &str,
75
+ inbox_id: &str,
76
+ limit: Option<u32>,
77
+) -> String {
78
+ let mut url_builder = smb_base_url_builder(env, client);
79
+ url_builder.add_route("v1/mail_apps");
80
+ url_builder.add_route(mail_app_id);
81
+ url_builder.add_route("inboxes");
82
+ url_builder.add_route(inbox_id);
83
+ url_builder.add_route("messages");
84
+ if let Some(limit) = limit {
85
+ let limit_string = limit.to_string();
86
+ url_builder.add_param("limit", &limit_string);
87
+ }
88
+ url_builder.build()
89
+}
90
+
91
+pub(crate) fn build_mail_message_url(
92
+ env: Environment,
93
+ client: (&SmbClient, &str),
94
+ mail_app_id: &str,
95
+ inbox_id: &str,
96
+ message_id: &str,
97
+) -> String {
98
+ let mut url_builder = smb_base_url_builder(env, client);
99
+ url_builder.add_route("v1/mail_apps");
100
+ url_builder.add_route(mail_app_id);
101
+ url_builder.add_route("inboxes");
102
+ url_builder.add_route(inbox_id);
103
+ url_builder.add_route("messages");
104
+ url_builder.add_route(message_id);
105
+ url_builder.build()
106
+}
crates/smbcloud-model/src/lib.rs
+1
@@ -4,6 +4,7 @@ pub mod error_codes;
4
pub mod forgot;
5
pub mod frontend_app;
6
pub mod login;
7
+pub mod mail;
8
pub mod oauth;
9
pub mod project;
10
pub mod repository;
crates/smbcloud-model/src/mail.rs
new
+182
@@ -0,0 +1,182 @@
1
+use {
2
+ chrono::{DateTime, Utc},
3
+ serde::{Deserialize, Serialize},
4
+ serde_repr::{Deserialize_repr, Serialize_repr},
5
+ std::{collections::HashMap, fmt::Display},
6
+};
7
+
8
+#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
9
+#[repr(u8)]
10
+pub enum MailStatus {
11
+ #[default]
12
+ PendingDns = 0,
13
+ Active = 1,
14
+ Suspended = 2,
15
+}
16
+
17
+impl Display for MailStatus {
18
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19
+ match self {
20
+ MailStatus::PendingDns => write!(f, "pending_dns"),
21
+ MailStatus::Active => write!(f, "active"),
22
+ MailStatus::Suspended => write!(f, "suspended"),
23
+ }
24
+ }
25
+}
26
+
27
+#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
28
+#[repr(u8)]
29
+pub enum MailMessageStatus {
30
+ #[default]
31
+ Received = 0,
32
+ Forwarded = 1,
33
+ ForwardFailed = 2,
34
+}
35
+
36
+impl Display for MailMessageStatus {
37
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38
+ match self {
39
+ MailMessageStatus::Received => write!(f, "received"),
40
+ MailMessageStatus::Forwarded => write!(f, "forwarded"),
41
+ MailMessageStatus::ForwardFailed => write!(f, "forward_failed"),
42
+ }
43
+ }
44
+}
45
+
46
+#[derive(Deserialize, Serialize, Debug, Clone)]
47
+pub struct MailApp {
48
+ pub id: i32,
49
+ pub name: String,
50
+ pub domain: String,
51
+ pub aws_region: String,
52
+ pub project_id: i32,
53
+ pub tenant_id: i32,
54
+ pub created_at: DateTime<Utc>,
55
+ pub updated_at: DateTime<Utc>,
56
+ pub status: MailStatus,
57
+ pub ses_inbound_mx_value: String,
58
+ pub inbound_bucket_name: String,
59
+ pub inbound_email_key_prefix: String,
60
+ #[serde(default)]
61
+ pub lambda_config: HashMap<String, String>,
62
+ #[serde(default)]
63
+ pub inboxes: Vec<MailInbox>,
64
+}
65
+
66
+#[derive(Deserialize, Serialize, Debug, Clone)]
67
+pub struct MailInbox {
68
+ pub id: i32,
69
+ pub local_part: String,
70
+ pub full_address: String,
71
+ pub inbox_email: String,
72
+ pub sender_email: String,
73
+ pub forward_to_email: String,
74
+ pub mail_app_id: i32,
75
+ pub project_id: i32,
76
+ pub tenant_id: i32,
77
+ pub last_test_email_sent_at: Option<DateTime<Utc>>,
78
+ pub created_at: DateTime<Utc>,
79
+ pub updated_at: DateTime<Utc>,
80
+ pub status: MailStatus,
81
+ pub email_key_prefix: String,
82
+ #[serde(default)]
83
+ pub lambda_config: HashMap<String, String>,
84
+}
85
+
86
+#[derive(Deserialize, Serialize, Debug, Clone)]
87
+pub struct MailMessage {
88
+ pub id: i32,
89
+ pub mail_inbox_id: i32,
90
+ pub provider_message_id: String,
91
+ pub original_recipient_email: String,
92
+ pub from_email: String,
93
+ pub from_name: Option<String>,
94
+ pub subject: Option<String>,
95
+ pub text_preview: Option<String>,
96
+ pub size_bytes: Option<i64>,
97
+ pub forwarded_at: Option<DateTime<Utc>>,
98
+ pub forward_error: Option<String>,
99
+ pub received_at: DateTime<Utc>,
100
+ pub created_at: DateTime<Utc>,
101
+ pub updated_at: DateTime<Utc>,
102
+ pub status: MailMessageStatus,
103
+ pub text_body: Option<String>,
104
+ pub html_body: Option<String>,
105
+ #[serde(default)]
106
+ pub to_emails: Vec<String>,
107
+ #[serde(default)]
108
+ pub cc_emails: Vec<String>,
109
+ #[serde(default)]
110
+ pub forward_recipients: Vec<String>,
111
+ #[serde(default)]
112
+ pub headers_json: HashMap<String, String>,
113
+ pub s3_bucket: Option<String>,
114
+ pub s3_key: Option<String>,
115
+}
116
+
117
+#[derive(Serialize, Debug, Clone)]
118
+pub struct MailAppCreate {
119
+ pub name: String,
120
+ pub project_id: String,
121
+ pub domain: String,
122
+ #[serde(skip_serializing_if = "Option::is_none")]
123
+ pub aws_region: Option<String>,
124
+}
125
+
126
+#[derive(Serialize, Debug, Clone, Default)]
127
+pub struct MailAppUpdate {
128
+ #[serde(skip_serializing_if = "Option::is_none")]
129
+ pub name: Option<String>,
130
+ #[serde(skip_serializing_if = "Option::is_none")]
131
+ pub domain: Option<String>,
132
+ #[serde(skip_serializing_if = "Option::is_none")]
133
+ pub aws_region: Option<String>,
134
+}
135
+
136
+impl MailAppUpdate {
137
+ pub fn is_empty(&self) -> bool {
138
+ self.name.is_none() && self.domain.is_none() && self.aws_region.is_none()
139
+ }
140
+}
141
+
142
+#[derive(Serialize, Debug, Clone)]
143
+pub struct MailInboxCreate {
144
+ pub local_part: String,
145
+ pub forward_to_email: String,
146
+ #[serde(skip_serializing_if = "Option::is_none")]
147
+ pub sender_email: Option<String>,
148
+}
149
+
150
+#[derive(Serialize, Debug, Clone, Default)]
151
+pub struct MailInboxUpdate {
152
+ #[serde(skip_serializing_if = "Option::is_none")]
153
+ pub local_part: Option<String>,
154
+ #[serde(skip_serializing_if = "Option::is_none")]
155
+ pub forward_to_email: Option<String>,
156
+ #[serde(skip_serializing_if = "Option::is_none")]
157
+ pub sender_email: Option<String>,
158
+}
159
+
160
+impl MailInboxUpdate {
161
+ pub fn is_empty(&self) -> bool {
162
+ self.local_part.is_none() && self.forward_to_email.is_none() && self.sender_email.is_none()
163
+ }
164
+}
165
+
166
+#[derive(Serialize, Debug, Clone, Default)]
167
+pub struct MailTestEmailRequest {
168
+ #[serde(skip_serializing_if = "Option::is_none")]
169
+ pub recipient_email: Option<String>,
170
+ #[serde(skip_serializing_if = "Option::is_none")]
171
+ pub subject: Option<String>,
172
+ #[serde(skip_serializing_if = "Option::is_none")]
173
+ pub body: Option<String>,
174
+}
175
+
176
+#[derive(Deserialize, Serialize, Debug, Clone)]
177
+pub struct MailTestEmailDelivery {
178
+ pub message_id: String,
179
+ pub recipient_email: String,
180
+ pub delivery_method: String,
181
+ pub sent_at: String,
182
+}