This commit is contained in:
Christoph Haas 2026-03-21 22:41:23 +01:00
commit f4be03ceba
1826 changed files with 830924 additions and 0 deletions

8
backend/.env Normal file
View file

@ -0,0 +1,8 @@
DB_HOST=localhost
DB_PORT=3306
DB_USER=mailadmin
DB_PASSWORD=MAILADMIN-PASSWORD-HERE
DB_NAME=mailserver
JWT_SECRET=change-this-secret-in-production
ADMIN_USER=admin
ADMIN_PASSWORD=admin123

19
backend/.env.example Normal file
View file

@ -0,0 +1,19 @@
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=mailadmin
DB_PASSWORD=your-password
DB_NAME=mailserver
# App
PORT=8080
USE_EMBEDDED=true
JWT_SECRET=change-this-to-a-random-secret
# Mail server paths
MAIL_DATA_DIR=/var/vmail
POSTQUEUE_PATH=/usr/sbin/postqueue
POSTSUPER_PATH=/usr/sbin/postsuper
DOVECOT_QUOTA_CMD=/usr/bin/doveadm
JOURNALCTL_PATH=/usr/bin/journalctl
RSPMAD_API=http://127.0.0.1:11334

View file

@ -0,0 +1 @@
export const env={}

View file

@ -0,0 +1 @@
.loading.svelte-12qhfyh{min-height:100vh;display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:#666}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,sans-serif;background:#f5f5f5}.app.svelte-12qhfyh{min-height:100vh}header.svelte-12qhfyh{background:#2c3e50;color:#fff;padding:.75rem 2rem;display:flex;align-items:center;gap:2rem;flex-wrap:wrap}h1.svelte-12qhfyh{margin:0;font-size:1.25rem}nav.svelte-12qhfyh{display:flex;gap:.5rem;flex:1}nav.svelte-12qhfyh a:where(.svelte-12qhfyh){color:#ecf0f1;text-decoration:none;padding:.5rem 1rem;border-radius:4px;font-size:.9rem;transition:background .2s}nav.svelte-12qhfyh a:where(.svelte-12qhfyh):hover{background:#34495e}main.svelte-12qhfyh{padding:2rem;max-width:1400px;margin:0 auto}.user-menu.svelte-12qhfyh{display:flex;align-items:center;gap:1rem;font-size:.9rem}.user-menu.svelte-12qhfyh span:where(.svelte-12qhfyh){color:#ecf0f1}.user-menu.svelte-12qhfyh a:where(.svelte-12qhfyh){color:#ecf0f1;text-decoration:none;padding:.375rem .75rem;border:1px solid #7f8c8d;border-radius:4px;font-size:.85rem}.user-menu.svelte-12qhfyh a:where(.svelte-12qhfyh):hover{background:#34495e}.user-menu.svelte-12qhfyh button:where(.svelte-12qhfyh){background:transparent;border:1px solid #7f8c8d;color:#ecf0f1;padding:.375rem .75rem;border-radius:4px;cursor:pointer;font-size:.85rem}.user-menu.svelte-12qhfyh button:where(.svelte-12qhfyh):hover{background:#34495e}

View file

@ -0,0 +1 @@
.header.svelte-15uyvzd{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;gap:1rem}.back.svelte-15uyvzd{color:#3498db;text-decoration:none;font-size:.9rem}.back.svelte-15uyvzd:hover{text-decoration:underline}.header.svelte-15uyvzd h2:where(.svelte-15uyvzd){flex:1;margin:0}table.svelte-15uyvzd{width:100%;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 4px #0000001a}th.svelte-15uyvzd,td.svelte-15uyvzd{padding:1rem;text-align:left;border-bottom:1px solid #eee}th.svelte-15uyvzd{background:#f8f9fa;font-weight:600}button.svelte-15uyvzd{padding:.5rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#3498db;color:#fff}button.danger.svelte-15uyvzd{background:#e74c3c}.modal-backdrop.svelte-15uyvzd{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;border:none;cursor:pointer;z-index:100}.modal.svelte-15uyvzd{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:2rem;border-radius:8px;min-width:400px;z-index:101}.modal.svelte-15uyvzd h3:where(.svelte-15uyvzd){margin-top:0}.modal.svelte-15uyvzd form:where(.svelte-15uyvzd){display:flex;flex-direction:column;gap:1rem}.modal.svelte-15uyvzd label:where(.svelte-15uyvzd){display:flex;flex-direction:column;gap:.5rem}.modal.svelte-15uyvzd input:where(.svelte-15uyvzd){padding:.5rem;border:1px solid #ddd;border-radius:4px}.actions.svelte-15uyvzd{display:flex;gap:1rem;justify-content:flex-end}

View file

@ -0,0 +1 @@
.header.svelte-oa5z6t{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;gap:1rem}.back.svelte-oa5z6t{color:#3498db;text-decoration:none;font-size:.9rem}.back.svelte-oa5z6t:hover{text-decoration:underline}.header.svelte-oa5z6t h2:where(.svelte-oa5z6t){flex:1;margin:0}table.svelte-oa5z6t{width:100%;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 4px #0000001a}th.svelte-oa5z6t,td.svelte-oa5z6t{padding:1rem;text-align:left;border-bottom:1px solid #eee}th.svelte-oa5z6t{background:#f8f9fa;font-weight:600}button.svelte-oa5z6t{padding:.5rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#3498db;color:#fff}button.danger.svelte-oa5z6t{background:#e74c3c}.modal-backdrop.svelte-oa5z6t{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;border:none;cursor:pointer;z-index:100}.modal.svelte-oa5z6t{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:2rem;border-radius:8px;min-width:400px;z-index:101}.modal.svelte-oa5z6t h3:where(.svelte-oa5z6t){margin-top:0}.modal.svelte-oa5z6t form:where(.svelte-oa5z6t){display:flex;flex-direction:column;gap:1rem}.modal.svelte-oa5z6t label:where(.svelte-oa5z6t){display:flex;flex-direction:column;gap:.5rem}.modal.svelte-oa5z6t input:where(.svelte-oa5z6t){padding:.5rem;border:1px solid #ddd;border-radius:4px}.actions.svelte-oa5z6t{display:flex;gap:1rem;justify-content:flex-end}

View file

@ -0,0 +1 @@
.header.svelte-1lsf4ps{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;flex-wrap:wrap;gap:1rem}.controls.svelte-1lsf4ps{display:flex;gap:1rem;align-items:center}.controls.svelte-1lsf4ps label:where(.svelte-1lsf4ps){display:flex;align-items:center;gap:.5rem}.controls.svelte-1lsf4ps select:where(.svelte-1lsf4ps),.controls.svelte-1lsf4ps input:where(.svelte-1lsf4ps){padding:.5rem;border:1px solid #ddd;border-radius:4px}button.svelte-1lsf4ps{padding:.5rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#3498db;color:#fff}.log-container.svelte-1lsf4ps{background:#1a1a2e;color:#eee;padding:1rem;border-radius:8px;max-height:600px;overflow-y:auto;font-family:monospace;font-size:.85rem}.log-entry.svelte-1lsf4ps{padding:.25rem 0;border-bottom:1px solid #333}.log-entry.svelte-1lsf4ps:last-child{border-bottom:none}.timestamp.svelte-1lsf4ps{color:#888;margin-right:.5rem}.priority.svelte-1lsf4ps{margin-right:.5rem;font-weight:700}.log-entry.error.svelte-1lsf4ps .priority:where(.svelte-1lsf4ps){color:#e74c3c}.log-entry.warning.svelte-1lsf4ps .priority:where(.svelte-1lsf4ps){color:#f39c12}.log-entry.info.svelte-1lsf4ps .priority:where(.svelte-1lsf4ps){color:#3498db}.error.svelte-1lsf4ps{color:#e74c3c}

View file

@ -0,0 +1 @@
.header.svelte-qegr5c{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem}table.svelte-qegr5c{width:100%;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 4px #0000001a}th.svelte-qegr5c,td.svelte-qegr5c{padding:.75rem;text-align:left;border-bottom:1px solid #eee;font-size:.9rem}th.svelte-qegr5c{background:#f8f9fa;font-weight:600}.id.svelte-qegr5c{font-family:monospace;font-size:.8rem}.reason.svelte-qegr5c{color:#e74c3c}button.svelte-qegr5c{padding:.25rem .5rem;border:none;border-radius:4px;cursor:pointer;background:#3498db;color:#fff;font-size:.8rem;margin-right:.25rem}button.danger.svelte-qegr5c{background:#e74c3c}.error.svelte-qegr5c{color:#e74c3c}

View file

@ -0,0 +1 @@
.stats-grid.svelte-1uha8ag{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem;margin-top:2rem}.stat-card.svelte-1uha8ag{background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 2px 4px #0000001a;text-align:center}.stat-card.warning.svelte-1uha8ag{border:2px solid #e74c3c}.stat-card.svelte-1uha8ag h3:where(.svelte-1uha8ag){font-size:2.5rem;margin:0;color:#2c3e50}.stat-card.svelte-1uha8ag p:where(.svelte-1uha8ag){margin:.5rem 0 0;color:#7f8c8d}.warning.svelte-1uha8ag h3:where(.svelte-1uha8ag){color:#e74c3c}

View file

@ -0,0 +1 @@
.page-container.svelte-imhhoo{min-height:100vh;background:#f5f7fa}.header.svelte-imhhoo{background:#fff;padding:1rem 2rem;box-shadow:0 2px 4px #0000001a}.header-content.svelte-imhhoo{max-width:800px;margin:0 auto;display:flex;justify-content:space-between;align-items:center}.header.svelte-imhhoo h1:where(.svelte-imhhoo){margin:0;font-size:1.5rem;color:#2c3e50}.user-info.svelte-imhhoo{display:flex;align-items:center;gap:1rem}.logout.svelte-imhhoo{padding:.5rem 1rem;background:#e74c3c;color:#fff;border:none;border-radius:4px;cursor:pointer}.content.svelte-imhhoo{max-width:500px;margin:2rem auto;padding:2rem;background:#fff;border-radius:12px;box-shadow:0 2px 8px #0000001a}.error.svelte-imhhoo{background:#fee;color:#c00;padding:.75rem;border-radius:4px;margin-bottom:1rem}.success.svelte-imhhoo{background:#efe;color:#060;padding:.75rem;border-radius:4px;margin-bottom:1rem}form.svelte-imhhoo{display:flex;flex-direction:column;gap:1rem}label.svelte-imhhoo{display:flex;flex-direction:column;gap:.5rem;font-weight:500;color:#555}input.svelte-imhhoo{padding:.75rem;border:1px solid #ddd;border-radius:6px;font-size:1rem}input.svelte-imhhoo:focus{outline:none;border-color:#667eea;box-shadow:0 0 0 3px #667eea1a}button.svelte-imhhoo{padding:.75rem;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:6px;font-size:1rem;font-weight:500;cursor:pointer;margin-top:.5rem}button.svelte-imhhoo:hover:not(:disabled){opacity:.9}button.svelte-imhhoo:disabled{opacity:.6;cursor:not-allowed}.links.svelte-imhhoo{margin-top:1.5rem;text-align:center}.links.svelte-imhhoo a:where(.svelte-imhhoo){color:#667eea;text-decoration:none}.links.svelte-imhhoo a:where(.svelte-imhhoo):hover{text-decoration:underline}

View file

@ -0,0 +1 @@
.page-container.svelte-1s1ktxm{min-height:100vh;background:#f5f7fa}.header.svelte-1s1ktxm{background:#fff;padding:1rem 2rem;box-shadow:0 2px 4px #0000001a;display:flex;justify-content:space-between;align-items:center}.header.svelte-1s1ktxm h1:where(.svelte-1s1ktxm){margin:0;font-size:1.5rem;color:#2c3e50}.user-info.svelte-1s1ktxm{display:flex;align-items:center;gap:1rem}.logout.svelte-1s1ktxm{padding:.5rem 1rem;background:#e74c3c;color:#fff;border:none;border-radius:4px;cursor:pointer}.content.svelte-1s1ktxm{max-width:800px;margin:2rem auto;padding:0 1rem}.card.svelte-1s1ktxm{background:#fff;padding:1.5rem;border-radius:12px;box-shadow:0 2px 8px #0000001a;margin-bottom:1.5rem}.card.svelte-1s1ktxm h2:where(.svelte-1s1ktxm){margin:0 0 1rem;color:#2c3e50;font-size:1.2rem}.info-row.svelte-1s1ktxm{display:flex;padding:.75rem 0;border-bottom:1px solid #eee}.info-row.svelte-1s1ktxm:last-child{border-bottom:none}.label.svelte-1s1ktxm{font-weight:500;color:#666;width:120px}.value.svelte-1s1ktxm{color:#2c3e50}.actions.svelte-1s1ktxm{display:flex;gap:1rem;flex-wrap:wrap}.action-btn.svelte-1s1ktxm{padding:.75rem 1.5rem;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;text-decoration:none;border-radius:6px;font-weight:500;transition:opacity .2s}.action-btn.svelte-1s1ktxm:hover{opacity:.9}

View file

@ -0,0 +1 @@
.login-container.svelte-15sczaz{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#667eea,#764ba2);padding:1rem}.login-card.svelte-15sczaz{background:#fff;padding:2rem;border-radius:12px;box-shadow:0 10px 40px #0003;width:100%;max-width:400px}h1.svelte-15sczaz{text-align:center;margin:0 0 1rem;color:#2c3e50}.info.svelte-15sczaz{color:#666;text-align:center;margin-bottom:1.5rem;font-size:.9rem}.error.svelte-15sczaz{background:#fee;color:#c00;padding:.75rem;border-radius:4px;margin-bottom:1rem;font-size:.9rem}.success.svelte-15sczaz{background:#efe;color:#060;padding:1rem;border-radius:4px;margin-bottom:1rem;text-align:center}.success.svelte-15sczaz p:where(.svelte-15sczaz){margin:.5rem 0}form.svelte-15sczaz{display:flex;flex-direction:column;gap:1rem}label.svelte-15sczaz{display:flex;flex-direction:column;gap:.5rem;font-weight:500;color:#555}input.svelte-15sczaz{padding:.75rem;border:1px solid #ddd;border-radius:6px;font-size:1rem}input.svelte-15sczaz:focus{outline:none;border-color:#667eea;box-shadow:0 0 0 3px #667eea1a}button.svelte-15sczaz{padding:.75rem;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:6px;font-size:1rem;font-weight:500;cursor:pointer;transition:opacity .2s}button.svelte-15sczaz:hover:not(:disabled){opacity:.9}button.svelte-15sczaz:disabled{opacity:.6;cursor:not-allowed}.links.svelte-15sczaz{margin-top:1.5rem;text-align:center;padding-top:1rem;border-top:1px solid #eee}.links.svelte-15sczaz a:where(.svelte-15sczaz){color:#667eea;text-decoration:none;font-size:.9rem}.links.svelte-15sczaz a:where(.svelte-15sczaz):hover{text-decoration:underline}

View file

@ -0,0 +1 @@
.login-container.svelte-1i2smtp{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#2c3e50,#34495e)}.login-card.svelte-1i2smtp{background:#fff;padding:3rem;border-radius:12px;box-shadow:0 10px 40px #0003;width:100%;max-width:400px}h1.svelte-1i2smtp{margin:0;text-align:center;color:#2c3e50;font-size:2rem}.subtitle.svelte-1i2smtp{text-align:center;color:#7f8c8d;margin:.5rem 0 2rem}.error.svelte-1i2smtp{background:#fee;border:1px solid #fcc;color:#c00;padding:.75rem;border-radius:4px;margin-bottom:1rem;font-size:.9rem}form.svelte-1i2smtp{display:flex;flex-direction:column;gap:1.25rem}label.svelte-1i2smtp{display:flex;flex-direction:column;gap:.5rem;font-weight:500;color:#2c3e50}input.svelte-1i2smtp{padding:.75rem;border:1px solid #ddd;border-radius:6px;font-size:1rem;transition:border-color .2s}input.svelte-1i2smtp:focus{outline:none;border-color:#3498db}.forgot.svelte-1i2smtp{text-align:right;margin-top:-.5rem}.forgot.svelte-1i2smtp a:where(.svelte-1i2smtp){color:#3498db;text-decoration:none;font-size:.9rem}.forgot.svelte-1i2smtp a:where(.svelte-1i2smtp):hover{text-decoration:underline}button.svelte-1i2smtp{padding:.875rem;background:#3498db;color:#fff;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s;margin-top:.5rem}button.svelte-1i2smtp:hover:not(:disabled){background:#2980b9}button.svelte-1i2smtp:disabled{opacity:.7;cursor:not-allowed}

View file

@ -0,0 +1 @@
.header.svelte-83awcy{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem}table.svelte-83awcy{width:100%;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 4px #0000001a}th.svelte-83awcy,td.svelte-83awcy{padding:1rem;text-align:left;border-bottom:1px solid #eee}th.svelte-83awcy{background:#f8f9fa;font-weight:600}button.svelte-83awcy{padding:.5rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#3498db;color:#fff}button.danger.svelte-83awcy{background:#e74c3c}.modal-backdrop.svelte-83awcy{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:100}.modal.svelte-83awcy{background:#fff;padding:2rem;border-radius:8px;min-width:400px}.modal.svelte-83awcy form:where(.svelte-83awcy){display:flex;flex-direction:column;gap:1rem}.modal.svelte-83awcy label:where(.svelte-83awcy){display:flex;flex-direction:column;gap:.5rem}.modal.svelte-83awcy input:where(.svelte-83awcy){padding:.5rem;border:1px solid #ddd;border-radius:4px}.actions.svelte-83awcy{display:flex;gap:1rem;justify-content:flex-end}

View file

@ -0,0 +1 @@
.header.svelte-lb8x40{margin-bottom:2rem}.back.svelte-lb8x40{display:inline-block;margin-bottom:1rem;color:#3498db;text-decoration:none}.back.svelte-lb8x40:hover{text-decoration:underline}.error.svelte-lb8x40{color:#e74c3c}.cards-grid.svelte-lb8x40{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem}.card.svelte-lb8x40{background:#fff;padding:2rem;border-radius:8px;box-shadow:0 2px 4px #0000001a;text-align:center;text-decoration:none;color:inherit;transition:transform .2s,box-shadow .2s}.card.svelte-lb8x40:hover{transform:translateY(-2px);box-shadow:0 4px 8px #00000026}.card.svelte-lb8x40 h3:where(.svelte-lb8x40){font-size:3rem;margin:0;color:#3498db}.card.svelte-lb8x40 p:where(.svelte-lb8x40){margin:.5rem 0 0;color:#7f8c8d;font-size:1.1rem}

View file

@ -0,0 +1 @@
var C=Object.defineProperty;var w=a=>{throw TypeError(a)};var D=(a,e,s)=>e in a?C(a,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):a[e]=s;var A=(a,e,s)=>D(a,typeof e!="symbol"?e+"":e,s),x=(a,e,s)=>e.has(a)||w("Cannot "+s);var t=(a,e,s)=>(x(a,e,"read from private field"),s?s.call(a):e.get(a)),u=(a,e,s)=>e.has(a)?w("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(a):e.set(a,s),E=(a,e,s,i)=>(x(a,e,"write to private field"),i?i.call(a,s):e.set(a,s),s);import{U as N,_ as g,V as R,o as F,E as M,c as S,h as k,A as B,Z as j,G as z,w as G,v as I,a9 as P,x as U,y as V,j as Z,z as T}from"./DtGl34IE.js";var d,l,c,p,v,m,b;class q{constructor(e,s=!0){A(this,"anchor");u(this,d,new Map);u(this,l,new Map);u(this,c,new Map);u(this,p,new Set);u(this,v,!0);u(this,m,e=>{if(t(this,d).has(e)){var s=t(this,d).get(e),i=t(this,l).get(s);if(i)N(i),t(this,p).delete(s);else{var n=t(this,c).get(s);n&&(t(this,l).set(s,n.effect),t(this,c).delete(s),n.fragment.lastChild.remove(),this.anchor.before(n.fragment),i=n.effect)}for(const[f,h]of t(this,d)){if(t(this,d).delete(f),f===e)break;const r=t(this,c).get(h);r&&(g(r.effect),t(this,c).delete(h))}for(const[f,h]of t(this,l)){if(f===s||t(this,p).has(f))continue;const r=()=>{if(Array.from(t(this,d).values()).includes(f)){var _=document.createDocumentFragment();j(h,_),_.append(F()),t(this,c).set(f,{effect:h,fragment:_})}else g(h);t(this,p).delete(f),t(this,l).delete(f)};t(this,v)||!i?(t(this,p).add(f),R(h,r,!1)):r()}}});u(this,b,e=>{t(this,d).delete(e);const s=Array.from(t(this,d).values());for(const[i,n]of t(this,c))s.includes(i)||(g(n.effect),t(this,c).delete(i))});this.anchor=e,E(this,v,s)}ensure(e,s){var i=S,n=z();if(s&&!t(this,l).has(e)&&!t(this,c).has(e))if(n){var f=document.createDocumentFragment(),h=F();f.append(h),t(this,c).set(e,{effect:M(()=>s(h)),fragment:f})}else t(this,l).set(e,M(()=>s(this.anchor)));if(t(this,d).set(i,e),n){for(const[r,o]of t(this,l))r===e?i.unskip_effect(o):i.skip_effect(o);for(const[r,o]of t(this,c))r===e?i.unskip_effect(o.effect):i.skip_effect(o.effect);i.oncommit(t(this,m)),i.ondiscard(t(this,b))}else k&&(this.anchor=B),t(this,m).call(this,i)}}d=new WeakMap,l=new WeakMap,c=new WeakMap,p=new WeakMap,v=new WeakMap,m=new WeakMap,b=new WeakMap;function K(a,e,s=!1){var i;k&&(i=B,I());var n=new q(a),f=s?P:0;function h(r,o){if(k){var _=U(i);if(r!==parseInt(_.substring(1))){var y=V();Z(y),n.anchor=y,T(!1),n.ensure(r,o),T(!0);return}}n.ensure(r,o)}G(()=>{var r=!1;e((o,_=0)=>{r=!0,h(_,o)}),r||h(-1,null)},f)}export{q as B,K as i};

View file

@ -0,0 +1 @@
import{s as c,g as l}from"./BX-b5t2l.js";import{n as a,m as o,g as b,t as d,d as p,s as g}from"./DtGl34IE.js";let s=!1,i=Symbol();function m(e,u,r){const n=r[u]??(r[u]={store:null,source:o(void 0),unsubscribe:a});if(n.store!==e&&!(i in r))if(n.unsubscribe(),n.store=e??null,e==null)n.source.v=void 0,n.unsubscribe=a;else{var t=!0;n.unsubscribe=c(e,f=>{t?n.source.v=f:g(n.source,f)}),t=!1}return e&&i in r?l(e):b(n.source)}function y(){const e={};function u(){d(()=>{for(var r in e)e[r].unsubscribe();p(e,i,{enumerable:!1,value:!0})})}return[e,u]}function N(e){var u=s;try{return s=!1,[e(),s]}finally{s=u}}export{y as a,N as c,m as s};

View file

@ -0,0 +1 @@
import{s as e}from"./RKc8iKz_.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

View file

@ -0,0 +1 @@
import{n as o,u as a,ah as d}from"./DtGl34IE.js";function p(s,u,e){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,e));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function h(s,u=o){let e=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,e)){const b=!i.length;for(const n of t)n[1](),i.push(n,s);if(b){for(let n=0;n<i.length;n+=2)i[n][0](i[n+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const n=[r,b];return t.add(n),t.size===1&&(e=u(c,f)||o),r(s),()=>{t.delete(n),t.size===0&&e&&(e(),e=null)}}return{set:c,update:f,subscribe:l}}function _(s){let u;return p(s,e=>u=e)(),u}export{_ as g,p as s,h as w};

View file

@ -0,0 +1,2 @@
import{h as c}from"./DtGl34IE.js";const a=[...`
\r\f \v\uFEFF`];function e(r,g,u){var f=r==null?"":""+r;if(g&&(f=f?f+" "+g:g),u){for(var t of Object.keys(u))if(u[t])f=f?f+" "+t:t;else if(f.length)for(var l=t.length,i=0;(i=f.indexOf(t,i))>=0;){var n=i+l;(i===0||a.includes(f[i-1]))&&(n===f.length||a.includes(f[n]))?f=(i===0?"":f.substring(0,i))+f.substring(n+1):i=n}}return f===""?null:f}function v(r,g,u,f,t,l){var i=r.__className;if(c||i!==u||i===void 0){var n=e(u,f,l);(!c||n!==r.getAttribute("class"))&&(n==null?r.removeAttribute("class"):r.className=n),r.__className=u}else if(l&&t!==l)for(var o in l){var s=!!l[o];(t==null||s!==!!t[o])&&r.classList.toggle(o,s)}return l}export{v as s};

View file

@ -0,0 +1 @@
import{am as p,o as u,an as c,k as l,ao as E,ap as g,aq as w,h as d,A as s,ar as y,v as N,as as A,j as M,at as x}from"./DtGl34IE.js";var f;const i=((f=globalThis==null?void 0:globalThis.window)==null?void 0:f.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function L(t){return(i==null?void 0:i.createHTML(t))??t}function b(t){var r=p("template");return r.innerHTML=L(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=c;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function R(t,r){var e=(r&g)!==0,m=(r&w)!==0,a,v=!t.startsWith("<!>");return()=>{if(d)return n(s,null),s;a===void 0&&(a=b(v?t:"<!>"+t),e||(a=l(a)));var o=m||E?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=l(o),h=o.lastChild;n(T,h)}else n(o,o);return o}}function C(t=""){if(!d){var r=u(t+"");return n(r,r),r}var e=s;return e.nodeType!==A?(e.before(e=u()),M(e)):x(e),n(e,e),e}function I(){if(d)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=u();return t.append(r,e),n(r,e),t}function D(t,r){if(d){var e=c;(!(e.f&y)||e.nodes.end===null)&&(e.nodes.end=s),N();return}t!==null&&t.before(r)}const O="5";var _;typeof window<"u"&&((_=window.__svelte??(window.__svelte={})).v??(_.v=new Set)).add(O);export{D as a,n as b,I as c,R as f,C as t};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{aa as g,ab as d,a8 as l,u as b,ac as i,ad as m,g as p,ae as v,af as h,ag as k}from"./DtGl34IE.js";function x(t=!1){const a=g,e=a.l.u;if(!e)return;let o=()=>v(a.s);if(t){let n=0,s={};const _=h(()=>{let c=!1;const r=a.s;for(const f in r)r[f]!==s[f]&&(s[f]=r[f],c=!0);return c&&n++,n});o=()=>p(_)}e.b.length&&d(()=>{u(a,o),i(e.b)}),l(()=>{const n=b(()=>e.m.map(m));return()=>{for(const s of n)typeof s=="function"&&s()}}),e.a.length&&l(()=>{u(a,o),i(e.a)})}function u(t,a){if(t.l.s)for(const e of t.l.s)p(e);a()}k();export{x as i};

View file

@ -0,0 +1 @@
import{a8 as o,aa as t,ai as a,u as c}from"./DtGl34IE.js";function u(n){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(n){t===null&&u(),a&&t.l!==null?l(t).m.push(n):o(()=>{const e=c(n);if(typeof e=="function")return e})}function l(n){var e=n.l;return e.u??(e.u={a:[],b:[],m:[]})}export{r as o};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{l as b,c as v,b as m,u as _,r as i,h as y}from"./DtGl34IE.js";function k(e,l,u=l){var s=new WeakSet;b(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=t(e)?o(a):a,u(a),v!==null&&s.add(v),await m(),a!==(a=l())){var d=e.selectionStart,c=e.selectionEnd,n=e.value.length;if(e.value=a??"",c!==null){var f=e.value.length;d===c&&c===n&&f>n?(e.selectionStart=f,e.selectionEnd=f):(e.selectionStart=d,e.selectionEnd=Math.min(c,f))}}}),(y&&e.defaultValue!==e.value||_(l)==null&&e.value)&&(u(t(e)?o(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}t(e)&&r===o(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function t(e){var l=e.type;return l==="number"||l==="range"}function o(e){return e===""?null:+e}export{k as b};

View file

@ -0,0 +1 @@
import{h as i,q as d,e as n,L as v,N as h,I as u,f as g,i as A}from"./DtGl34IE.js";const L=Symbol("is custom element"),N=Symbol("is html"),l=u?"link":"LINK";function S(r){if(i){var s=!1,e=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var a=r.value;t(r,"value",null),r.value=a}if(r.hasAttribute("checked")){var o=r.checked;t(r,"checked",null),r.checked=o}}};r.__on_r=e,d(e),n()}}function t(r,s,e,a){var o=p(r);i&&(o[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===l)||o[s]!==(o[s]=e)&&(s==="loading"&&(r[v]=e),e==null?r.removeAttribute(s):typeof e!="string"&&I(r).includes(s)?r[s]=e:r.setAttribute(s,e))}function p(r){return r.__attributes??(r.__attributes={[L]:r.nodeName.includes("-"),[N]:r.namespaceURI===h})}var c=new Map;function I(r){var s=r.getAttribute("is")||r.nodeName,e=c.get(s);if(e)return e;c.set(s,e=[]);for(var a,o=r,f=Element.prototype;f!==o;){a=A(o);for(var _ in a)a[_].set&&e.push(_);o=g(o)}return e}export{S as r,t as s};

View file

@ -0,0 +1 @@
import{h as N,j as z,k as W,o as H,v as Z,w as $,g as L,x as j,H as ee,y as U,z as O,A as b,C as ne,B as re,D as X,c as fe,E as Y,F as ae,G as ie,J as le,K as ue,M as V,O as se,P as q,Q as oe,R as ve,m as te,S as T,T as de,U as J,V as K,W as y,X as ce,Y as pe,Z as ge,_ as he,$ as _e}from"./DtGl34IE.js";function Ce(e,a){return a}function Ee(e,a,l){for(var o=[],c=a.length,u,i=a.length,r=0;r<c;r++){let v=a[r];K(v,()=>{if(u){if(u.pending.delete(v),u.done.add(v),u.pending.size===0){var w=e.outrogroups;B(e,V(u.done)),w.delete(u),w.size===0&&(e.outrogroups=null)}}else i-=1},!1)}if(i===0){var t=o.length===0&&l!==null;if(t){var f=l,d=f.parentNode;pe(d),d.append(f),e.items.clear()}B(e,a,!t)}else u={pending:new Set(a),done:new Set},(e.outrogroups??(e.outrogroups=new Set)).add(u)}function B(e,a,l=!0){var o;if(e.pending.size>0){o=new Set;for(const i of e.pending.values())for(const r of i)o.add(e.items.get(r).e)}for(var c=0;c<a.length;c++){var u=a[c];if(o!=null&&o.has(u)){u.f|=T;const i=document.createDocumentFragment();ge(u,i)}else he(a[c],l)}}var G;function Ae(e,a,l,o,c,u=null){var i=e,r=new Map;{var t=e;i=N?z(W(t)):t.appendChild(H())}N&&Z();var f=null,d=le(()=>{var s=l();return ue(s)?s:s==null?[]:V(s)}),v,w=new Map,h=!0;function n(s){p.effect.f&de||(p.pending.delete(s),p.fallback=f,me(p,v,i,a,o),f!==null&&(v.length===0?f.f&T?(f.f^=T,D(f,null,i)):J(f):K(f,()=>{f=null})))}function C(s){p.pending.delete(s)}var I=$(()=>{v=L(d);var s=v.length;let k=!1;if(N){var R=j(i)===ee;R!==(s===0)&&(i=U(),z(i),O(!1),k=!0)}for(var _=new Set,A=fe,M=ie(),E=0;E<s;E+=1){N&&b.nodeType===ne&&b.data===re&&(i=b,k=!0,O(!1));var g=v[E],F=o(g,E),m=h?null:r.get(F);m?(m.v&&X(m.v,g),m.i&&X(m.i,E),M&&A.unskip_effect(m.e)):(m=Te(r,h?i:G??(G=H()),g,F,E,c,a,l),h||(m.e.f|=T),r.set(F,m)),_.add(F)}if(s===0&&u&&!f&&(h?f=Y(()=>u(i)):(f=Y(()=>u(G??(G=H()))),f.f|=T)),s>_.size&&ae(),N&&s>0&&z(U()),!h)if(w.set(A,_),M){for(const[P,Q]of r)_.has(P)||A.skip_effect(Q.e);A.oncommit(n),A.ondiscard(C)}else n(A);k&&O(!0),L(d)}),p={effect:I,items:r,pending:w,outrogroups:null,fallback:f};h=!1,N&&(i=b)}function x(e){for(;e!==null&&!(e.f&ce);)e=e.next;return e}function me(e,a,l,o,c){var E;var u=a.length,i=e.items,r=x(e.effect.first),t,f=null,d=[],v=[],w,h,n,C;for(C=0;C<u;C+=1){if(w=a[C],h=c(w,C),n=i.get(h).e,e.outrogroups!==null)for(const g of e.outrogroups)g.pending.delete(n),g.done.delete(n);if(n.f&y&&J(n),n.f&T)if(n.f^=T,n===r)D(n,null,l);else{var I=f?f.next:r;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),S(e,f,n),S(e,n,I),D(n,I,l),f=n,d=[],v=[],r=x(f.next);continue}if(n!==r){if(t!==void 0&&t.has(n)){if(d.length<v.length){var p=v[0],s;f=p.prev;var k=d[0],R=d[d.length-1];for(s=0;s<d.length;s+=1)D(d[s],p,l);for(s=0;s<v.length;s+=1)t.delete(v[s]);S(e,k.prev,R.next),S(e,f,k),S(e,R,p),r=p,f=R,C-=1,d=[],v=[]}else t.delete(n),D(n,r,l),S(e,n.prev,n.next),S(e,n,f===null?e.effect.first:f.next),S(e,f,n),f=n;continue}for(d=[],v=[];r!==null&&r!==n;)(t??(t=new Set)).add(r),v.push(r),r=x(r.next);if(r===null)continue}n.f&T||d.push(n),f=n,r=x(n.next)}if(e.outrogroups!==null){for(const g of e.outrogroups)g.pending.size===0&&(B(e,V(g.done)),(E=e.outrogroups)==null||E.delete(g));e.outrogroups.size===0&&(e.outrogroups=null)}if(r!==null||t!==void 0){var _=[];if(t!==void 0)for(n of t)n.f&y||_.push(n);for(;r!==null;)!(r.f&y)&&r!==e.fallback&&_.push(r),r=x(r.next);var A=_.length;if(A>0){var M=u===0?l:null;Ee(e,_,M)}}}function Te(e,a,l,o,c,u,i,r){var t=i&oe?i&ve?q(l):te(l,!1,!1):null,f=i&se?q(c):null;return{v:t,i:f,e:Y(()=>(u(a,t??l,f??c,r),()=>{e.delete(o)}))}}function D(e,a,l){if(e.nodes)for(var o=e.nodes.start,c=e.nodes.end,u=a&&!(a.f&T)?a.nodes.start:l;o!==null;){var i=_e(o);if(u.before(o),o===c)return;o=i}}function S(e,a,l){a===null?e.effect.first=l:a.next=l,l===null?e.effect.last=a:l.prev=a}export{Ae as e,Ce as i};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{a as r}from"../chunks/RKc8iKz_.js";import{w as t}from"../chunks/CfgL3m1f.js";export{t as load_css,r as start};

View file

@ -0,0 +1 @@
import{c as A,a as h,f as q}from"../chunks/CJv7FbHz.js";import{w as j,a9 as z,p as Q,a8 as U,s as v,g as r,a3 as x,a as V,a0 as B,a4 as n,a2 as _,a5 as i,a7 as W}from"../chunks/DtGl34IE.js";import{s as G,a as H}from"../chunks/B3ZK_jYJ.js";import{d as J,a as K,s as O}from"../chunks/n1byU5WW.js";import{B as X,i as C}from"../chunks/-EcHSJia.js";import{p as Y}from"../chunks/B7kAneMc.js";import{g as f}from"../chunks/RKc8iKz_.js";function E(u,o,...d){var m=new X(u);j(()=>{const l=o()??null;m.ensure(l,l&&(s=>l(s,...d)))},z)}var Z=q('<div class="loading svelte-12qhfyh">Loading...</div>'),$=q('<span class="svelte-12qhfyh"> </span>'),aa=q('<div class="app svelte-12qhfyh"><header class="svelte-12qhfyh"><h1 class="svelte-12qhfyh">IMC Vibe</h1> <nav class="svelte-12qhfyh"><a href="/" class="svelte-12qhfyh">Dashboard</a> <a href="/domains" class="svelte-12qhfyh">Domains</a> <a href="/users" class="svelte-12qhfyh">Users</a> <a href="/aliases" class="svelte-12qhfyh">Aliases</a> <a href="/queue" class="svelte-12qhfyh">Queue</a> <a href="/logs" class="svelte-12qhfyh">Logs</a></nav> <div class="user-menu svelte-12qhfyh"><!> <a href="/auth/change-password" class="svelte-12qhfyh">Change Password</a> <button class="svelte-12qhfyh">Logout</button></div></header> <main class="svelte-12qhfyh"><!></main></div>');function na(u,o){Q(o,!0);const d=()=>G(Y,"$page",m),[m,l]=H();let s=B(null),p=B(!0);U(()=>{d().url.pathname.startsWith("/auth/")?v(p,!1):I()});async function I(){var a;const e=localStorage.getItem("token");if(!e){f("/auth/login");return}try{const t=await fetch("/api/auth/me",{headers:{Authorization:`Bearer ${e}`}});if(!t.ok){f("/auth/login");return}const c=await t.json();if(v(s,c.data,!0),((a=r(s))==null?void 0:a.role)!=="admin"){f("/auth/dashboard");return}}catch{f("/auth/login")}finally{v(p,!1)}}function L(){localStorage.removeItem("token"),v(s,null),f("/auth/login")}var b=A(),S=x(b);{var T=e=>{var a=Z();h(e,a)},D=e=>{var a=aa(),t=n(a),c=_(n(t),4),k=n(c);{var M=g=>{var y=$(),R=n(y,!0);i(y),W(()=>O(R,r(s).username||r(s).email)),h(g,y)};C(k,g=>{r(s)&&g(M)})}var N=_(k,4);i(c),i(t);var w=_(t,2),P=n(w);E(P,()=>o.children),i(w),i(a),K("click",N,L),h(e,a)},F=e=>{var a=A(),t=x(a);E(t,()=>o.children),h(e,a)};C(S,e=>{var a;r(p)?e(T):((a=r(s))==null?void 0:a.role)==="admin"?e(D,1):e(F,-1)})}h(u,b),V(),l()}J(["click"]);export{na as component};

View file

@ -0,0 +1 @@
import{a as h,f as g}from"../chunks/CJv7FbHz.js";import{i as l}from"../chunks/DFRBE_Uq.js";import{p as v,a3 as d,a7 as _,a as x,a4 as e,a5 as o,a2 as $}from"../chunks/DtGl34IE.js";import{s as p}from"../chunks/n1byU5WW.js";import{p as m}from"../chunks/CfgL3m1f.js";import{s as k}from"../chunks/RKc8iKz_.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=g("<h1> </h1> <p> </p>",1);function B(f,n){v(n,!1),l();var t=E(),r=d(t),c=e(r,!0);o(r);var a=$(r,2),u=e(a,!0);o(a),_(()=>{var s;p(c,i.status),p(u,(s=i.error)==null?void 0:s.message)}),h(f,t),x()}export{B as component};

View file

@ -0,0 +1 @@
import{a as m,f as y}from"../chunks/CJv7FbHz.js";import{p as Z,a0 as _,a1 as I,a8 as aa,s as i,a3 as N,a7 as D,a as ea,a2 as o,a4 as r,a5 as s,g as t,a6 as ta}from"../chunks/DtGl34IE.js";import{s as sa,a as oa}from"../chunks/B3ZK_jYJ.js";import{d as ra,a as w,s as F,e as la}from"../chunks/n1byU5WW.js";import{i as R}from"../chunks/-EcHSJia.js";import{e as ia,i as da}from"../chunks/ecAlPOj8.js";import{s as T,r as U}from"../chunks/e1WBZ7UL.js";import{b as q}from"../chunks/ayyTDfEa.js";import{p as na}from"../chunks/B7kAneMc.js";var va=y("<p>Loading...</p>"),ca=y("<p>No aliases configured yet.</p>"),ua=y('<tr><td class="svelte-15uyvzd"> </td><td class="svelte-15uyvzd"> </td><td class="svelte-15uyvzd"><button class="danger svelte-15uyvzd">Delete</button></td></tr>'),pa=y('<table class="svelte-15uyvzd"><thead><tr><th class="svelte-15uyvzd">Source</th><th class="svelte-15uyvzd">Destination</th><th class="svelte-15uyvzd">Actions</th></tr></thead><tbody></tbody></table>'),fa=y('<button class="modal-backdrop svelte-15uyvzd" aria-label="Close modal"></button> <div class="modal svelte-15uyvzd"><h3 class="svelte-15uyvzd">Add Alias</h3> <form class="svelte-15uyvzd"><label class="svelte-15uyvzd">Source (alias address): <input type="text" required="" class="svelte-15uyvzd"/></label> <label class="svelte-15uyvzd">Destination (forward to): <input type="text" placeholder="user@example.org" required="" class="svelte-15uyvzd"/></label> <div class="actions svelte-15uyvzd"><button type="button" class="svelte-15uyvzd">Cancel</button> <button type="submit" class="svelte-15uyvzd">Create</button></div></form></div>',1),ma=y('<div class="header svelte-15uyvzd"><a class="back svelte-15uyvzd"> </a> <h2 class="svelte-15uyvzd">Aliases</h2> <button class="svelte-15uyvzd">Add Alias</button></div> <!> <!>',1);function Aa(L,O){Z(O,!0);const B=()=>sa(na,"$page",J),[J,M]=oa();let A=_(I([])),S=_(!0),b=_(!1),p=_(I({source:"",destination:""})),l=_("");async function $(a){try{const e=await fetch(`/api/domains/${encodeURIComponent(a)}/aliases`);if(e.ok){const d=await e.json();i(A,d.data||[],!0)}}catch(e){console.error("Failed to load aliases:",e)}finally{i(S,!1)}}async function P(){try{const a=await fetch(`/api/domains/${encodeURIComponent(t(l))}/aliases`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t(p))});if(a.ok)i(b,!1),i(p,{source:"",destination:""},!0),await $(t(l));else{const e=await a.json();alert(e.error||"Failed to create alias")}}catch(a){console.error("Failed to create alias:",a)}}async function G(a){if(confirm("Delete this alias?"))try{await fetch(`/api/domains/${encodeURIComponent(t(l))}/aliases/${a}`,{method:"DELETE"}),await $(t(l))}catch(e){console.error("Failed to delete alias:",e)}}aa(()=>{const a=B().params.name;a&&(i(l,a,!0),$(a))});var j=ma(),C=N(j),z=r(C),H=r(z);s(z);var K=o(z,4);s(C);var E=o(C,2);{var Q=a=>{var e=va();m(a,e)},V=a=>{var e=ca();m(a,e)},W=a=>{var e=pa(),d=o(r(e));ia(d,21,()=>t(A),da,(g,n)=>{var v=ua(),c=r(v),h=r(c,!0);s(c);var f=o(c),k=r(f,!0);s(f);var x=o(f),u=r(x);s(x),s(v),D(()=>{F(h,t(n).source),F(k,t(n).destination)}),w("click",u,()=>G(t(n).id)),m(g,v)}),s(d),s(e),m(a,e)};R(E,a=>{t(S)?a(Q):t(A).length===0?a(V,1):a(W,-1)})}var X=o(E,2);{var Y=a=>{var e=fa(),d=N(e),g=o(d,2),n=o(r(g),2),v=r(n),c=o(r(v));U(c),s(v);var h=o(v,2),f=o(r(h));U(f),s(h);var k=o(h,2),x=r(k);ta(2),s(k),s(n),s(g),D(()=>T(c,"placeholder",`info@${t(l)??""}`)),w("click",d,()=>i(b,!1)),la("submit",n,u=>{u.preventDefault(),P()}),q(c,()=>t(p).source,u=>t(p).source=u),q(f,()=>t(p).destination,u=>t(p).destination=u),w("click",x,()=>i(b,!1)),m(a,e)};R(X,a=>{t(b)&&a(Y)})}D(()=>{T(z,"href",`/domains/${t(l)??""}`),F(H,`← Back to ${t(l)??""}`)}),w("click",K,()=>i(b,!0)),m(L,j),ea(),M()}ra(["click"]);export{Aa as component};

View file

@ -0,0 +1 @@
import{a as b,f as _}from"../chunks/CJv7FbHz.js";import{p as ot,a0 as w,a1 as M,a8 as rt,s as c,a3 as O,a7 as D,a as lt,a2 as s,a4 as r,a5 as o,g as a,a6 as it}from"../chunks/DtGl34IE.js";import{s as nt,a as dt}from"../chunks/B3ZK_jYJ.js";import{d as ct,a as $,s as q,e as vt}from"../chunks/n1byU5WW.js";import{i as Q}from"../chunks/-EcHSJia.js";import{e as ut,i as pt}from"../chunks/ecAlPOj8.js";import{s as R,r as E}from"../chunks/e1WBZ7UL.js";import{b as N}from"../chunks/ayyTDfEa.js";import{p as mt}from"../chunks/B7kAneMc.js";var ft=_("<p>Loading...</p>"),bt=_("<p>No users configured yet.</p>"),_t=_('<tr><td class="svelte-oa5z6t"> </td><td class="svelte-oa5z6t"> </td><td class="svelte-oa5z6t"> </td><td class="svelte-oa5z6t"><button class="danger svelte-oa5z6t">Delete</button></td></tr>'),ht=_('<table class="svelte-oa5z6t"><thead><tr><th class="svelte-oa5z6t">Email</th><th class="svelte-oa5z6t">Quota</th><th class="svelte-oa5z6t">Used</th><th class="svelte-oa5z6t">Actions</th></tr></thead><tbody></tbody></table>'),zt=_('<button class="modal-backdrop svelte-oa5z6t" aria-label="Close modal"></button> <div class="modal svelte-oa5z6t"><h3 class="svelte-oa5z6t">Add User</h3> <form class="svelte-oa5z6t"><label class="svelte-oa5z6t">Email: <input type="email" required="" class="svelte-oa5z6t"/></label> <label class="svelte-oa5z6t">Password: <input type="password" required="" class="svelte-oa5z6t"/></label> <label class="svelte-oa5z6t">Quota (bytes, 0 = default): <input type="number" min="0" class="svelte-oa5z6t"/></label> <div class="actions svelte-oa5z6t"><button type="button" class="svelte-oa5z6t">Cancel</button> <button type="submit" class="svelte-oa5z6t">Create</button></div></form></div>',1),gt=_('<div class="header svelte-oa5z6t"><a class="back svelte-oa5z6t"> </a> <h2 class="svelte-oa5z6t">Users</h2> <button class="svelte-oa5z6t">Add User</button></div> <!> <!>',1);function Bt(L,P){ot(P,!0);const S=()=>nt(mt,"$page",G),[G,J]=dt();let C=w(M([])),T=w(!0),h=w(!1),i=w(M({email:"",password:"",quota:0})),n=w("");async function F(t){try{const e=await fetch(`/api/domains/${encodeURIComponent(t)}/users`);if(e.ok){const v=await e.json();c(C,v.data||[],!0)}}catch(e){console.error("Failed to load users:",e)}finally{c(T,!1)}}async function K(){try{const t=await fetch(`/api/domains/${encodeURIComponent(a(n))}/users`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a(i))});if(t.ok)c(h,!1),c(i,{email:"",password:"",quota:0},!0),await F(a(n));else{const e=await t.json();alert(e.error||"Failed to create user")}}catch(t){console.error("Failed to create user:",t)}}async function H(t){if(confirm("Delete this user? Mailbox will NOT be deleted."))try{await fetch(`/api/domains/${encodeURIComponent(a(n))}/users/${t}`,{method:"DELETE"}),await F(a(n))}catch(e){console.error("Failed to delete user:",e)}}function j(t){return t===0?"Default":t<1024?t+" B":t<1024*1024?(t/1024).toFixed(1)+" KB":t<1024*1024*1024?(t/1024/1024).toFixed(1)+" MB":(t/1024/1024/1024).toFixed(2)+" GB"}rt(()=>{const t=S().params.name;t&&(c(n,t,!0),F(t))});var A=gt(),B=O(A),y=r(B),V=r(y);o(y);var W=s(y,4);o(B);var I=s(B,2);{var X=t=>{var e=ft();b(t,e)},Y=t=>{var e=bt();b(t,e)},Z=t=>{var e=ht(),v=s(r(e));ut(v,21,()=>a(C),pt,(x,d)=>{var u=_t(),p=r(u),z=r(p,!0);o(p);var m=s(p),g=r(m,!0);o(m);var f=s(m),k=r(f,!0);o(f);var U=s(f),l=r(U);o(U),o(u),D((et,st)=>{q(z,a(d).email),q(g,et),q(k,st)},[()=>j(a(d).quota),()=>j(a(d).used_quota)]),$("click",l,()=>H(a(d).id)),b(x,u)}),o(v),o(e),b(t,e)};Q(I,t=>{a(T)?t(X):a(C).length===0?t(Y,1):t(Z,-1)})}var tt=s(I,2);{var at=t=>{var e=zt(),v=O(e),x=s(v,2),d=s(r(x),2),u=r(d),p=s(r(u));E(p),o(u);var z=s(u,2),m=s(r(z));E(m),o(z);var g=s(z,2),f=s(r(g));E(f),o(g);var k=s(g,2),U=r(k);it(2),o(k),o(d),o(x),D(()=>R(p,"placeholder",`user@${a(n)??""}`)),$("click",v,()=>c(h,!1)),vt("submit",d,l=>{l.preventDefault(),K()}),N(p,()=>a(i).email,l=>a(i).email=l),N(m,()=>a(i).password,l=>a(i).password=l),N(f,()=>a(i).quota,l=>a(i).quota=l),$("click",U,()=>c(h,!1)),b(t,e)};Q(tt,t=>{a(h)&&t(at)})}D(()=>{R(y,"href",`/domains/${a(n)??""}`),q(V,`← Back to ${a(n)??""}`)}),$("click",W,()=>c(h,!0)),b(L,A),lt(),J()}ct(["click"]);export{Bt as component};

View file

@ -0,0 +1 @@
import{a as d,f as m}from"../chunks/CJv7FbHz.js";import{l as Z,c as A,aj as $,t as ee,K as ae,ak as re,al as te,p as se,a0 as g,a1 as oe,a8 as le,a3 as ie,g as s,a as ne,s as c,a2 as v,a4 as f,a5 as u,a7 as U}from"../chunks/DtGl34IE.js";import{d as ve,a as H,s as x}from"../chunks/n1byU5WW.js";import{i as ue}from"../chunks/-EcHSJia.js";import{e as fe,i as pe}from"../chunks/ecAlPOj8.js";import{r as _e}from"../chunks/e1WBZ7UL.js";import{s as ce}from"../chunks/BYrNiJ2z.js";import{b as de}from"../chunks/ayyTDfEa.js";function W(e,t,p=!1){if(e.multiple){if(t==null)return;if(!ae(t))return re();for(var o of e.options)o.selected=t.includes(h(o));return}for(o of e.options){var i=h(o);if(te(i,t)){o.selected=!0;return}}(!p||t!==void 0)&&(e.selectedIndex=-1)}function me(e){var t=new MutationObserver(()=>{W(e,e.__value)});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),ee(()=>{t.disconnect()})}function ge(e,t,p=t){var o=new WeakSet,i=!0;Z(e,"change",n=>{var _=n?"[selected]":":checked",l;if(e.multiple)l=[].map.call(e.querySelectorAll(_),h);else{var b=e.querySelector(_)??e.querySelector("option:not([disabled])");l=b&&h(b)}p(l),e.__value=l,A!==null&&o.add(A)}),$(()=>{var n=t();if(e===document.activeElement){var _=A;if(o.has(_))return}if(W(e,n,i),i&&n===void 0){var l=e.querySelector(":checked");l!==null&&(n=h(l),p(n))}e.__value=n,i=!1}),me(e)}function h(e){return"__value"in e?e.__value:e.value}var he=m("<p>Loading...</p>"),be=m('<p class="error svelte-1lsf4ps"> </p>'),ye=m("<p>No log entries found.</p>"),ke=m('<div><span class="timestamp svelte-1lsf4ps"> </span> <span class="priority svelte-1lsf4ps"> </span> <span class="message"> </span></div>'),we=m('<div class="log-container svelte-1lsf4ps"></div>'),xe=m('<div class="header svelte-1lsf4ps"><h2>Mail Logs</h2> <div class="controls svelte-1lsf4ps"><label class="svelte-1lsf4ps">Hours: <select class="svelte-1lsf4ps"><option>Last hour</option><option>Last 6 hours</option><option>Last 24 hours</option><option>Last 48 hours</option></select></label> <label class="svelte-1lsf4ps">Filter: <input type="text" placeholder="Filter logs..." class="svelte-1lsf4ps"/></label> <button class="svelte-1lsf4ps">Refresh</button></div></div> <!>',1);function Me(e,t){se(t,!0);let p=g(oe([])),o=g(!0),i=g(""),n=g(""),_=g(1);async function l(){c(o,!0),c(i,"");try{const a=await fetch(`/api/logs?hours=${s(_)}&filter=${encodeURIComponent(s(n))}`);if(a.ok){const r=await a.json();c(p,r.data||[],!0)}else c(i,"Failed to load logs")}catch(a){c(i,"Connection error"),console.error("Failed to load logs:",a)}finally{c(o,!1)}}function b(a){const r=a.toLowerCase();return r==="err"||r==="error"?"error":r==="warning"||r==="warn"?"warning":r==="info"?"info":""}le(()=>{l()});var K=xe(),L=ie(K),N=v(f(L),2),q=f(N),y=v(f(q)),F=f(y);F.value=F.__value=1;var S=v(F);S.value=S.__value=6;var C=v(S);C.value=C.__value=24;var O=v(C);O.value=O.__value=48,u(y),u(q);var j=v(q,2),E=v(f(j));_e(E),u(j);var z=v(j,2);u(N),u(L);var B=v(L,2);{var D=a=>{var r=he();d(a,r)},G=a=>{var r=be(),I=f(r,!0);u(r),U(()=>x(I,s(i))),d(a,r)},J=a=>{var r=ye();d(a,r)},Q=a=>{var r=we();fe(r,21,()=>s(p),pe,(I,k)=>{var w=ke(),M=f(w),T=f(M,!0);u(M);var R=v(M,2),V=f(R);u(R);var P=v(R,2),X=f(P,!0);u(P),u(w),U(Y=>{ce(w,1,`log-entry ${Y??""}`,"svelte-1lsf4ps"),x(T,s(k).timestamp),x(V,`[${s(k).priority??""}]`),x(X,s(k).message)},[()=>b(s(k).priority)]),d(I,w)}),u(r),d(a,r)};ue(B,a=>{s(o)?a(D):s(i)?a(G,1):s(p).length===0?a(J,2):a(Q,-1)})}H("change",y,l),ge(y,()=>s(_),a=>c(_,a)),H("keydown",E,a=>a.key==="Enter"&&l()),de(E,()=>s(n),a=>c(n,a)),H("click",z,l),d(e,K),ne()}ve(["change","keydown","click"]);export{Me as component};

View file

@ -0,0 +1 @@
import{a as i,f as d}from"../chunks/CJv7FbHz.js";import{p as Y,a0 as w,a1 as Z,a8 as $,a3 as ee,a as te,s as u,a2 as o,a4 as s,a5 as r,g as a,a7 as z}from"../chunks/DtGl34IE.js";import{d as ae,a as D,s as l}from"../chunks/n1byU5WW.js";import{i as re}from"../chunks/-EcHSJia.js";import{e as se,i as oe}from"../chunks/ecAlPOj8.js";var ce=d("<p>Loading...</p>"),le=d('<p class="error svelte-qegr5c"> </p>'),ie=d("<p>Queue is empty.</p>"),ue=d('<tr><td class="id svelte-qegr5c"> </td><td class="svelte-qegr5c"> </td><td class="svelte-qegr5c"> </td><td class="svelte-qegr5c"> </td><td class="svelte-qegr5c"> </td><td class="reason svelte-qegr5c"> </td><td class="svelte-qegr5c"><button class="svelte-qegr5c">Requeue</button> <button class="danger svelte-qegr5c">Delete</button></td></tr>'),de=d('<table class="svelte-qegr5c"><thead><tr><th class="svelte-qegr5c">ID</th><th class="svelte-qegr5c">Sender</th><th class="svelte-qegr5c">Recipients</th><th class="svelte-qegr5c">Size</th><th class="svelte-qegr5c">Time</th><th class="svelte-qegr5c">Reason</th><th class="svelte-qegr5c">Actions</th></tr></thead><tbody></tbody></table>'),ve=d('<div class="header svelte-qegr5c"><h2>Mail Queue</h2> <button class="svelte-qegr5c">Refresh</button></div> <!>',1);function pe(B,E){Y(E,!0);let h=w(Z([])),g=w(!0),v=w("");async function n(){u(g,!0),u(v,"");try{const e=await fetch("/api/queue");if(e.ok){const t=await e.json();u(h,t.data||[],!0)}else u(v,"Failed to load queue")}catch(e){u(v,"Connection error"),console.error("Failed to load queue:",e)}finally{u(g,!1)}}async function T(e){try{await fetch(`/api/queue/${e}/requeue`,{method:"POST"}),await n()}catch(t){console.error("Failed to requeue:",t)}}async function j(e){if(confirm("Delete this message from queue?"))try{await fetch(`/api/queue/${e}`,{method:"DELETE"}),await n()}catch(t){console.error("Failed to delete from queue:",t)}}function L(e){return e<1024?e+" B":e<1024*1024?(e/1024).toFixed(1)+" KB":(e/1024/1024).toFixed(1)+" MB"}$(()=>{n()});var Q=ve(),q=ee(Q),M=o(s(q),2);r(q);var y=o(q,2);{var A=e=>{var t=ce();i(e,t)},C=e=>{var t=le(),f=s(t,!0);r(t),z(()=>l(f,a(v))),i(e,t)},I=e=>{var t=ie();i(e,t)},K=e=>{var t=de(),f=o(s(t));se(f,21,()=>a(h),oe,(O,c)=>{var p=ue(),_=s(p),P=s(_,!0);r(_);var m=o(_),G=s(m,!0);r(m);var b=o(m),H=s(b,!0);r(b);var x=o(b),J=s(x,!0);r(x);var F=o(x),N=s(F,!0);r(F);var k=o(F),U=s(k,!0);r(k);var R=o(k),S=s(R),V=o(S,2);r(R),r(p),z((W,X)=>{l(P,a(c).id),l(G,a(c).sender),l(H,W),l(J,X),l(N,a(c).time),l(U,a(c).reason)},[()=>a(c).recipients.join(", "),()=>L(a(c).size)]),D("click",S,()=>T(a(c).id)),D("click",V,()=>j(a(c).id)),i(O,p)}),r(f),r(t),i(e,t)};re(y,e=>{a(g)?e(A):a(v)?e(C,1):a(h).length===0?e(I,2):e(K,-1)})}D("click",M,n),i(B,Q),te()}ae(["click"]);export{pe as component};

View file

@ -0,0 +1 @@
import{a as r,f as m}from"../chunks/CJv7FbHz.js";import{i as t}from"../chunks/DFRBE_Uq.js";import{o as i}from"../chunks/DR7h53-u.js";import{p as s,a as n}from"../chunks/DtGl34IE.js";import{g as f}from"../chunks/RKc8iKz_.js";var e=m("<p>Redirecting to domains...</p>");function l(o,a){s(a,!1),i(()=>{f("/domains")}),t();var p=e();r(o,p),n()}export{l as component};

View file

@ -0,0 +1 @@
import{a as p,f as n}from"../chunks/CJv7FbHz.js";import{o as L}from"../chunks/DR7h53-u.js";import{p as M,a0 as b,a1 as Q,a2 as o,a3 as B,a as C,s as q,g as l,a4 as s,a5 as t,a6 as i,a7 as G}from"../chunks/DtGl34IE.js";import{s as v}from"../chunks/n1byU5WW.js";import{i as H}from"../chunks/-EcHSJia.js";import{s as I}from"../chunks/BYrNiJ2z.js";var J=n("<p>Loading...</p>"),K=n('<div class="stats-grid svelte-1uha8ag"><div class="stat-card svelte-1uha8ag"><h3 class="svelte-1uha8ag"> </h3> <p class="svelte-1uha8ag">Domains</p></div> <div class="stat-card svelte-1uha8ag"><h3 class="svelte-1uha8ag"> </h3> <p class="svelte-1uha8ag">Users</p></div> <div class="stat-card svelte-1uha8ag"><h3 class="svelte-1uha8ag"> </h3> <p class="svelte-1uha8ag">Aliases</p></div> <div><h3 class="svelte-1uha8ag"> </h3> <p class="svelte-1uha8ag">Queued Emails</p></div></div>'),N=n("<h2>Dashboard</h2> <!>",1);function X(w,z){M(z,!0);let r=b(Q({totalDomains:0,totalUsers:0,totalAliases:0,queueSize:0})),g=b(!0);L(async()=>{try{const a=await fetch("/api/stats");if(a.ok){const e=await a.json();q(r,e.data,!0)}}catch(a){console.error("Failed to fetch stats:",a)}finally{q(g,!1)}});var m=N(),A=o(B(m),2);{var S=a=>{var e=J();p(a,e)},U=a=>{var e=K(),h=s(e),f=s(h),j=s(f,!0);t(f),i(2),t(h);var c=o(h,2),_=s(c),k=s(_,!0);t(_),i(2),t(c);var u=o(c,2),x=s(u),E=s(x,!0);t(x),i(2),t(u);var d=o(u,2);let y;var D=s(d),F=s(D,!0);t(D),i(2),t(d),t(e),G(()=>{v(j,l(r).totalDomains),v(k,l(r).totalUsers),v(E,l(r).totalAliases),y=I(d,1,"stat-card svelte-1uha8ag",null,y,{warning:l(r).queueSize>10}),v(F,l(r).queueSize)}),p(a,e)};H(A,a=>{l(g)?a(S):a(U,-1)})}p(w,m),C()}export{X as component};

View file

@ -0,0 +1 @@
import{a as r,f as m}from"../chunks/CJv7FbHz.js";import{i as t}from"../chunks/DFRBE_Uq.js";import{o as i}from"../chunks/DR7h53-u.js";import{p as s,a as n}from"../chunks/DtGl34IE.js";import{g as f}from"../chunks/RKc8iKz_.js";var e=m("<p>Redirecting to domains...</p>");function l(o,a){s(a,!1),i(()=>{f("/domains")}),t();var p=e();r(o,p),n()}export{l as component};

View file

@ -0,0 +1 @@
import{a as p,f as g}from"../chunks/CJv7FbHz.js";import{o as W}from"../chunks/DR7h53-u.js";import{p as X,a7 as q,g as t,a as Y,a2 as i,a4 as o,a0 as v,a5 as r,a6 as Z,s}from"../chunks/DtGl34IE.js";import{d as aa,a as ea,e as ta,s as B}from"../chunks/n1byU5WW.js";import{i as I}from"../chunks/-EcHSJia.js";import{r as L}from"../chunks/e1WBZ7UL.js";import{b as N}from"../chunks/ayyTDfEa.js";import{g as f}from"../chunks/RKc8iKz_.js";var sa=g("<span>Logged in as <strong> </strong></span>"),oa=g('<div class="error svelte-imhhoo"> </div>'),ra=g('<div class="success svelte-imhhoo">Password changed successfully!</div>'),ia=g('<div class="page-container svelte-imhhoo"><header class="header svelte-imhhoo"><div class="header-content svelte-imhhoo"><h1 class="svelte-imhhoo">Change Password</h1> <div class="user-info svelte-imhhoo"><!> <button class="logout svelte-imhhoo">Logout</button></div></div></header> <div class="content svelte-imhhoo"><!> <!> <form class="svelte-imhhoo"><label class="svelte-imhhoo">Current Password <input type="password" placeholder="Enter current password" required="" class="svelte-imhhoo"/></label> <label class="svelte-imhhoo">New Password <input type="password" placeholder="Enter new password (min 8 characters)" required="" class="svelte-imhhoo"/></label> <label class="svelte-imhhoo">Confirm New Password <input type="password" placeholder="Confirm new password" required="" class="svelte-imhhoo"/></label> <button type="submit" class="svelte-imhhoo"> </button></form> <div class="links svelte-imhhoo"><a href="/auth/dashboard" class="svelte-imhhoo">Back to Dashboard</a></div></div></div>');function pa(F,J){X(J,!0);let c=null,u=v(""),h=v(""),m=v(""),l=v(""),z=v(!1),n=v(!1);W(async()=>{const a=localStorage.getItem("token");if(!a){await f("/auth/login");return}try{const e=await fetch("/api/auth/me",{headers:{Authorization:`Bearer ${a}`}});if(!e.ok){await f("/auth/login");return}c=(await e.json()).data}catch{await f("/auth/login")}});async function M(a){if(a.preventDefault(),s(l,""),t(h)!==t(m)){s(l,"Passwords do not match");return}if(t(h).length<8){s(l,"Password must be at least 8 characters");return}s(n,!0);try{const e=await fetch("/api/auth/change-password",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${localStorage.getItem("token")}`},body:JSON.stringify({oldPassword:t(u),newPassword:t(h)})}),d=await e.json();if(!e.ok){s(l,d.error||"Failed to change password",!0);return}s(z,!0),s(u,""),s(h,""),s(m,"")}catch{s(l,"Connection error")}finally{s(n,!1)}}function G(){localStorage.removeItem("token"),f("/auth/login")}var w=ia(),b=o(w),A=o(b),D=i(o(A),2),E=o(D);{var H=a=>{var e=sa(),d=i(o(e)),V=o(d,!0);r(d),r(e),q(()=>B(V,c.email||c.username)),p(a,e)};I(E,a=>{c&&a(H)})}var K=i(E,2);r(D),r(A),r(b);var O=i(b,2),T=o(O);{var Q=a=>{var e=oa(),d=o(e,!0);r(e),q(()=>B(d,t(l))),p(a,e)};I(T,a=>{t(l)&&a(Q)})}var $=i(T,2);{var R=a=>{var e=ra();p(a,e)};I($,a=>{t(z)&&a(R)})}var _=i($,2),P=o(_),y=i(o(P));L(y),r(P);var k=i(P,2),C=i(o(k));L(C),r(k);var x=i(k,2),S=i(o(x));L(S),r(x);var j=i(x,2),U=o(j,!0);r(j),r(_),Z(2),r(O),r(w),q(()=>{y.disabled=t(n),C.disabled=t(n),S.disabled=t(n),j.disabled=t(n),B(U,t(n)?"Changing...":"Change Password")}),ea("click",K,G),ta("submit",_,M),N(y,()=>t(u),a=>s(u,a)),N(C,()=>t(h),a=>s(h,a)),N(S,()=>t(m),a=>s(m,a)),p(F,w),Y()}aa(["click"]);export{pa as component};

View file

@ -0,0 +1 @@
import{a as n,f as c}from"../chunks/CJv7FbHz.js";import{o as H}from"../chunks/DR7h53-u.js";import{p as J,a as K,a2 as r,a4 as a,s as O,a5 as s,g as T,a0 as V,a7 as g,a3 as W,a6 as X}from"../chunks/DtGl34IE.js";import{d as Y,a as Z,s as i}from"../chunks/n1byU5WW.js";import{i as h}from"../chunks/-EcHSJia.js";import{g as d}from"../chunks/RKc8iKz_.js";var $=c("<span>Logged in as <strong> </strong></span>"),aa=c("<p>Loading...</p>"),sa=c('<div class="info-row svelte-1s1ktxm"><span class="label svelte-1s1ktxm">Quota:</span> <span class="value svelte-1s1ktxm"> </span></div>'),ta=c('<div class="card svelte-1s1ktxm"><h2 class="svelte-1s1ktxm">Account Information</h2> <div class="info-row svelte-1s1ktxm"><span class="label svelte-1s1ktxm">Email:</span> <span class="value svelte-1s1ktxm"> </span></div> <div class="info-row svelte-1s1ktxm"><span class="label svelte-1s1ktxm">Username:</span> <span class="value svelte-1s1ktxm"> </span></div> <div class="info-row svelte-1s1ktxm"><span class="label svelte-1s1ktxm">Role:</span> <span class="value svelte-1s1ktxm"> </span></div> <!></div> <div class="card svelte-1s1ktxm"><h2 class="svelte-1s1ktxm">Quick Actions</h2> <div class="actions svelte-1s1ktxm"><a href="/auth/change-password" class="action-btn svelte-1s1ktxm">Change Password</a></div></div>',1),ea=c('<div class="page-container svelte-1s1ktxm"><header class="header svelte-1s1ktxm"><h1 class="svelte-1s1ktxm">User Dashboard</h1> <div class="user-info svelte-1s1ktxm"><!> <button class="logout svelte-1s1ktxm">Logout</button></div></header> <div class="content svelte-1s1ktxm"><!></div></div>');function da(U,B){J(B,!0);let t=null,_=V(!0);H(async()=>{const e=localStorage.getItem("token");if(!e){await d("/auth/login");return}try{const o=await fetch("/api/auth/me",{headers:{Authorization:`Bearer ${e}`}});if(!o.ok){await d("/auth/login");return}if(t=(await o.json()).data,(t==null?void 0:t.role)==="admin"){await d("/");return}}catch{await d("/auth/login")}finally{O(_,!1)}});function M(){localStorage.removeItem("token"),d("/auth/login")}var m=ea(),p=a(m),w=r(a(p),2),b=a(w);{var N=e=>{var o=$(),l=r(a(o)),v=a(l,!0);s(l),s(o),g(()=>i(v,t.email||t.username)),n(e,o)};h(b,e=>{t&&e(N)})}var Q=r(b,2);s(w),s(p);var A=r(p,2),S=a(A);{var j=e=>{var o=aa();n(e,o)},z=e=>{var o=ta(),l=W(o),v=r(a(l),2),L=r(a(v),2),C=a(L,!0);s(L),s(v);var u=r(v,2),q=r(a(u),2),D=a(q,!0);s(q),s(u);var f=r(u,2),y=r(a(f),2),E=a(y,!0);s(y),s(f);var F=r(f,2);{var P=x=>{var k=sa(),I=r(a(k),2),R=a(I,!0);s(I),s(k),g(G=>i(R,G),[()=>t.quota>0?(t.quota/1024/1024).toFixed(0)+" MB":"Unlimited"]),n(x,k)};h(F,x=>{t.quota!==void 0&&x(P)})}s(l),X(2),g(()=>{i(C,t.email||"N/A"),i(D,t.username||"N/A"),i(E,t.role)}),n(e,o)};h(S,e=>{T(_)?e(j):t&&e(z,1)})}s(A),s(m),Z("click",Q,M),n(U,m),K()}Y(["click"]);export{da as component};

View file

@ -0,0 +1 @@
import{a as v,f}from"../chunks/CJv7FbHz.js";import{a2 as c,a4 as i,g as s,a5 as o,a0 as d,a7 as y,a6 as k,a3 as E,s as r}from"../chunks/DtGl34IE.js";import{s as x,e as O}from"../chunks/n1byU5WW.js";import{i as w}from"../chunks/-EcHSJia.js";import{r as T}from"../chunks/e1WBZ7UL.js";import{b as D}from"../chunks/ayyTDfEa.js";import"../chunks/CfgL3m1f.js";var I=f('<div class="error svelte-15sczaz"> </div>'),J=f('<div class="success svelte-15sczaz"><p class="svelte-15sczaz">If an account exists with that email or username, a password reset link has been sent.</p> <p class="svelte-15sczaz">Please check your inbox.</p></div> <div class="links svelte-15sczaz"><a href="/auth/login" class="svelte-15sczaz">Back to Login</a></div>',1),N=f(`<p class="info svelte-15sczaz">Enter your email address or username and we'll send you a link to reset your password.</p> <form class="svelte-15sczaz"><label class="svelte-15sczaz">Email or Username <input type="text" placeholder="user@example.org or username" required="" class="svelte-15sczaz"/></label> <button type="submit" class="svelte-15sczaz"> </button></form> <div class="links svelte-15sczaz"><a href="/auth/login" class="svelte-15sczaz">Back to Login</a></div>`,1),U=f('<div class="login-container svelte-15sczaz"><div class="login-card svelte-15sczaz"><h1 class="svelte-15sczaz">Reset Password</h1> <!> <!></div></div>');function V(S){let u=d(""),l=d(""),b=d(!1),n=d(!1);async function L(e){e.preventDefault(),r(l,""),r(n,!0);try{const a=await fetch("/api/auth/forgot",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({identifier:s(u)})}),t=await a.json();if(!a.ok){r(l,t.error||"Request failed",!0);return}r(b,!0)}catch{r(l,"Connection error")}finally{r(n,!1)}}var m=U(),g=i(m),_=c(i(g),2);{var P=e=>{var a=I(),t=i(a,!0);o(a),y(()=>x(t,s(l))),v(e,a)};w(_,e=>{s(l)&&e(P)})}var R=c(_,2);{var j=e=>{var a=J();k(2),v(e,a)},q=e=>{var a=N(),t=c(E(a),2),p=i(t),z=c(i(p));T(z),o(p);var h=c(p,2),B=i(h,!0);o(h),o(t),k(2),y(()=>{z.disabled=s(n),h.disabled=s(n),x(B,s(n)?"Sending...":"Send Reset Link")}),O("submit",t,L),D(z,()=>s(u),C=>r(u,C)),v(e,a)};w(R,e=>{s(b)?e(j):e(q,-1)})}o(g),o(m),v(S,m)}export{V as component};

View file

@ -0,0 +1 @@
import{a as y,f as j}from"../chunks/CJv7FbHz.js";import{p as A,a7 as S,g as t,a as D,a2 as i,a4 as s,s as r,a0 as p,a5 as o}from"../chunks/DtGl34IE.js";import{e as F,s as x}from"../chunks/n1byU5WW.js";import{i as J}from"../chunks/-EcHSJia.js";import{r as k}from"../chunks/e1WBZ7UL.js";import{b as C}from"../chunks/ayyTDfEa.js";import{g as I}from"../chunks/RKc8iKz_.js";var L=j('<div class="error svelte-1i2smtp"> </div>'),N=j('<div class="login-container svelte-1i2smtp"><div class="login-card svelte-1i2smtp"><h1 class="svelte-1i2smtp">IMC Vibe</h1> <p class="subtitle svelte-1i2smtp">Mail Server Administration</p> <!> <form class="svelte-1i2smtp"><label class="svelte-1i2smtp">Email or Username <input type="text" placeholder="user@example.org or username" required="" autocomplete="username" class="svelte-1i2smtp"/></label> <label class="svelte-1i2smtp">Password <input type="password" placeholder="Enter password" required="" autocomplete="current-password" class="svelte-1i2smtp"/></label> <div class="forgot svelte-1i2smtp"><a href="/auth/forgot" class="svelte-1i2smtp">Forgot password?</a></div> <button type="submit" class="svelte-1i2smtp"> </button></form></div></div>');function Q(q,E){A(E,!0);let m=p(""),d=p(""),l=p(""),n=p(!1);async function M(){r(n,!0),r(l,"");try{const e=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t(m),password:t(d)})}),a=await e.json();if(!e.ok){r(l,a.error||"Login failed",!0);return}localStorage.setItem("token",a.data.token),a.data.user.role==="admin"?I("/"):I("/auth/dashboard")}catch{r(l,"Connection error")}finally{r(n,!1)}}var v=N(),g=s(v),h=i(s(g),4);{var O=e=>{var a=L(),T=s(a,!0);o(a),S(()=>x(T,t(l))),y(e,a)};J(h,e=>{t(l)&&e(O)})}var u=i(h,2),c=s(u),_=i(s(c));k(_),o(c);var f=i(c,2),w=i(s(f));k(w),o(f);var b=i(f,4),P=s(b,!0);o(b),o(u),o(g),o(v),S(()=>{b.disabled=t(n),x(P,t(n)?"Signing in...":"Sign In")}),F("submit",u,e=>{e.preventDefault(),M()}),C(_,()=>t(m),e=>r(m,e)),C(w,()=>t(d),e=>r(d,e)),y(q,v),D()}export{Q as component};

View file

@ -0,0 +1 @@
import{a as m,f}from"../chunks/CJv7FbHz.js";import{p as K,a0 as w,a1 as j,a8 as Q,a3 as R,a as V,s as c,a2 as r,a4 as s,a5 as e,g as o,a6 as W,a7 as X}from"../chunks/DtGl34IE.js";import{d as Y,a as h,e as Z,s as C}from"../chunks/n1byU5WW.js";import{i as L}from"../chunks/-EcHSJia.js";import{e as $,i as aa}from"../chunks/ecAlPOj8.js";import{r as ta,s as ea}from"../chunks/e1WBZ7UL.js";import{b as sa}from"../chunks/ayyTDfEa.js";var oa=f("<p>Loading...</p>"),ra=f("<p>No domains configured yet.</p>"),la=f('<tr><td class="svelte-83awcy"><a> </a></td><td class="svelte-83awcy"> </td><td class="svelte-83awcy"> </td><td class="svelte-83awcy"><button class="danger svelte-83awcy">Delete</button></td></tr>'),ia=f('<table class="svelte-83awcy"><thead><tr><th class="svelte-83awcy">Domain</th><th class="svelte-83awcy">Users</th><th class="svelte-83awcy">Aliases</th><th class="svelte-83awcy">Actions</th></tr></thead><tbody></tbody></table>'),ca=f('<div class="modal-backdrop svelte-83awcy"><div class="modal svelte-83awcy"><h3>Add Domain</h3> <form class="svelte-83awcy"><label class="svelte-83awcy">Domain name: <input type="text" placeholder="example.org" required="" class="svelte-83awcy"/></label> <div class="actions svelte-83awcy"><button type="button" class="svelte-83awcy">Cancel</button> <button type="submit" class="svelte-83awcy">Create</button></div></form></div></div>'),na=f('<div class="header svelte-83awcy"><h2>Domains</h2> <button class="svelte-83awcy">Add Domain</button></div> <!> <!>',1);function ha(N,O){K(O,!0);let _=w(j([])),A=w(!0),p=w(!1),b=w(j({name:""}));async function g(){try{const a=await fetch("/api/domains");if(a.ok){const t=await a.json();c(_,t.data||[],!0)}}catch(a){console.error("Failed to load domains:",a)}finally{c(A,!1)}}async function P(){try{(await fetch("/api/domains",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o(b))})).ok&&(c(p,!1),c(b,{name:""},!0),await g())}catch(a){console.error("Failed to create domain:",a)}}async function S(a){if(confirm("Delete this domain? Users and aliases will be deleted."))try{await fetch(`/api/domains/${a}`,{method:"DELETE"}),await g()}catch(t){console.error("Failed to delete domain:",t)}}Q(()=>{g()});var E=na(),D=R(E),U=r(s(D),2);e(D);var F=r(D,2);{var q=a=>{var t=oa();m(a,t)},J=a=>{var t=ra();m(a,t)},M=a=>{var t=ia(),n=r(s(t));$(n,21,()=>o(_),aa,(u,l)=>{var d=la(),v=s(d),y=s(v),i=s(y,!0);e(y),e(v);var k=r(v),G=s(k,!0);e(k);var x=r(k),H=s(x,!0);e(x);var T=r(x),I=s(T);e(T),e(d),X(()=>{ea(y,"href",`/domains/${o(l).name??""}/users`),C(i,o(l).name),C(G,o(l).userCount),C(H,o(l).aliasCount)}),h("click",I,()=>S(o(l).id)),m(u,d)}),e(n),e(t),m(a,t)};L(F,a=>{o(A)?a(q):o(_).length===0?a(J,1):a(M,-1)})}var z=r(F,2);{var B=a=>{var t=ca(),n=s(t),u=r(s(n),2),l=s(u),d=r(s(l));ta(d),e(l);var v=r(l,2),y=s(v);W(2),e(v),e(u),e(n),e(t),h("click",t,()=>c(p,!1)),h("click",n,i=>i.stopPropagation()),Z("submit",u,i=>{i.preventDefault(),P()}),sa(d,()=>o(b).name,i=>o(b).name=i),h("click",y,()=>c(p,!1)),m(a,t)};L(z,a=>{o(p)&&a(B)})}h("click",U,()=>c(p,!0)),m(N,E),V()}Y(["click"]);export{ha as component};

View file

@ -0,0 +1 @@
import{c as J,a as i,f as c}from"../chunks/CJv7FbHz.js";import{p as K,a8 as M,a3 as D,a as N,s as m,a0 as d,g as e,a4 as t,a5 as r,a7 as y,a2 as _,a6 as C}from"../chunks/DtGl34IE.js";import{s as O,a as P}from"../chunks/B3ZK_jYJ.js";import{s as p}from"../chunks/n1byU5WW.js";import{i as Q}from"../chunks/-EcHSJia.js";import{s as $}from"../chunks/e1WBZ7UL.js";import{p as S}from"../chunks/B7kAneMc.js";var T=c("<p>Loading...</p>"),V=c('<p class="error svelte-lb8x40"> </p>'),W=c('<div class="header svelte-lb8x40"><a href="/domains" class="back svelte-lb8x40">← Back to Domains</a> <h2> </h2></div> <div class="cards-grid svelte-lb8x40"><a class="card svelte-lb8x40"><h3 class="svelte-lb8x40"> </h3> <p class="svelte-lb8x40">Users</p></a> <a class="card svelte-lb8x40"><h3 class="svelte-lb8x40"> </h3> <p class="svelte-lb8x40">Aliases</p></a></div>',1),X=c("<p>Domain not found</p>");function oa(w,U){K(U,!0);const j=()=>O(S,"$page",A),[A,B]=P();let o=d(null),u=d(!0),n=d("");async function F(a){try{const s=await fetch(`/api/domains/${encodeURIComponent(a)}`);if(s.ok){const l=await s.json();m(o,l.data,!0)}else m(n,"Domain not found")}catch{m(n,"Failed to load domain")}finally{m(u,!1)}}M(()=>{const a=j().params.name;a&&F(a)});var h=J(),I=D(h);{var L=a=>{var s=T();i(a,s)},R=a=>{var s=V(),l=t(s,!0);r(s),y(()=>p(l,e(n))),i(a,s)},q=a=>{var s=W(),l=D(s),x=_(t(l),2),E=t(x,!0);r(x),r(l);var b=_(l,2),v=t(b),g=t(v),G=t(g,!0);r(g),C(2),r(v);var f=_(v,2),k=t(f),H=t(k,!0);r(k),C(2),r(f),r(b),y(()=>{p(E,e(o).name),$(v,"href",`/domains/${e(o).name??""}/users`),p(G,e(o).userCount),$(f,"href",`/domains/${e(o).name??""}/aliases`),p(H,e(o).aliasCount)}),i(a,s)},z=a=>{var s=X();i(a,s)};Q(I,a=>{e(u)?a(L):e(n)?a(R,1):e(o)?a(q,2):a(z,-1)})}i(w,h),N(),B()}export{oa as component};

View file

@ -0,0 +1 @@
{"version":"1774129207705"}

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#2c3e50"/>
<text x="50" y="65" font-size="50" text-anchor="middle" fill="white">V</text>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View file

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/_app/immutable/entry/start.tprRzko2.js" rel="modulepreload">
<link href="/_app/immutable/chunks/RKc8iKz_.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DtGl34IE.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BX-b5t2l.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CfgL3m1f.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DR7h53-u.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BI_Sozkn.js" rel="modulepreload">
<link href="/_app/immutable/chunks/n1byU5WW.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CJv7FbHz.js" rel="modulepreload">
<link href="/_app/immutable/chunks/-EcHSJia.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B3ZK_jYJ.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_63i6zh = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.tprRzko2.js"),
import("/_app/immutable/entry/app.BI_Sozkn.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View file

@ -0,0 +1,19 @@
package main
import (
"embed"
"io/fs"
"net/http"
)
//go:embed all:embed
//go:embed all:embed/_app
var Files embed.FS
func FrontendFileSystem() http.FileSystem {
return http.FS(Files)
}
func FrontendFS() fs.FS {
return Files
}

180
backend/cmd/server/main.go Normal file
View file

@ -0,0 +1,180 @@
package main
import (
"log"
"net/http"
"os"
"strings"
"github.com/imc-vibe/backend/internal/api"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/config"
"github.com/imc-vibe/backend/internal/db"
)
func main() {
cfg := config.Load()
database, err := db.Connect(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
if err := database.InitSchema(); err != nil {
log.Printf("Warning: Could not initialize schema: %v", err)
}
if cfg.AdminUser != "" && cfg.AdminPassword != "" {
hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil {
log.Printf("Warning: Failed to hash admin password: %v", err)
} else {
if err := database.EnsureAdminUser(cfg.AdminUser, hash); err != nil {
log.Printf("Warning: Failed to create admin user: %v", err)
} else {
log.Printf("Admin user '%s' created or already exists", cfg.AdminUser)
}
}
}
useEmbedded := os.Getenv("USE_EMBEDDED") != "false"
port := cfg.Port
frontendFS := FrontendFileSystem()
router := http.NewServeMux()
router.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
apiRouter := api.New(database, cfg)
router.Handle("/api/", apiRouter.Handler())
if useEmbedded {
router.HandleFunc("/favicon.png", serveStaticFile(frontendFS, "embed/favicon.png"))
router.Handle("/_app/", http.StripPrefix("/_app/", serveStaticPrefixed(frontendFS, "embed/_app/")))
router.HandleFunc("/", serveSPA(frontendFS))
log.Println("Using embedded frontend")
} else {
router.Handle("/", http.FileServer(http.Dir("../frontend/build")))
log.Println("Using filesystem frontend")
}
log.Printf("Server starting on http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, router))
}
func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := prefix + r.URL.Path
log.Printf("Static file requested: /_app/%s -> %s", r.URL.Path, path)
file, err := fsys.Open(path)
if err != nil {
log.Printf("Error opening file %s: %v", path, err)
http.NotFound(w, r)
return
}
defer file.Close()
fi, err := file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
if fi.IsDir() {
http.NotFound(w, r)
return
}
contentType := mimeType(path)
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, fi.Name(), fi.ModTime(), file)
}
}
func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("Static file requested: /%s", filename)
file, err := fsys.Open(filename)
if err != nil {
log.Printf("Error opening file %s: %v", filename, err)
http.NotFound(w, r)
return
}
defer file.Close()
fi, err := file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
contentType := mimeType(filename)
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, fi.Name(), fi.ModTime(), file)
}
}
func serveSPA(fsys http.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "/api/") {
http.NotFound(w, r)
return
}
if path == "/" || path == "" {
path = "embed/index.html"
} else {
path = "embed/index.html"
}
file, err := fsys.Open(path)
if err != nil {
log.Printf("Error opening %s: %v", path, err)
http.Error(w, "Not found", http.StatusNotFound)
return
}
defer file.Close()
fi, _ := file.Stat()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, fi.Name(), fi.ModTime(), file)
}
}
func mimeType(path string) string {
switch {
case strings.HasSuffix(path, ".html"):
return "text/html; charset=utf-8"
case strings.HasSuffix(path, ".css"):
return "text/css"
case strings.HasSuffix(path, ".js"):
return "application/javascript"
case strings.HasSuffix(path, ".json"):
return "application/json"
case strings.HasSuffix(path, ".png"):
return "image/png"
case strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(path, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(path, ".ico"):
return "image/x-icon"
case strings.HasSuffix(path, ".woff"):
return "font/woff"
case strings.HasSuffix(path, ".woff2"):
return "font/woff2"
case strings.HasSuffix(path, ".ttf"):
return "font/ttf"
case strings.HasSuffix(path, ".eot"):
return "application/vnd.ms-fontobject"
default:
return "text/plain"
}
}

19
backend/go.mod Normal file
View file

@ -0,0 +1,19 @@
module github.com/imc-vibe/backend
go 1.25.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.49.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
golang.org/x/text v0.35.0 // indirect
)

20
backend/go.sum Normal file
View file

@ -0,0 +1,20 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/imc-vibe/backend/internal/db"
)
type AliasHandler struct {
db *db.DB
}
func NewAliasHandler(database *db.DB) *AliasHandler {
return &AliasHandler{db: database}
}
type CreateAliasRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
}
func (h *AliasHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
aliases, err := h.db.GetAliasesByDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, aliases)
}
func (h *AliasHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req CreateAliasRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Source == "" || req.Destination == "" {
Error(w, http.StatusBadRequest, "source and destination required")
return
}
alias, err := h.db.CreateAliasInDomain(req.Source, req.Destination, domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create alias")
return
}
Created(w, alias)
}
func (h *AliasHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
pathParts := strings.Split(r.URL.Path, "/")
var domainName, idStr string
for i, part := range pathParts {
if part == "domains" && i+1 < len(pathParts) {
domainName = pathParts[i+1]
}
if part == "aliases" && i+1 < len(pathParts) {
idStr = pathParts[i+1]
}
}
if domainName == "" || idStr == "" {
Error(w, http.StatusBadRequest, "invalid path")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid alias id")
return
}
if err := h.db.DeleteAlias(uint(id)); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete alias")
return
}
NoContent(w)
}

View file

@ -0,0 +1,269 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/db"
)
const MaxLoginAttempts = 5
type AuthHandler struct {
db *db.DB
jwtManager *auth.JWTManager
}
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager) *AuthHandler {
return &AuthHandler{
db: database,
jwtManager: jwtManager,
}
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
type ForgotPasswordRequest struct {
Identifier string `json:"identifier"`
}
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Domains []string `json:"domains"`
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
Error(w, http.StatusBadRequest, "username and password required")
return
}
ip := getClientIP(r)
if isLockedOut(ip, req.Username, h.db) {
Error(w, http.StatusTooManyRequests, "too many failed attempts, try again later")
return
}
user, err := h.db.GetImcUserByUsername(req.Username)
if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
recordFailedAttempt(req.Username, ip, h.db)
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
clearFailedAttempts(req.Username, ip, h.db)
domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin")
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
token, err := h.jwtManager.GenerateToken(user.ID, user.Username, user.Role, 24*time.Hour)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to generate token")
return
}
Success(w, map[string]interface{}{
"token": token,
"user": UserResponse{
ID: user.ID,
Username: user.Username,
Role: user.Role,
Domains: domainNames,
},
})
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := h.db.GetImcUserByID(authCtx.UserID)
if err != nil || user == nil {
Error(w, http.StatusNotFound, "user not found")
return
}
domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin")
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
Success(w, UserResponse{
ID: user.ID,
Username: user.Username,
Role: user.Role,
Domains: domainNames,
})
}
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
req.Identifier = strings.TrimSpace(strings.ToLower(req.Identifier))
if req.Identifier == "" {
Error(w, http.StatusBadRequest, "identifier required")
return
}
_, err := h.db.GetImcUserByUsername(req.Identifier)
if err == nil {
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})
return
}
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})
}
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
if req.OldPassword == "" || req.NewPassword == "" {
Error(w, http.StatusBadRequest, "old and new password required")
return
}
if len(req.NewPassword) < 8 {
Error(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
user, err := h.db.GetImcUserByID(authCtx.UserID)
if err != nil || user == nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !auth.CheckPassword(req.OldPassword, user.PasswordHash) {
Error(w, http.StatusUnauthorized, "current password is incorrect")
return
}
newHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to hash password")
return
}
err = h.db.UpdateImcUserPassword(user.ID, newHash)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to update password")
return
}
Success(w, map[string]string{"message": "password updated successfully"})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
Success(w, map[string]string{"message": "logged out"})
}
func isLockedOut(ip, identifier string, database *db.DB) bool {
var count int64
cutoff := time.Now().Add(-15 * time.Minute)
database.Model(&db.ImcLoginAttempt{}).
Where("ip_address = ? AND attempted_at > ? AND successful = false", ip, cutoff).
Count(&count)
if count >= MaxLoginAttempts {
return true
}
database.Model(&db.ImcLoginAttempt{}).
Where("email = ? AND attempted_at > ? AND successful = false", identifier, cutoff).
Count(&count)
return count >= MaxLoginAttempts
}
func recordFailedAttempt(identifier, ip string, database *db.DB) {
database.Create(&db.ImcLoginAttempt{
Email: identifier,
IPAddress: ip,
Successful: false,
})
}
func clearFailedAttempts(identifier, ip string, database *db.DB) {
database.Model(&db.ImcLoginAttempt{}).
Where("email = ? OR ip_address = ?", identifier, ip).
Update("successful", true)
}
func getClientIP(r *http.Request) string {
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
return strings.Split(forwarded, ",")[0]
}
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}

View file

@ -0,0 +1,316 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/db"
)
type DomainHandler struct {
db *db.DB
}
func NewDomainHandler(database *db.DB) *DomainHandler {
return &DomainHandler{db: database}
}
type CreateDomainRequest struct {
Name string `json:"name"`
}
type DomainPermissions struct {
DomainID uint `json:"domainId"`
DomainName string `json:"domainName"`
UserID uint `json:"userId"`
CanManage bool `json:"canManage"`
}
func (h *DomainHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
isAdmin := authCtx.IsAdmin()
domains, err := h.db.GetUserAccessibleDomains(authCtx.UserID, isAdmin)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
domainStats := make([]db.DomainStats, len(domains))
for i, d := range domains {
var userCount, aliasCount int64
h.db.Model(&db.User{}).Where("domain_id = ?", d.ID).Count(&userCount)
h.db.Model(&db.Alias{}).Where("domain_id = ?", d.ID).Count(&aliasCount)
domainStats[i] = db.DomainStats{
ID: d.ID,
Name: d.Name,
UserCount: userCount,
AliasCount: aliasCount,
}
}
Success(w, domainStats)
}
func (h *DomainHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
Success(w, domain)
}
func (h *DomainHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
var req CreateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
existing, err := h.db.GetDomainByName(req.Name)
if err == nil && existing != nil {
Error(w, http.StatusConflict, "domain already exists")
return
}
domain, err := h.db.CreateDomain(req.Name)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create domain")
return
}
Created(w, domain)
}
func (h *DomainHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
if err := h.db.DeleteDomain(domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete domain")
return
}
NoContent(w)
}
func (h *DomainHandler) GetPermissions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
if !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
users, err := h.db.GetUsersForDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
permissions := make([]DomainPermissions, len(users))
for i, u := range users {
permissions[i] = DomainPermissions{
DomainID: domain.ID,
DomainName: domain.Name,
UserID: u.ID,
CanManage: true,
}
}
Success(w, permissions)
}
func (h *DomainHandler) AddPermission(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req struct {
UserID uint `json:"userId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.AddUserToDomain(req.UserID, domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to add user to domain")
return
}
Success(w, map[string]string{"message": "user added to domain"})
}
func (h *DomainHandler) RemovePermission(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
userID := extractIDFromPath(r.URL.Path)
if err := h.db.RemoveUserFromDomain(userID, domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to remove user from domain")
return
}
NoContent(w)
}
func extractDomainName(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
if len(parts) >= 2 && parts[1] != "" {
return parts[1]
}
return ""
}
func extractIDFromPath(path string) uint {
parts := strings.Split(path, "/")
for i := len(parts) - 1; i >= 0; i-- {
if idStr := parts[i]; idStr != "" {
var id uint
for _, c := range idStr {
if c >= '0' && c <= '9' {
id = id*10 + uint(c-'0')
} else {
break
}
}
if id > 0 {
return id
}
}
}
return 0
}

View file

@ -0,0 +1,38 @@
package handlers
import (
"net/http"
"strconv"
"github.com/imc-vibe/backend/internal/mail"
)
type LogsHandler struct{}
func NewLogsHandler() *LogsHandler {
return &LogsHandler{}
}
func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
hours := 1
if h := r.URL.Query().Get("hours"); h != "" {
if n, err := strconv.Atoi(h); err == nil && n > 0 {
hours = n
}
}
filter := r.URL.Query().Get("filter")
entries, err := mail.GetLogs(hours, filter)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get logs")
return
}
Success(w, entries)
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"context"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/auth"
)
type contextKey string
const AuthContextKey contextKey = "auth"
func AuthMiddleware(jwtManager *auth.JWTManager, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"authorization header required"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
http.Error(w, `{"error":"invalid authorization header format"}`, http.StatusUnauthorized)
return
}
claims, err := jwtManager.ValidateToken(parts[1])
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
authCtx := &auth.Context{
UserID: claims.UserID,
Username: claims.Username,
Role: auth.Role(claims.Role),
}
ctx := context.WithValue(r.Context(), AuthContextKey, authCtx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetAuthContext(r *http.Request) *auth.Context {
if ctx := r.Context().Value(AuthContextKey); ctx != nil {
if authCtx, ok := ctx.(*auth.Context); ok {
return authCtx
}
}
return nil
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func extractID(path string) string {
parts := strings.Split(path, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}

View file

@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"github.com/imc-vibe/backend/internal/mail"
)
type QueueHandler struct{}
func NewQueueHandler() *QueueHandler {
return &QueueHandler{}
}
func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
entries, err := mail.GetQueue()
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get queue")
return
}
Success(w, entries)
}
func (h *QueueHandler) Requeue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := extractID(r.URL.Path)
if err := mail.RequeueMail(id); err != nil {
Error(w, http.StatusInternalServerError, "failed to requeue mail")
return
}
Success(w, map[string]string{"message": "mail requeued"})
}
func (h *QueueHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := extractID(r.URL.Path)
if err := mail.DeleteFromQueue(id); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete from queue")
return
}
NoContent(w)
}

View file

@ -0,0 +1,41 @@
package handlers
import (
"encoding/json"
"net/http"
)
type Response struct {
Data interface{} `json:"data"`
Error string `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Meta struct {
Total int `json:"total,omitempty"`
Page int `json:"page,omitempty"`
}
func JSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(Response{Data: data})
}
func Error(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(Response{Error: message})
}
func Success(w http.ResponseWriter, data interface{}) {
JSON(w, http.StatusOK, data)
}
func Created(w http.ResponseWriter, data interface{}) {
JSON(w, http.StatusCreated, data)
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"github.com/imc-vibe/backend/internal/db"
)
type StatsHandler struct {
db *db.DB
}
func NewStatsHandler(database *db.DB) *StatsHandler {
return &StatsHandler{db: database}
}
type Stats struct {
TotalDomains int `json:"totalDomains"`
TotalUsers int `json:"totalUsers"`
TotalAliases int `json:"totalAliases"`
QueueSize int `json:"queueSize"`
}
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domains, err := h.db.GetAllDomains()
if err != nil {
domains = []db.DomainStats{}
}
users, err := h.db.GetAllUsers()
if err != nil {
users = []db.User{}
}
aliases, err := h.db.GetAllAliases()
if err != nil {
aliases = []db.AliasWithDomain{}
}
stats := Stats{
TotalDomains: len(domains),
TotalUsers: len(users),
TotalAliases: len(aliases),
QueueSize: 0,
}
Success(w, stats)
}

View file

@ -0,0 +1,351 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/db"
)
type UserHandler struct {
db *db.DB
}
func NewUserHandler(database *db.DB) *UserHandler {
return &UserHandler{db: database}
}
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Quota int64 `json:"quota"`
}
type UpdateUserRequest struct {
Password string `json:"password,omitempty"`
Quota int64 `json:"quota,omitempty"`
}
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
users, err := h.db.GetUsersByDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, users)
}
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
user, err := h.db.GetUserByID(uint(id))
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
Success(w, user)
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
Error(w, http.StatusBadRequest, "email and password required")
return
}
existing, _ := h.db.GetUserByEmail(req.Email)
if existing != nil {
Error(w, http.StatusConflict, "user already exists")
return
}
passwordHash := "{BLF-CRYPT}" + req.Password
user, err := h.db.CreateUserInDomain(req.Email, passwordHash, req.Quota, domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create user")
return
}
Created(w, user)
}
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
user, err := h.db.GetUserByID(uint(id))
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Password != "" {
passwordHash := "{BLF-CRYPT}" + req.Password
if err := h.db.UpdateUserPassword(user.ID, passwordHash); err != nil {
Error(w, http.StatusInternalServerError, "failed to update password")
return
}
}
if req.Quota >= 0 {
if err := h.db.UpdateUserQuota(user.ID, req.Quota); err != nil {
Error(w, http.StatusInternalServerError, "failed to update quota")
return
}
}
Success(w, map[string]string{"message": "user updated"})
}
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
if err := h.db.DeleteUser(uint(id)); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete user")
return
}
NoContent(w)
}
func (h *UserHandler) ListAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
users, err := h.db.GetAllUsers()
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, users)
}
func extractDomainNameFromPath(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
if len(parts) >= 2 && parts[0] == "domains" && parts[1] != "" {
if idx := strings.Index(parts[1], "/"); idx > 0 {
return parts[1][:idx]
}
return parts[1]
}
return ""
}
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetAuthContext(r) == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func checkDomainAccess(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
next.ServeHTTP(w, r)
return
}
canAccess, _ := database.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
http.Error(w, `{"error":"access denied"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
var _ = auth.RoleAdmin

View file

@ -0,0 +1,247 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/api/handlers"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/config"
"github.com/imc-vibe/backend/internal/db"
)
type Router struct {
authHandler *handlers.AuthHandler
domainHandler *handlers.DomainHandler
userHandler *handlers.UserHandler
aliasHandler *handlers.AliasHandler
statsHandler *handlers.StatsHandler
queueHandler *handlers.QueueHandler
logsHandler *handlers.LogsHandler
jwtManager *auth.JWTManager
db *db.DB
}
func New(database *db.DB, cfg *config.Config) *Router {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
return &Router{
authHandler: handlers.NewAuthHandler(database, jwtManager),
domainHandler: handlers.NewDomainHandler(database),
userHandler: handlers.NewUserHandler(database),
aliasHandler: handlers.NewAliasHandler(database),
statsHandler: handlers.NewStatsHandler(database),
queueHandler: handlers.NewQueueHandler(),
logsHandler: handlers.NewLogsHandler(),
jwtManager: jwtManager,
db: database,
}
}
func (r *Router) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
// Only handle /api/* routes
if !strings.HasPrefix(path, "/api/") {
http.NotFound(w, req)
return
}
// Public routes - no auth required
switch path {
case "/api/auth/login":
r.authHandler.Login(w, req)
return
case "/api/auth/forgot":
r.authHandler.ForgotPassword(w, req)
return
case "/api/auth/logout":
r.authHandler.Logout(w, req)
return
}
// Protected routes - auth required
req = r.validateAuth(req)
if req == nil {
http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized)
return
}
switch {
case path == "/api/auth/me":
r.authHandler.Me(w, req)
case path == "/api/auth/change-password":
r.authHandler.ChangePassword(w, req)
case path == "/api/stats":
r.statsHandler.Get(w, req)
case strings.HasPrefix(path, "/api/domains/"):
if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/users") {
r.handleDomainUsers(w, req)
} else if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/aliases") {
r.handleDomainAliases(w, req)
} else {
r.handleDomains(w, req)
}
case strings.HasPrefix(path, "/api/domains"):
r.handleDomains(w, req)
case strings.HasPrefix(path, "/api/queue"):
r.handleQueue(w, req)
case path == "/api/logs":
r.logsHandler.List(w, req)
default:
http.NotFound(w, req)
}
})
}
func (r *Router) validateAuth(req *http.Request) *http.Request {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return nil
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return nil
}
claims, err := r.jwtManager.ValidateToken(parts[1])
if err != nil {
return nil
}
authCtx := &auth.Context{
UserID: claims.UserID,
Username: claims.Username,
Role: auth.Role(claims.Role),
}
ctx := context.WithValue(req.Context(), handlers.AuthContextKey, authCtx)
return req.WithContext(ctx)
}
func (r *Router) handleDomains(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if len(req.URL.Path) > len("/api/domains/") {
r.domainHandler.Get(w, req)
} else {
r.domainHandler.List(w, req)
}
case http.MethodPost:
r.domainHandler.Create(w, req)
case http.MethodDelete:
r.domainHandler.Delete(w, req)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleDomainUsers(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/")
if len(parts) < 2 || parts[1] != "users" {
http.NotFound(w, req)
return
}
domainName := parts[0]
var idStr string
if len(parts) >= 3 {
idStr = parts[2]
}
switch req.Method {
case http.MethodGet:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Get(w, req)
} else {
req.URL.Path = "/api/domains/" + domainName + "/users"
r.userHandler.List(w, req)
}
case http.MethodPost:
req.URL.Path = "/api/domains/" + domainName + "/users"
r.userHandler.Create(w, req)
case http.MethodPut:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Update(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
case http.MethodDelete:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleDomainAliases(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/")
if len(parts) < 2 || parts[1] != "aliases" {
http.NotFound(w, req)
return
}
domainName := parts[0]
var idStr string
if len(parts) >= 3 {
idStr = parts[2]
}
switch req.Method {
case http.MethodGet:
req.URL.Path = "/api/domains/" + domainName + "/aliases"
r.aliasHandler.List(w, req)
case http.MethodPost:
req.URL.Path = "/api/domains/" + domainName + "/aliases"
r.aliasHandler.Create(w, req)
case http.MethodDelete:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/aliases/" + idStr
r.aliasHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleQueue(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
id := ""
if len(path) > len("/api/queue/") {
id = path[len("/api/queue/"):]
}
switch req.Method {
case http.MethodGet:
r.queueHandler.List(w, req)
case http.MethodPost:
if id != "" {
req.URL.Path = "/api/queue/" + id
r.queueHandler.Requeue(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
case http.MethodDelete:
if id != "" {
req.URL.Path = "/api/queue/" + id
r.queueHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}

View file

@ -0,0 +1,85 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("token has expired")
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secretKey []byte
issuer string
}
func NewJWTManager(secretKey, issuer string) *JWTManager {
return &JWTManager{
secretKey: []byte(secretKey),
issuer: issuer,
}
}
func (j *JWTManager) GenerateToken(userID uint, username, role string, duration time.Duration) (string, error) {
claims := &Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: j.issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(j.secretKey)
}
func (j *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return j.secretKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View file

@ -0,0 +1,18 @@
package auth
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type Context struct {
UserID uint
Username string
Role Role
}
func (c *Context) IsAdmin() bool {
return c.Role == RoleAdmin
}

View file

@ -0,0 +1,56 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
Port string
UseEmbedded bool
JWTSecret string
AdminUser string
AdminPassword string
MailDataDir string
PostqueuePath string
PostsuperPath string
DovecotQuotaCmd string
JournalctlPath string
RspamdAPI string
}
func Load() *Config {
godotenv.Load("backend/.env")
return &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "mailadmin"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "mailserver"),
Port: getEnv("PORT", "8080"),
UseEmbedded: getEnv("USE_EMBEDDED", "true") == "true",
JWTSecret: getEnv("JWT_SECRET", "change-this-secret"),
AdminUser: getEnv("ADMIN_USER", ""),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"),
PostqueuePath: getEnv("POSTQUEUE_PATH", "/usr/sbin/postqueue"),
PostsuperPath: getEnv("POSTSUPER_PATH", "/usr/sbin/postsuper"),
DovecotQuotaCmd: getEnv("DOVECOT_QUOTA_CMD", "/usr/bin/doveadm"),
JournalctlPath: getEnv("JOURNALCTL_PATH", "/usr/bin/journalctl"),
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View file

@ -0,0 +1,70 @@
package db
func (d *DB) GetAllAliases() ([]AliasWithDomain, error) {
var aliases []Alias
if err := d.Preload("Domain").Find(&aliases).Error; err != nil {
return nil, err
}
result := make([]AliasWithDomain, len(aliases))
for i, a := range aliases {
result[i] = AliasWithDomain{
Alias: a,
DomainName: "",
}
if a.Domain != nil {
result[i].DomainName = a.Domain.Name
}
}
if result == nil {
result = []AliasWithDomain{}
}
return result, nil
}
func (d *DB) GetAliasesByDomain(domainID uint) ([]Alias, error) {
var aliases []Alias
if err := d.Where("domain_id = ?", domainID).Find(&aliases).Error; err != nil {
return nil, err
}
if aliases == nil {
aliases = []Alias{}
}
return aliases, nil
}
func (d *DB) GetAliasByID(id uint) (*Alias, error) {
var alias Alias
if err := d.Preload("Domain").First(&alias, id).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) CreateAlias(source, destination string) (*Alias, error) {
alias := Alias{
Source: source,
Destination: destination,
}
if err := d.Create(&alias).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) CreateAliasInDomain(source, destination string, domainID uint) (*Alias, error) {
alias := Alias{
DomainID: domainID,
Source: source,
Destination: destination,
}
if err := d.Create(&alias).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) DeleteAlias(id uint) error {
return d.Delete(&Alias{}, id).Error
}

164
backend/internal/db/db.go Normal file
View file

@ -0,0 +1,164 @@
package db
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/imc-vibe/backend/internal/config"
)
type DB struct {
*gorm.DB
}
type Domain struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"`
CreatedAt time.Time `json:"createdAt"`
Users []User `gorm:"foreignKey:DomainID" json:"users,omitempty"`
Aliases []Alias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"`
}
func (Domain) TableName() string { return "virtual_domains" }
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"`
Password string `gorm:"size:150;not null" json:"-"`
Quota int64 `gorm:"default:0" json:"quota"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
}
func (User) TableName() string { return "virtual_users" }
type Alias struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Source string `gorm:"size:100;not null" json:"source"`
Destination string `gorm:"size:100;not null" json:"destination"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
}
func (Alias) TableName() string { return "virtual_aliases" }
type ImcUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:100;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"type:enum('admin','user');default:'user'" json:"role"`
CreatedAt time.Time `json:"createdAt"`
Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"`
}
func (ImcUser) TableName() string { return "imc_users" }
type ImcUserDomain struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"userId"`
DomainID uint `gorm:"not null" json:"domainId"`
CreatedAt time.Time `json:"createdAt"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
}
func (ImcUserDomain) TableName() string { return "imc_users2domains" }
type ImcLoginAttempt struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:100;not null" json:"email"`
IPAddress string `gorm:"size:45;not null" json:"ipAddress"`
AttemptedAt time.Time `json:"attemptedAt"`
Successful bool `gorm:"default:false" json:"successful"`
}
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
type DomainStats struct {
ID uint `json:"id"`
Name string `json:"name"`
UserCount int64 `json:"userCount"`
AliasCount int64 `json:"aliasCount"`
}
type AliasWithDomain struct {
Alias
DomainName string `json:"domainName"`
}
type UserWithDomain struct {
User
DomainName string `json:"domainName"`
}
func Connect(cfg *config.Config) (*DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return &DB{db}, nil
}
func (d *DB) InitSchema() error {
imcUsersSQL := `
CREATE TABLE IF NOT EXISTS imc_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','user') DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(imcUsersSQL).Error; err != nil {
return err
}
loginAttemptsSQL := `
CREATE TABLE IF NOT EXISTS imc_login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
successful BOOLEAN DEFAULT FALSE,
INDEX idx_email_time (email, attempted_at),
INDEX idx_ip_time (ip_address, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(loginAttemptsSQL).Error; err != nil {
return err
}
users2DomainsSQL := `
CREATE TABLE IF NOT EXISTS imc_users2domains (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
domain_id INT UNSIGNED NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_domain (user_id, domain_id),
INDEX idx_user_id (user_id),
INDEX idx_domain_id (domain_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
return d.Exec(users2DomainsSQL).Error
}

View file

@ -0,0 +1,68 @@
package db
func (d *DB) GetAllDomains() ([]DomainStats, error) {
var domains []Domain
if err := d.Find(&domains).Error; err != nil {
return nil, err
}
stats := make([]DomainStats, len(domains))
for i, dom := range domains {
var userCount, aliasCount int64
d.Model(&User{}).Where("domain_id = ?", dom.ID).Count(&userCount)
d.Model(&Alias{}).Where("domain_id = ?", dom.ID).Count(&aliasCount)
stats[i] = DomainStats{
ID: dom.ID,
Name: dom.Name,
UserCount: userCount,
AliasCount: aliasCount,
}
}
if stats == nil {
stats = []DomainStats{}
}
return stats, nil
}
func (d *DB) GetDomainByID(id uint) (*Domain, error) {
var domain Domain
if err := d.Where("id = ?", id).First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) GetDomainByName(name string) (*Domain, error) {
var domain Domain
if err := d.Where("name = ?", name).First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) CreateDomain(name string) (*Domain, error) {
domain := Domain{Name: name}
if err := d.Create(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) DeleteDomain(id uint) error {
tx := d.Begin()
defer func() { tx.Rollback() }()
if err := tx.Where("domain_id = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := tx.Where("domain_id = ?", id).Delete(&Alias{}).Error; err != nil {
return err
}
if err := tx.Delete(&Domain{}, id).Error; err != nil {
return err
}
return tx.Commit().Error
}

View file

@ -0,0 +1,122 @@
package db
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
var user ImcUser
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
var user ImcUser
if err := d.Preload("Domains.Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error) {
user := ImcUser{
Username: username,
PasswordHash: passwordHash,
Role: role,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) UpdateImcUserPassword(id uint, passwordHash string) error {
return d.Model(&ImcUser{}).Where("id = ?", id).Update("password_hash", passwordHash).Error
}
func (d *DB) DeleteImcUser(id uint) error {
return d.Delete(&ImcUser{}, id).Error
}
func (d *DB) EnsureAdminUser(username, passwordHash string) error {
var user ImcUser
err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error
if err == nil {
return d.Model(&ImcUser{}).Where("id = ?", user.ID).Update("password_hash", passwordHash).Error
}
_, err = d.CreateImcUser(username, passwordHash, "admin")
return err
}
func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, error) {
if isAdmin {
var domains []Domain
if err := d.Find(&domains).Error; err != nil {
return nil, err
}
return domains, nil
}
var domains []Domain
err := d.Table("imc_users2domains").
Select("virtual_domains.*").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ?", userID).
Find(&domains).Error
if err != nil {
return nil, err
}
if domains == nil {
domains = []Domain{}
}
return domains, nil
}
func (d *DB) CanAccessDomain(userID uint, domainName string, isAdmin bool) (bool, error) {
if isAdmin {
return true, nil
}
var count int64
err := d.Table("imc_users2domains").
Select("COUNT(*)").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *DB) AddUserToDomain(userID, domainID uint) error {
ud := ImcUserDomain{
UserID: userID,
DomainID: domainID,
}
return d.Create(&ud).Error
}
func (d *DB) RemoveUserFromDomain(userID, domainID uint) error {
return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error
}
func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
var users []ImcUser
err := d.Table("imc_users2domains").
Select("imc_users.*").
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id").
Where("imc_users2domains.domain_id = ?", domainID).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []ImcUser{}
}
return users, nil
}
func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) {
var count int64
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
return count > 0, err
}

View file

@ -0,0 +1,76 @@
package db
func (d *DB) GetAllUsers() ([]User, error) {
var users []User
if err := d.Preload("Domain").Find(&users).Error; err != nil {
return nil, err
}
if users == nil {
users = []User{}
}
return users, nil
}
func (d *DB) GetUsersByDomain(domainID uint) ([]User, error) {
var users []User
if err := d.Where("domain_id = ?", domainID).Find(&users).Error; err != nil {
return nil, err
}
if users == nil {
users = []User{}
}
return users, nil
}
func (d *DB) GetUserByID(id uint) (*User, error) {
var user User
if err := d.Preload("Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) GetUserByEmail(email string) (*User, error) {
var user User
if err := d.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateUser(email, passwordHash string, quota int64) (*User, error) {
user := User{
Email: email,
Password: passwordHash,
Quota: quota,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateUserInDomain(email, passwordHash string, quota int64, domainID uint) (*User, error) {
user := User{
DomainID: domainID,
Email: email,
Password: passwordHash,
Quota: quota,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) UpdateUserPassword(id uint, passwordHash string) error {
return d.Model(&User{}).Where("id = ?", id).Update("password", passwordHash).Error
}
func (d *DB) UpdateUserQuota(id uint, quota int64) error {
return d.Model(&User{}).Where("id = ?", id).Update("quota", quota).Error
}
func (d *DB) DeleteUser(id uint) error {
return d.Delete(&User{}, id).Error
}

View file

@ -0,0 +1,88 @@
package mail
import (
"bufio"
"bytes"
"context"
"os/exec"
"regexp"
"strings"
"time"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Priority string `json:"priority"`
Message string `json:"message"`
}
func GetLogs(hours int, filter string) ([]LogEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{
"-u", "postfix",
"--since", formatDuration(hours),
"--no-pager",
"-o", "short=false",
}
cmd := exec.CommandContext(ctx, "journalctl", args...)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var entries []LogEntry
scanner := bufio.NewScanner(&out)
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
for scanner.Scan() {
line := scanner.Text()
if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) {
continue
}
parts := strings.SplitN(line, " ", 4)
if len(parts) < 4 {
continue
}
timestamp := parts[0] + " " + parts[1]
matches := priorityRegex.FindStringSubmatch(parts[2])
priority := "info"
if len(matches) > 1 {
priority = matches[1]
} else if strings.Contains(parts[2], "err") {
priority = "err"
} else if strings.Contains(parts[2], "warning") || strings.Contains(parts[2], "warn") {
priority = "warning"
}
message := parts[3]
if filter != "" && !strings.Contains(strings.ToLower(message), strings.ToLower(filter)) {
continue
}
entries = append(entries, LogEntry{
Timestamp: timestamp,
Priority: priority,
Message: message,
})
}
if entries == nil {
entries = []LogEntry{}
}
return entries, nil
}
func formatDuration(hours int) string {
return time.Now().Add(-time.Duration(hours) * time.Hour).Format("15:04:05 2006-01-02")
}

View file

@ -0,0 +1,123 @@
package mail
import (
"bufio"
"bytes"
"encoding/json"
"os/exec"
"regexp"
"strconv"
"strings"
)
type QueueEntry struct {
ID string `json:"id"`
Sender string `json:"sender"`
Recipients []string `json:"recipients"`
Size int `json:"size"`
Time string `json:"time"`
Reason string `json:"reason"`
}
var queueLineRegex = regexp.MustCompile(`^([A-Fa-f0-9]+)\s+(\d+)\s+(\w+)\s+([A-Za-z]+\s+\d+\s+[\d:]+)\s+([^\s]+)\s+(.+)$`)
func GetQueue() ([]QueueEntry, error) {
cmd := exec.Command("postqueue", "-p", "-j")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var entries []QueueEntry
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
var entry struct {
QueueID string `json:"queueid"`
Sender string `json:"sender"`
Recipients []struct {
Address string `json:"address"`
} `json:"recipients"`
Size int64 `json:"size"`
Arrival string `json:"arrival_timestamp"`
Delay string `json:"delay_reason"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
recipients := make([]string, len(entry.Recipients))
for i, r := range entry.Recipients {
recipients[i] = r.Address
}
entries = append(entries, QueueEntry{
ID: entry.QueueID,
Sender: entry.Sender,
Recipients: recipients,
Size: int(entry.Size),
Time: entry.Arrival,
Reason: entry.Delay,
})
}
if entries == nil {
entries = []QueueEntry{}
}
return entries, nil
}
func RequeueMail(id string) error {
cmd := exec.Command("postqueue", "-f", "-v")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return err
}
return nil
}
func DeleteFromQueue(id string) error {
cmd := exec.Command("postsuper", "-d", id)
return cmd.Run()
}
func GetQueueCount() (int, error) {
cmd := exec.Command("postqueue", "-p")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return 0, err
}
count := 0
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, " mail queue") {
parts := strings.Fields(line)
for i, p := range parts {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
count = n
if i > 0 && parts[i-1] == "in" {
return n, nil
}
}
}
}
}
return count, nil
}