Skip to content

Commit e4fe003

Browse files
committed
feat: Add sync and sync-from-email CLI commands
- Add sync_aliases() and sync_from_email() core functions with dry-run support - Add CLI commands: `sync` (storage-authoritative) and `sync-from-email` (email-authoritative) - Both reuse audit_aliases() to diff, then apply creates/deletes/updates accordingly - Close beads issue aliasman-dev
1 parent 4439dc9 commit e4fe003

5 files changed

Lines changed: 264 additions & 1 deletion

File tree

.beads/issues.jsonl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
{"id":"aliasman-847.4","title":"Routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-22T02:49:50.271958Z","created_by":"pjl","updated_at":"2026-03-22T04:31:15.961455Z","closed_at":"2026-03-22T04:31:15.961154Z","close_reason":"Add theme context to templates and branding file route","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-847.4","depends_on_id":"aliasman-847","type":"parent-child","created_at":"2026-03-22T02:49:50.271958Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
88
{"id":"aliasman-847.5","title":"Templates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-22T02:49:55.286660Z","created_by":"pjl","updated_at":"2026-03-22T04:52:57.525944Z","closed_at":"2026-03-22T04:52:57.525528Z","close_reason":"Apply theme colors and branding to all web templates","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-847.5","depends_on_id":"aliasman-847","type":"parent-child","created_at":"2026-03-22T02:49:55.286660Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
99
{"id":"aliasman-847.6","title":"Favicon support","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-22T04:35:52.633755Z","created_by":"pjl","updated_at":"2026-03-22T16:43:28.327921Z","closed_at":"2026-03-22T16:43:28.327597Z","close_reason":"Add favicon, dark-mode logo, and default branding assets","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-847.6","depends_on_id":"aliasman-847","type":"parent-child","created_at":"2026-03-22T04:35:52.633755Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
10-
{"id":"aliasman-dev","title":"Implement sync and sync-from-email CLI commands","status":"open","priority":2,"issue_type":"feature","created_at":"2026-03-22T21:48:25.714280Z","created_by":"pjl","updated_at":"2026-03-22T21:48:25.714280Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["cli","core"]}
10+
{"id":"aliasman-dev","title":"Implement sync and sync-from-email CLI commands","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-03-22T21:48:25.714280Z","created_by":"pjl","updated_at":"2026-04-08T16:13:46.639306Z","closed_at":"2026-04-08T16:13:46.638987Z","close_reason":"Implemented sync and sync-from-email CLI commands with core functions, dry-run support, and domain flag","source_repo":".","compaction_level":0,"original_size":0,"labels":["cli","core"]}
1111
{"id":"aliasman-dpx","title":"Auth types and user store in core","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-21T19:13:49.679066Z","created_by":"pjl","updated_at":"2026-03-21T22:37:47.126085Z","closed_at":"2026-03-21T22:37:47.125914Z","close_reason":"Add auth module with UserStore trait and SQLite/Postgres implementations","source_repo":".","compaction_level":0,"original_size":0}
1212
{"id":"aliasman-ixa","title":"Have clicking on the full alias in the web ui put it on the clipboard","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-30T02:55:51.524884Z","created_by":"pjl","updated_at":"2026-03-30T03:02:47.024444Z","closed_at":"2026-03-30T03:02:47.024066Z","close_reason":"Added click-to-copy on full alias in web UI using Clipboard API with CSS tooltip feedback","source_repo":".","compaction_level":0,"original_size":0}
1313
{"id":"aliasman-k5x","title":"Wire auth into existing routes","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-21T19:14:36.597816Z","created_by":"pjl","updated_at":"2026-03-22T01:57:54.939105Z","closed_at":"2026-03-22T01:57:54.938697Z","close_reason":"Add per-domain permission checks to web mutation handlers","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-k5x","depends_on_id":"aliasman-5dn","type":"blocks","created_at":"2026-03-21T19:16:47.492776Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
1414
{"id":"aliasman-mvn","title":"CLI user management commands","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-21T19:14:06.059543Z","created_by":"pjl","updated_at":"2026-03-21T22:49:11.773901Z","closed_at":"2026-03-21T22:49:11.773714Z","close_reason":"Add CLI user management commands for auth administration","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-mvn","depends_on_id":"aliasman-dpx","type":"blocks","created_at":"2026-03-21T19:16:24.325655Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
1515
{"id":"aliasman-wo4","title":"Web user management UI (superuser only)","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-21T19:14:49.605051Z","created_by":"pjl","updated_at":"2026-03-22T02:15:26.122461Z","closed_at":"2026-03-22T02:15:26.122079Z","close_reason":"Add web admin UI for user and permission management","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"aliasman-wo4","depends_on_id":"aliasman-k5x","type":"blocks","created_at":"2026-03-21T19:16:57.260156Z","created_by":"pjl","metadata":"{}","thread_id":""}]}
16+
{"id":"aliasman-x7t","title":"Theme Authentik","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-03-31T15:03:49.796915Z","created_by":"pjl","updated_at":"2026-04-08T14:27:22.830720Z","source_repo":".","deleted_at":"2026-04-08T14:27:22.830685Z","deleted_by":"pjl","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
1617
{"id":"aliasman-y2q","title":"Implement files storage provider","status":"open","priority":3,"issue_type":"feature","created_at":"2026-03-22T21:49:04.550072Z","created_by":"pjl","updated_at":"2026-03-22T21:49:04.550072Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["core"]}

crates/aliasman-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ pub mod alias;
22
pub mod audit;
33
pub mod config;
44
pub mod storage;
5+
pub mod sync;
56
pub mod user;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use anyhow::{Context, Result};
2+
3+
use aliasman_core::email::EmailProvider;
4+
use aliasman_core::storage::StorageProvider;
5+
use aliasman_core::{sync_aliases, sync_from_email};
6+
7+
pub async fn handle_sync(
8+
storage: &mut dyn StorageProvider,
9+
email: &dyn EmailProvider,
10+
domain: &str,
11+
dry_run: bool,
12+
) -> Result<()> {
13+
storage.open(false).await?;
14+
let result = sync_aliases(storage, email, domain, dry_run)
15+
.await
16+
.context("sync failed")?;
17+
storage.close().await?;
18+
19+
if dry_run {
20+
println!("Dry run — no changes made.");
21+
}
22+
23+
println!(
24+
"Sync to email complete: {} created, {} deleted, {} updated",
25+
result.created, result.deleted, result.updated
26+
);
27+
28+
Ok(())
29+
}
30+
31+
pub async fn handle_sync_from_email(
32+
storage: &mut dyn StorageProvider,
33+
email: &dyn EmailProvider,
34+
domain: &str,
35+
dry_run: bool,
36+
) -> Result<()> {
37+
storage.open(false).await?;
38+
let result = sync_from_email(storage, email, domain, dry_run)
39+
.await
40+
.context("sync-from-email failed")?;
41+
storage.close().await?;
42+
43+
if dry_run {
44+
println!("Dry run — no changes made.");
45+
}
46+
47+
println!(
48+
"Sync from email complete: {} created, {} deleted, {} updated",
49+
result.created, result.deleted, result.updated
50+
);
51+
52+
Ok(())
53+
}

crates/aliasman-cli/src/main.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ enum Commands {
4949
/// Print version information
5050
Version,
5151

52+
/// Sync aliases from storage to email provider (storage is authoritative)
53+
Sync {
54+
/// Domain to sync
55+
#[arg(short, long)]
56+
domain: Option<String>,
57+
},
58+
59+
/// Sync aliases from email provider into storage (email is authoritative)
60+
SyncFromEmail {
61+
/// Domain to sync
62+
#[arg(short, long)]
63+
domain: Option<String>,
64+
},
65+
5266
/// Storage management commands
5367
Storage {
5468
#[command(subcommand)]
@@ -85,6 +99,49 @@ async fn main() -> Result<()> {
8599
commands::audit::handle(storage.as_mut(), email.as_ref(), domain).await?;
86100
}
87101

102+
Commands::Sync { domain } => {
103+
let config =
104+
AppConfig::load(&cli.config_dir).context("failed to load configuration")?;
105+
let system = config
106+
.system(cli.system.as_deref())
107+
.context("failed to resolve system")?;
108+
109+
let domain = domain
110+
.as_deref()
111+
.or(system.domain.as_deref())
112+
.context("--domain is required (no default domain configured)")?;
113+
114+
let (mut storage, email) =
115+
create_providers(system).context("failed to create providers")?;
116+
117+
commands::sync::handle_sync(storage.as_mut(), email.as_ref(), domain, cli.dry_run)
118+
.await?;
119+
}
120+
121+
Commands::SyncFromEmail { domain } => {
122+
let config =
123+
AppConfig::load(&cli.config_dir).context("failed to load configuration")?;
124+
let system = config
125+
.system(cli.system.as_deref())
126+
.context("failed to resolve system")?;
127+
128+
let domain = domain
129+
.as_deref()
130+
.or(system.domain.as_deref())
131+
.context("--domain is required (no default domain configured)")?;
132+
133+
let (mut storage, email) =
134+
create_providers(system).context("failed to create providers")?;
135+
136+
commands::sync::handle_sync_from_email(
137+
storage.as_mut(),
138+
email.as_ref(),
139+
domain,
140+
cli.dry_run,
141+
)
142+
.await?;
143+
}
144+
88145
Commands::Config => {
89146
commands::config::handle(&cli.config_dir)?;
90147
}

crates/aliasman-core/src/lib.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,157 @@ pub async fn audit_aliases(
403403
})
404404
}
405405

406+
/// Result of a sync operation.
407+
#[derive(Debug)]
408+
pub struct SyncResult {
409+
pub created: usize,
410+
pub deleted: usize,
411+
pub updated: usize,
412+
}
413+
414+
/// Sync aliases from storage to the email provider (storage is authoritative).
415+
///
416+
/// For each diff from audit:
417+
/// - `StorageOnly` (active): create on email provider
418+
/// - `EmailOnly`: delete from email provider
419+
/// - `AddressMismatch`: delete + recreate on email with storage addresses
420+
/// - Suspended aliases found on email: delete from email provider
421+
pub async fn sync_aliases(
422+
storage: &dyn StorageProvider,
423+
email: &dyn EmailProvider,
424+
domain: &str,
425+
dry_run: bool,
426+
) -> Result<SyncResult> {
427+
let audit = audit_aliases(storage, email, domain).await?;
428+
let mut result = SyncResult {
429+
created: 0,
430+
deleted: 0,
431+
updated: 0,
432+
};
433+
434+
for diff in &audit.diffs {
435+
match diff {
436+
AuditDiff::StorageOnly {
437+
alias,
438+
domain,
439+
suspended,
440+
} => {
441+
if !suspended {
442+
if !dry_run {
443+
let sa = storage.get(alias, domain).await?.ok_or_else(|| {
444+
Error::AliasNotFound {
445+
alias: alias.clone(),
446+
domain: domain.clone(),
447+
}
448+
})?;
449+
email
450+
.alias_create(alias, domain, &sa.email_addresses)
451+
.await?;
452+
}
453+
result.created += 1;
454+
}
455+
}
456+
AuditDiff::EmailOnly { alias, domain, .. } => {
457+
if !dry_run {
458+
email.alias_delete(alias, domain).await?;
459+
}
460+
result.deleted += 1;
461+
}
462+
AuditDiff::AddressMismatch {
463+
alias,
464+
domain,
465+
storage_addresses,
466+
..
467+
} => {
468+
if !dry_run {
469+
email.alias_delete(alias, domain).await?;
470+
email.alias_create(alias, domain, storage_addresses).await?;
471+
}
472+
result.updated += 1;
473+
}
474+
}
475+
}
476+
477+
Ok(result)
478+
}
479+
480+
/// Sync aliases from the email provider into storage (email provider is authoritative).
481+
///
482+
/// For each diff from audit:
483+
/// - `EmailOnly`: add to storage with current timestamp
484+
/// - `StorageOnly` (active): delete from storage
485+
/// - `AddressMismatch`: update storage with email addresses
486+
/// - Suspended aliases found on email: update storage addresses and unsuspend
487+
pub async fn sync_from_email(
488+
storage: &dyn StorageProvider,
489+
email: &dyn EmailProvider,
490+
domain: &str,
491+
dry_run: bool,
492+
) -> Result<SyncResult> {
493+
let audit = audit_aliases(storage, email, domain).await?;
494+
let mut result = SyncResult {
495+
created: 0,
496+
deleted: 0,
497+
updated: 0,
498+
};
499+
500+
for diff in &audit.diffs {
501+
match diff {
502+
AuditDiff::EmailOnly {
503+
alias,
504+
domain,
505+
email_addresses,
506+
} => {
507+
if !dry_run {
508+
let new_alias = build_alias(
509+
alias.clone(),
510+
domain.clone(),
511+
email_addresses.clone(),
512+
String::new(),
513+
);
514+
storage.put(&new_alias).await?;
515+
}
516+
result.created += 1;
517+
}
518+
AuditDiff::StorageOnly {
519+
alias,
520+
domain,
521+
suspended,
522+
} => {
523+
if !suspended {
524+
if !dry_run {
525+
storage.delete(alias, domain).await?;
526+
}
527+
result.deleted += 1;
528+
}
529+
}
530+
AuditDiff::AddressMismatch {
531+
alias,
532+
domain,
533+
email_addresses,
534+
..
535+
} => {
536+
if !dry_run {
537+
let mut existing =
538+
storage
539+
.get(alias, domain)
540+
.await?
541+
.ok_or_else(|| Error::AliasNotFound {
542+
alias: alias.clone(),
543+
domain: domain.clone(),
544+
})?;
545+
existing.email_addresses = email_addresses.clone();
546+
existing.modified_at = Utc::now();
547+
storage.update(&existing).await?;
548+
}
549+
result.updated += 1;
550+
}
551+
}
552+
}
553+
554+
Ok(result)
555+
}
556+
406557
/// Build a new Alias struct with sensible defaults for timestamps.
407558
pub fn build_alias(
408559
alias_name: String,

0 commit comments

Comments
 (0)