Skip to main content

qualia_client_core/api/
domains.rs

1//! Domains & semantic mail/address stack
2
3#![allow(non_snake_case)]
4
5use super::*;
6
7pub fn mail_now_unix() -> u64 {
8    std::time::SystemTime::now()
9        .duration_since(std::time::UNIX_EPOCH)
10        .map(|d| d.as_secs())
11        .unwrap_or(0)
12}
13
14fn agent_type_from_token(t: &str) -> crate::domains::AgentType {
15    use crate::domains::AgentType::*;
16    match t {
17        "org" | "organization" => Organization,
18        "ai" | "agent" => AiAgent,
19        "service" | "humanitarian" => HumanitarianService,
20        "content" => ContentProvider,
21        "group" => Group,
22        _ => NaturalPerson,
23    }
24}
25
26/// The person's context-domains (personal/work/projects/…), each an agent with a front-door DID.
27pub fn list_mail_domains() -> Result<serde_json::Value, String> {
28    serde_json::to_value(crate::domains::list_domains()).map_err(|e| e.to_string())
29}
30
31/// Add a context-domain (single-owner). `agent_type` is a token: person/org/ai/service/content/group.
32pub fn add_mail_domain(
33    name: String,
34    agent_type: String,
35    front_door_did: String,
36    label: String,
37    parent: Option<String>,
38) -> Result<serde_json::Value, String> {
39    let owner = crate::domains::DomainOwner::Personal {
40        did: front_door_did.clone(),
41    };
42    let d = crate::domains::make_domain(
43        &name,
44        agent_type_from_token(&agent_type),
45        owner,
46        &front_door_did,
47        &label,
48        parent,
49        mail_now_unix(),
50    )?;
51    crate::domains::upsert_domain(d)?;
52    list_mail_domains()
53}
54
55/// Built-in purpose-inbox presets (frontdoor/junkmail/mygov/newsletters).
56pub fn purpose_inbox_presets() -> Result<serde_json::Value, String> {
57    serde_json::to_value(crate::domains::purpose_presets()).map_err(|e| e.to_string())
58}
59
60/// Addresses (optionally filtered to one domain).
61pub fn list_mail_addresses(domain: Option<String>) -> Result<serde_json::Value, String> {
62    serde_json::to_value(crate::domains::list_addresses(domain.as_deref()))
63        .map_err(|e| e.to_string())
64}
65
66/// Mint a purpose inbox (`frontdoor@`, `junkmail@`, …). `rules_json` is a `MailRules` object (or empty).
67pub fn mint_purpose_inbox(
68    domain: String,
69    local: String,
70    rules_json: String,
71) -> Result<serde_json::Value, String> {
72    let rules: crate::domains::MailRules = if rules_json.trim().is_empty() {
73        crate::domains::MailRules::default()
74    } else {
75        serde_json::from_str(&rules_json).map_err(|e| format!("bad rules json: {e}"))?
76    };
77    let a = crate::domains::make_purpose_address(&domain, &local, rules, mail_now_unix())?;
78    crate::domains::upsert_address(a)?;
79    list_mail_addresses(Some(domain))
80}
81
82/// Onboard mail for a registered domain: mint purpose presets + catchall, then
83/// **auto-start the local SMTP receiver** so the product path finishes without a second hunt.
84pub fn onboard_mail_domain(domain: String) -> Result<serde_json::Value, String> {
85    let minted = crate::domains::onboard_purpose_inboxes(&domain)?;
86    let domain_key = domain.trim().to_lowercase();
87    let addresses = crate::domains::list_addresses(Some(&domain_key));
88    #[cfg(not(target_arch = "wasm32"))]
89    let receiver = match crate::mail_inbound::start_receiver("") {
90        Ok(v) => v,
91        Err(e) => serde_json::json!({
92            "started": false,
93            "error": e,
94            "hint": "Start the receiver from Talk → Mail if bind failed (port in use?).",
95        }),
96    };
97    #[cfg(target_arch = "wasm32")]
98    let receiver = serde_json::json!({ "started": false, "hint": "receiver is native-only" });
99    let mint_msg = if minted.is_empty() {
100        "Mail already onboarded for this domain.".to_string()
101    } else {
102        format!(
103            "Minted {} mailbox(es): {}.",
104            minted.len(),
105            minted.join(", ")
106        )
107    };
108    let recv_msg = if receiver
109        .get("started")
110        .and_then(|x| x.as_bool())
111        .unwrap_or(false)
112        || receiver
113            .get("already_running")
114            .and_then(|x| x.as_bool())
115            .unwrap_or(false)
116    {
117        " Local SMTP receiver running — inbox can accept mail.".to_string()
118    } else if let Some(err) = receiver.get("error").and_then(|e| e.as_str()) {
119        format!(" Receiver not started: {err}.")
120    } else {
121        String::new()
122    };
123    Ok(serde_json::json!({
124        "domain": domain_key,
125        "minted": minted,
126        "addresses": addresses,
127        "receiver": receiver,
128        "message": format!("{mint_msg}{recv_msg}"),
129    }))
130}
131
132/// Human-readable readiness for Talk: domains, mailboxes, receiver, people, so first-run can guide.
133pub fn talk_setup_status() -> Result<serde_json::Value, String> {
134    let domains = crate::domains::list_domains();
135    let addresses = crate::domains::list_addresses(None);
136    let (mail_total, mail_unread, mail_quarantine) = crate::mail_store::counts();
137    let receiver = crate::mail_inbound::receiver_status();
138    let peers = crate::social_peers::list_peers();
139    let contacts_n = crate::social_connect::list_chat_contacts().len();
140    let has_domain = !domains.is_empty();
141    let has_mailboxes = !addresses.is_empty();
142    let receiver_running = receiver
143        .get("running")
144        .and_then(|x| x.as_bool())
145        .unwrap_or(false);
146    let has_people = !peers.is_empty() || contacts_n > 0;
147    let collabs = crate::project_collab::list(None);
148    let dial = crate::social_mesh::dialability(&peers);
149    let dialable = dial.iter().filter(|d| d.dialable_now).count();
150    let reachable = dial.iter().filter(|d| d.reachable).count();
151    let ready_for_mail = has_domain && has_mailboxes && receiver_running;
152    let ready_to_collaborate = has_domain && has_people;
153    Ok(serde_json::json!({
154        "domains": domains.len(),
155        "mailboxes": addresses.len(),
156        "mail_inbox": { "total": mail_total, "unread": mail_unread, "quarantine": mail_quarantine },
157        "receiver": receiver,
158        "peers": peers.len(),
159        "contacts": contacts_n,
160        "collaborators": collabs.len(),
161        "mesh": {
162            "reachable_peers": reachable,
163            "dialable_now": dialable,
164            "reports": dial,
165        },
166        "has_domain": has_domain,
167        "has_mailboxes": has_mailboxes,
168        "receiver_running": receiver_running,
169        "has_people": has_people,
170        "ready_for_mail": ready_for_mail,
171        "ready_to_collaborate": ready_to_collaborate,
172        "next_step": if !has_domain {
173            "reception"
174        } else if !has_mailboxes {
175            "mail_onboard"
176        } else if !receiver_running {
177            "mail_receiver"
178        } else if !has_people {
179            "people"
180        } else if collabs.is_empty() {
181            "projects"
182        } else {
183            "chat_or_projects"
184        },
185    }))
186}
187
188/// Ensure the profile allows connect invites (required for a one-paste join package).
189fn ensure_connect_invites_enabled() -> Result<(), String> {
190    let mut profile = crate::user_profile::load_profile();
191    if !profile.sharing.allow_group_chat_invites {
192        profile.sharing.allow_group_chat_invites = true;
193        crate::user_profile::save_profile(&profile)?;
194    }
195    Ok(())
196}
197
198/// Pure: pull the connect-invite JSON string out of a coop package or bare invite object.
199/// Used by accept + unit tests (no APP_STATE).
200pub fn extract_invite_json_from_package(v: &serde_json::Value) -> Result<String, String> {
201    if v.get("qualia_coop_share").is_some() {
202        let invite_payload = v
203            .get("invite_json")
204            .cloned()
205            .filter(|x| !x.is_null())
206            .ok_or_else(|| {
207                "share package has no invite_json — host must enable invites and rebuild package"
208                    .to_string()
209            })?;
210        if invite_payload.is_string() {
211            let s = invite_payload.as_str().unwrap_or("").trim().to_string();
212            if s.is_empty() {
213                return Err("invite_json string is empty".into());
214            }
215            return Ok(s);
216        }
217        return serde_json::to_string(&invite_payload).map_err(|e| e.to_string());
218    }
219    // Bare ConnectInvitePayload object.
220    if v.get("signature_hex").is_some() && v.get("inviter_did").is_some() {
221        return serde_json::to_string(v).map_err(|e| e.to_string());
222    }
223    Err("not a coop share package or connect invite JSON".into())
224}
225
226/// Build a pasteable **coop share package** — one blob the joiner pastes under People → Accept.
227/// Always embeds a signed connect invite (enables invites if needed). Never includes private keys.
228pub fn coop_share_package(
229    project_id: Option<String>,
230    project_name: Option<String>,
231) -> Result<serde_json::Value, String> {
232    ensure_connect_invites_enabled()?;
233
234    let domains = crate::domains::list_domains();
235    let domain = domains.first().map(|d| d.name.clone()).unwrap_or_default();
236    let front_door = domains
237        .first()
238        .map(|d| d.front_door_did.clone())
239        .unwrap_or_default();
240    let pid = project_id.unwrap_or_default();
241    let pname = project_name.unwrap_or_default();
242    let profile = crate::user_profile::load_profile();
243
244    // Required: invite must succeed for one-paste join (fail closed, not half-package).
245    let invite = crate::social_connect::generate_connect_invite(None).map_err(|e| {
246        format!(
247            "could not embed connect invite: {e}. Set a display name under Talk → People and try again."
248        )
249    })?;
250    let invite_json: serde_json::Value = serde_json::from_str(&invite.invite_json)
251        .unwrap_or_else(|_| serde_json::Value::String(invite.invite_json.clone()));
252
253    #[cfg(not(target_arch = "wasm32"))]
254    let magic = {
255        let fd = if front_door.is_empty() {
256            String::new()
257        } else {
258            front_door.clone()
259        };
260        // Mesh peer material for the joiner (optional second path).
261        generate_magic_link(fd, "collaborator".into(), domain.clone()).ok()
262    };
263    #[cfg(target_arch = "wasm32")]
264    let magic: Option<serde_json::Value> = None;
265
266    // Host also records themselves as steward on the project roster when a project is named.
267    if !pid.is_empty() {
268        let self_did = profile.public_did.clone();
269        if !self_did.is_empty() {
270            let _ = crate::project_collab::add(
271                &pid,
272                &pname,
273                &self_did,
274                &profile.display_name,
275                "steward",
276            );
277        }
278    }
279
280    let package = serde_json::json!({
281        "qualia_coop_share": "1",
282        "from_display_name": profile.display_name,
283        "domain": domain,
284        "front_door_did": front_door,
285        "project_id": pid,
286        "project_name": pname,
287        "members": crate::project_collab::list(if pid.is_empty() { None } else { Some(pid.as_str()) }),
288        "invite_code": invite.code,
289        "invite_json": invite_json,
290        "invite_expires_at": invite.expires_at,
291        "magic_link": magic,
292        "how": [
293            "1. Open Webizen (0.0.25+).",
294            "2. Talk → People → paste this entire JSON under Accept package / invite.",
295            "3. You are connected and the project is scoped on your device.",
296            "4. Talk → People → Start mesh for live peer chat.",
297            "5. Chat with #project:Name_With_Underscores for scoped work.",
298        ],
299        "note": "No private keys. Package is self-contained — one paste joins.",
300    });
301    Ok(package)
302}
303
304/// Accept a **coop share package** or a bare connect-invite JSON (one paste).
305/// Connects to the host, scopes the project, admits host+self on the local roster,
306/// registers mesh peer when magic_link is present, and opens a project group chat when possible.
307pub fn accept_coop_share_package(package_or_invite: String) -> Result<serde_json::Value, String> {
308    let raw = package_or_invite.trim();
309    if raw.is_empty() {
310        return Err("paste a coop share package or invite JSON".into());
311    }
312    let v: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("not JSON: {e}"))?;
313
314    let invite_str = extract_invite_json_from_package(&v).or_else(|e| {
315        // Bare invite string body already parsed as object without qualia_coop_share.
316        if v.get("inviter_did").is_some() {
317            serde_json::to_string(&v).map_err(|e2| e2.to_string())
318        } else {
319            Err(e)
320        }
321    })?;
322    let contact = crate::social_connect::accept_connect_invite(&invite_str)?;
323
324    let project_id = v
325        .get("project_id")
326        .and_then(|x| x.as_str())
327        .unwrap_or("")
328        .to_string();
329    let project_name = v
330        .get("project_name")
331        .and_then(|x| x.as_str())
332        .unwrap_or("")
333        .to_string();
334
335    let profile = crate::user_profile::load_profile();
336    let mut admitted = Vec::new();
337    if !project_id.is_empty() {
338        let host_did = contact.did.clone();
339        let host_name = if contact.display_name.is_empty() {
340            contact.did.clone()
341        } else {
342            contact.display_name.clone()
343        };
344        if !host_did.is_empty() {
345            if let Ok(row) = crate::project_collab::add(
346                &project_id,
347                &project_name,
348                &host_did,
349                &host_name,
350                "steward",
351            ) {
352                admitted.push(row);
353            }
354        }
355        let self_did = profile.public_did.clone();
356        if self_did.is_empty() {
357            // Ensure we have a resolvable self DID for the roster.
358            let resolved = crate::user_profile::resolve_public_did(&profile);
359            if !resolved.is_empty() {
360                if let Ok(row) = crate::project_collab::add(
361                    &project_id,
362                    &project_name,
363                    &resolved,
364                    &profile.display_name,
365                    "contributor",
366                ) {
367                    admitted.push(row);
368                }
369            }
370        } else if let Ok(row) = crate::project_collab::add(
371            &project_id,
372            &project_name,
373            &self_did,
374            &profile.display_name,
375            "contributor",
376        ) {
377            admitted.push(row);
378        }
379    }
380
381    // Magic link → SocialWebNet peer (mesh), when present.
382    let mut peer_registered = false;
383    #[cfg(not(target_arch = "wasm32"))]
384    if let Some(magic) = v.get("magic_link") {
385        let link = magic
386            .get("deep_link")
387            .or_else(|| magic.get("https_link"))
388            .and_then(|x| x.as_str())
389            .unwrap_or("");
390        if !link.is_empty() {
391            if accept_connection(link.to_string()).is_ok() {
392                peer_registered = true;
393            }
394        }
395    }
396
397    // Project group chat from roster (host + self + any prior members).
398    let mut group_chat: Option<serde_json::Value> = None;
399    if !project_id.is_empty() {
400        match create_project_group_chat(
401            project_id.clone(),
402            if project_name.is_empty() {
403                project_id.clone()
404            } else {
405                project_name.clone()
406            },
407            Some(vec![contact.did.clone()]),
408        ) {
409            Ok(g) => group_chat = Some(g),
410            Err(_) => {
411                // Still ok — roster may be thin; user can create group later.
412            }
413        }
414    }
415
416    Ok(serde_json::json!({
417        "connected": true,
418        "contact": contact,
419        "project_id": project_id,
420        "project_name": project_name,
421        "admitted": admitted,
422        "peer_registered": peer_registered,
423        "group_chat": group_chat,
424        "message": "Joined. Contact saved; project scoped; roster updated; group chat created when possible. Start mesh under People for live peer traffic.",
425    }))
426}
427
428#[cfg(test)]
429mod coop_share_tests {
430    use super::*;
431
432    #[test]
433    fn extract_invite_from_full_package() {
434        let package = serde_json::json!({
435            "qualia_coop_share": "1",
436            "project_id": "board-1",
437            "project_name": "QualiaDB Development Cooperative",
438            "invite_json": {
439                "version": 1,
440                "code": "QUALIA-TEST-TEST",
441                "inviter_name": "Host",
442                "inviter_did": "did:host",
443                "inviter_pubkey_hex": "",
444                "relay_endpoint": "",
445                "front_door_did": "did:host",
446                "profile_card": {},
447                "created_at": 1,
448                "expires_at": 9_999_999_999u64,
449                "signature_hex": ""
450            }
451        });
452        let s = extract_invite_json_from_package(&package).expect("extract");
453        assert!(s.contains("did:host"));
454        assert!(s.contains("QUALIA-TEST-TEST"));
455    }
456
457    #[test]
458    fn extract_invite_from_string_field() {
459        let package = serde_json::json!({
460            "qualia_coop_share": "1",
461            "invite_json": "{\"version\":1,\"code\":\"X\",\"inviter_did\":\"did:x\",\"signature_hex\":\"\"}"
462        });
463        let s = extract_invite_json_from_package(&package).expect("extract string");
464        assert!(s.contains("did:x"));
465    }
466
467    #[test]
468    fn extract_bare_invite_object() {
469        let invite = serde_json::json!({
470            "version": 1,
471            "inviter_did": "did:bare",
472            "signature_hex": "aa",
473            "code": "C"
474        });
475        let s = extract_invite_json_from_package(&invite).expect("bare");
476        assert!(s.contains("did:bare"));
477    }
478
479    #[test]
480    fn extract_rejects_empty_package() {
481        let package = serde_json::json!({ "qualia_coop_share": "1" });
482        assert!(extract_invite_json_from_package(&package).is_err());
483    }
484}
485
486/// Create a group chat for a project from its collaborator roster (+ optional extra DIDs).
487/// Returns `{ session_id, title, participants }`.
488pub fn create_project_group_chat(
489    project_id: String,
490    project_name: String,
491    extra_dids: Option<Vec<String>>,
492) -> Result<serde_json::Value, String> {
493    let mut dids: Vec<String> = crate::project_collab::list(Some(&project_id))
494        .into_iter()
495        .map(|c| c.member_did)
496        .collect();
497    if let Some(extra) = extra_dids {
498        for d in extra {
499            let d = d.trim().to_string();
500            if !d.is_empty() && !dids.iter().any(|x| x == &d) {
501                dids.push(d);
502            }
503        }
504    }
505    // Also fold in all chat contacts if roster empty — last resort so a group can start.
506    if dids.is_empty() {
507        for c in crate::social_connect::list_chat_contacts() {
508            if !c.did.is_empty() {
509                dids.push(c.did);
510            }
511        }
512    }
513    if dids.is_empty() {
514        return Err(
515            "No participants — admit collaborators on the project or accept invites under People first."
516                .into(),
517        );
518    }
519    let title = if project_name.trim().is_empty() {
520        format!("Project {project_id}")
521    } else {
522        format!("Project: {project_name}")
523    };
524    let session_id = create_group_chat_session(Some(title.clone()), dids.clone())?;
525    Ok(serde_json::json!({
526        "session_id": session_id,
527        "title": title,
528        "participants": dids,
529        "project_id": project_id,
530        "message": "Group chat created — open it under Talk → Chat → Conversations.",
531    }))
532}
533
534/// Resolve how mail to `to_address` would be handled (exact / catchall / reject) — pure, for UI/debug.
535pub fn resolve_mail_delivery(to_address: String) -> Result<serde_json::Value, String> {
536    let addresses = crate::domains::list_addresses(None);
537    match crate::domains::resolve_delivery(&addresses, &to_address) {
538        crate::domains::DeliveryResolution::Deliver { address, via } => Ok(serde_json::json!({
539            "deliver": true,
540            "via": via,
541            "address": address,
542        })),
543        crate::domains::DeliveryResolution::Reject { reason } => Ok(serde_json::json!({
544            "deliver": false,
545            "rejected": reason,
546        })),
547    }
548}
549
550/// Persist SMTP/IMAP prefs (app_meta_dir). Passwords stored locally — same trust as desktop secrets.
551pub fn save_mail_transport_config(
552    smtp_json: String,
553    imap_json: String,
554) -> Result<serde_json::Value, String> {
555    let path = crate::state::app_meta_dir().join("mail_transport.json");
556    if let Some(p) = path.parent() {
557        std::fs::create_dir_all(p).map_err(|e| e.to_string())?;
558    }
559    let smtp: Option<crate::mail_transport::SmtpConfig> = if smtp_json.trim().is_empty() {
560        None
561    } else {
562        Some(serde_json::from_str(&smtp_json).map_err(|e| format!("bad smtp: {e}"))?)
563    };
564    let imap: Option<crate::mail_transport::ImapConfig> = if imap_json.trim().is_empty() {
565        None
566    } else {
567        Some(serde_json::from_str(&imap_json).map_err(|e| format!("bad imap: {e}"))?)
568    };
569    let doc = serde_json::json!({ "smtp": smtp, "imap": imap });
570    std::fs::write(
571        &path,
572        serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?,
573    )
574    .map_err(|e| e.to_string())?;
575    Ok(serde_json::json!({ "saved": true, "path": path.display().to_string() }))
576}
577
578pub fn load_mail_transport_config() -> Result<serde_json::Value, String> {
579    let path = crate::state::app_meta_dir().join("mail_transport.json");
580    match std::fs::read_to_string(path) {
581        Ok(t) => serde_json::from_str(&t).map_err(|e| e.to_string()),
582        Err(_) => Ok(serde_json::json!({ "smtp": null, "imap": null })),
583    }
584}
585
586/// Mint a per-relationship (pairwise) address bound to a relationship DID.
587pub fn mint_relationship_address(
588    domain: String,
589    local: String,
590    relationship_did: String,
591) -> Result<serde_json::Value, String> {
592    let a = crate::domains::make_relationship_address(
593        &domain,
594        &local,
595        &relationship_did,
596        mail_now_unix(),
597    )?;
598    crate::domains::upsert_address(a)?;
599    list_mail_addresses(Some(domain))
600}
601
602/// Enable/disable an address (the surgical per-relationship revoke). Returns the refreshed list.
603pub fn set_mail_address_enabled(
604    address: String,
605    enabled: bool,
606) -> Result<serde_json::Value, String> {
607    crate::domains::set_address_enabled(&address, enabled)?;
608    list_mail_addresses(None)
609}
610
611/// The QDP front-door forms for a domain — the **DNS TXT** (no-hosting anchor), the DNS record name, and
612/// the rich profile in **Turtle + JSON-LD** (served by the local HTTP server over the mesh when hosting).
613pub fn front_door_forms(domain: String) -> Result<serde_json::Value, String> {
614    let d = crate::domains::list_domains()
615        .into_iter()
616        .find(|d| d.name == domain)
617        .ok_or_else(|| format!("unknown domain '{domain}'"))?;
618    // Any eCash address minted as a service could be attached here later; start from the domain identity.
619    let rec = crate::front_door::FrontDoorRecord {
620        domain: d.name.clone(),
621        agent_type: d.agent_type.clone(),
622        front_door_did: d.front_door_did.clone(),
623        name: if d.label.is_empty() {
624            None
625        } else {
626            Some(d.label.clone())
627        },
628        webid: None,
629        services: vec![],
630        identity_pubkey_hex: None,
631        wireguard_pubkey_hex: None,
632        overlay_addr: None,
633        profile_url: None,
634    };
635    let mail_dns = crate::mail_inbound::mail_dns_forms(&d.name, None);
636    Ok(serde_json::json!({
637        "dns_name": crate::front_door::dns_record_name(&d.name),
638        "dns_txt": rec.to_dns_txt(),
639        "turtle": rec.to_turtle(),
640        "jsonld": rec.to_json_ld(),
641        "mail_dns": mail_dns,
642    }))
643}
644
645/// Build a QDP front-door record from a stored domain's identity (shared by publish/serve).
646fn build_front_door_record(domain: &str) -> Result<crate::front_door::FrontDoorRecord, String> {
647    let d = crate::domains::list_domains()
648        .into_iter()
649        .find(|d| d.name == domain)
650        .ok_or_else(|| format!("unknown domain '{domain}'"))?;
651    Ok(crate::front_door::FrontDoorRecord {
652        domain: d.name.clone(),
653        agent_type: d.agent_type.clone(),
654        front_door_did: d.front_door_did.clone(),
655        name: if d.label.is_empty() {
656            None
657        } else {
658            Some(d.label.clone())
659        },
660        webid: None,
661        services: vec![],
662        identity_pubkey_hex: None,
663        wireguard_pubkey_hex: None,
664        overlay_addr: None,
665        profile_url: None,
666    })
667}
668
669/// Verify a Cloudflare API token (the easy-install front-door publishing path).
670#[cfg(not(target_arch = "wasm32"))]
671pub fn cf_verify_token(token: String) -> Result<serde_json::Value, String> {
672    crate::cloudflare::verify_token(&token)?;
673    Ok(serde_json::json!({ "ok": true }))
674}
675
676/// List the Cloudflare zones (domains) the token can manage.
677#[cfg(not(target_arch = "wasm32"))]
678pub fn cf_list_zones(token: String) -> Result<serde_json::Value, String> {
679    let zones = crate::cloudflare::list_zones(&token)?;
680    Ok(serde_json::json!(zones
681        .into_iter()
682        .map(|(id, name)| serde_json::json!({ "id": id, "name": name }))
683        .collect::<Vec<_>>()))
684}
685
686/// Publish the domain's `_qdp` TXT front-door record to Cloudflare (no hosting needed).
687#[cfg(not(target_arch = "wasm32"))]
688pub fn cf_publish_front_door(
689    token: String,
690    zone_id: String,
691    domain: String,
692) -> Result<serde_json::Value, String> {
693    let rec = build_front_door_record(&domain)?;
694    let cfg = crate::cloudflare::CfConfig {
695        api_token: token,
696        zone_id,
697    };
698    let id = crate::cloudflare::publish_front_door(&cfg, &rec)?;
699    Ok(serde_json::json!({
700        "record_id": id,
701        "dns_name": crate::front_door::dns_record_name(&domain),
702        "dns_txt": rec.to_dns_txt(),
703    }))
704}
705
706/// Provision a Cloudflare R2 bucket.
707#[cfg(not(target_arch = "wasm32"))]
708pub fn cf_provision_r2_bucket(
709    token: String,
710    account_id: String,
711    bucket_name: String,
712) -> Result<serde_json::Value, String> {
713    crate::cloudflare::provision_r2_bucket(&token, &account_id, &bucket_name)?;
714    Ok(serde_json::json!({ "ok": true, "bucket": bucket_name }))
715}
716
717/// Provision a Cloudflare Worker using the local vendor JS file.
718#[cfg(not(target_arch = "wasm32"))]
719pub fn cf_provision_worker(
720    token: String,
721    account_id: String,
722    script_name: String,
723) -> Result<serde_json::Value, String> {
724    let script_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
725        .join("../../vendor/cloudflare-worker/worker.js");
726    let script_content = std::fs::read_to_string(&script_path)
727        .map_err(|e| format!("Failed to read worker.js: {e}"))?;
728
729    crate::cloudflare::provision_worker(&token, &account_id, &script_name, &script_content)?;
730    Ok(serde_json::json!({ "ok": true, "script": script_name }))
731}
732
733/// Provision a Cloudflare Tunnel.
734#[cfg(not(target_arch = "wasm32"))]
735pub fn cf_provision_tunnel(
736    token: String,
737    account_id: String,
738    tunnel_name: String,
739    tunnel_secret: String,
740) -> Result<serde_json::Value, String> {
741    let tunnel_id =
742        crate::cloudflare::provision_tunnel(&token, &account_id, &tunnel_name, &tunnel_secret)?;
743    Ok(serde_json::json!({ "ok": true, "tunnel_id": tunnel_id }))
744}
745
746/// Route a Cloudflare Tunnel in DNS.
747#[cfg(not(target_arch = "wasm32"))]
748pub fn cf_route_tunnel_dns(
749    token: String,
750    zone_id: String,
751    record_name: String,
752    tunnel_id: String,
753) -> Result<serde_json::Value, String> {
754    let record_id =
755        crate::cloudflare::route_tunnel_dns(&token, &zone_id, &record_name, &tunnel_id)?;
756    Ok(serde_json::json!({ "ok": true, "record_id": record_id }))
757}
758
759/// Verify a GitHub PAT.
760#[cfg(not(target_arch = "wasm32"))]
761pub fn github_verify_token(token: String) -> Result<serde_json::Value, String> {
762    let login = crate::github::verify_github_token(&token).map_err(|e| e.to_string())?;
763    Ok(serde_json::json!({ "ok": true, "login": login }))
764}
765
766/// Create a GitHub repository and push static site files.
767#[cfg(not(target_arch = "wasm32"))]
768pub fn github_deploy_static_site(
769    token: String,
770    repo_name: String,
771    files: std::collections::HashMap<String, String>,
772) -> Result<serde_json::Value, String> {
773    let full_name =
774        crate::github::create_repository(&token, &repo_name).map_err(|e| e.to_string())?;
775    crate::github::push_static_site(&token, &full_name, files).map_err(|e| e.to_string())?;
776    Ok(serde_json::json!({ "ok": true, "full_name": full_name }))
777}
778
779/// Provision a Cloudflare Pages project linked to a GitHub repo.
780#[cfg(not(target_arch = "wasm32"))]
781pub fn cf_provision_pages_project(
782    token: String,
783    account_id: String,
784    project_name: String,
785    github_repo: String,
786) -> Result<serde_json::Value, String> {
787    crate::cloudflare::provision_pages_project(&token, &account_id, &project_name, &github_repo)?;
788    Ok(serde_json::json!({ "ok": true, "project": project_name }))
789}
790
791/// Start serving `/.well-known/QDP` for a domain from a local HTTP server (self-host over the mesh).
792#[cfg(not(target_arch = "wasm32"))]
793pub fn start_qdp_server(domain: String, bind_addr: String) -> Result<serde_json::Value, String> {
794    let rec = build_front_door_record(&domain)?;
795    let addr = bind_addr.clone();
796    std::thread::spawn(move || {
797        let _ = crate::qdp_http::serve_blocking(rec, &addr);
798    });
799    Ok(serde_json::json!({ "serving": bind_addr, "path": crate::qdp_server::WELL_KNOWN_QDP_PATH }))
800}
801
802/// Parse a magic link (deep link / https / bare `qcx1_…`) into the connection identifier it carries.
803pub fn parse_magic_link(link: String) -> Result<serde_json::Value, String> {
804    let id = crate::magic_link::from_link(&link)?;
805    serde_json::to_value(id).map_err(|e| e.to_string())
806}
807
808/// Send mail via SMTP. `smtp_json` = `SmtpConfig`, `mail_json` = `OutgoingMail`.
809#[cfg(not(target_arch = "wasm32"))]
810pub fn mail_send(smtp_json: String, mail_json: String) -> Result<serde_json::Value, String> {
811    let cfg: crate::mail_transport::SmtpConfig =
812        serde_json::from_str(&smtp_json).map_err(|e| format!("bad smtp config: {e}"))?;
813    let mail: crate::mail_transport::OutgoingMail =
814        serde_json::from_str(&mail_json).map_err(|e| format!("bad mail: {e}"))?;
815    crate::mail_transport::send(&cfg, &mail)?;
816    Ok(serde_json::json!({ "sent": true }))
817}
818
819/// Fetch unseen mail via IMAP, apply semantic delivery + rules, and **store accepted mail**
820/// in the local inbox (same store as the SMTP receiver).
821#[cfg(not(target_arch = "wasm32"))]
822pub fn mail_fetch(imap_json: String, mailbox: String) -> Result<serde_json::Value, String> {
823    let cfg: crate::mail_transport::ImapConfig =
824        serde_json::from_str(&imap_json).map_err(|e| format!("bad imap config: {e}"))?;
825    let msgs = crate::mail_transport::fetch_unseen(&cfg, &mailbox)?;
826    let mut stored_ids = Vec::new();
827    let evaluated: Vec<serde_json::Value> = msgs
828        .into_iter()
829        .map(|m| {
830            let to = if m.to_address.contains('@') {
831                m.to_address.clone()
832            } else {
833                cfg.username.clone()
834            };
835            // Reuse the product accept path so IMAP import and SMTP land in one inbox.
836            let r = crate::mail_inbound::accept_message(
837                &m.from_address,
838                &to,
839                &m.subject,
840                &format!("(imported via IMAP; size {} bytes)", m.size_bytes),
841                m.sender_verified,
842                m.sender_did.clone(),
843            );
844            if let Some(ref s) = r.stored {
845                stored_ids.push(s.id.clone());
846            }
847            serde_json::json!({
848                "message": m,
849                "accepted": r.accepted,
850                "rejected": r.rejected,
851                "stored": r.stored,
852            })
853        })
854        .collect();
855    Ok(serde_json::json!({
856        "messages": evaluated,
857        "stored_ids": stored_ids,
858        "stored_count": stored_ids.len(),
859    }))
860}