# v2 git bundle
d86f5f6c17160ab07b6320bea1ad426679259156 refs/heads/bundle
d86f5f6c17160ab07b6320bea1ad426679259156 HEAD

PACK      #x7tree 3ade6385fdbd698d232ead16c80e43dfad3fe2d6
author R3D347HR4Y <redeathray@gmail.com> 1779458573 +0200
committer R3D347HR4Y <redeathray@gmail.com> 1779458573 +0200

Initialize Ulti Backend project with Docker setup, environment configuration, and core services. Added .dockerignore, .env.example, Dockerfile, and docker-compose files for PostgreSQL, KeyDB, RustFS, Authentik, Nextcloud, Jitsi, and Immich. Implemented main application structure in Go with API handlers and environment variable expansion. Included README for project overview and setup instructions.
gxK100644 .dockerignore !S#cU|.-U100644 .env.example (l aS"5W100644 .gitignore 91oF*qse`
100644 Dockerfile EtkJ>WdtY100644 README.md ѴaY	3N2s40000 cmd sa{q0:jEhq40000 deploy 1qb8֬O©100644 go.mod ȧIfjL100644 go.sum |$AZmPO5240000 internal xDLm#*_)40000 migrations ڌ˵R}= =40000 project-plan (/XI7[TLd᫟xO .git
.env
project-plan
README.md
.gitignore
.dockerignore
.idea
.vscode
deploy
&xYn<S:$j-˕ KDR0W }>Gdd$ˇOhQ9o|zS{aJPqH[2/%qhc?)E][:7u^DQ!}&`J6a  Cğ,%!-CpdKm;`y|<Q
AM1ŧh$NќLa=@G&lqK̼?2&	a-p"& a{T)1Rg~LqdS;W0HcE|$N |sai;mڴԞ?)HYc9]YQ4v?h7$*lM4ڝ<v.GИ3hx0\!7hhZ]uM]U\eZ/C.L<`YPƷg;y}NnVN~4йj8?_Yq+mM\kKӄm\@IA46T\Y53mغ+F;{AHxnk\ }$pnU,3q4fdZNmm{ia09.+^l $#c<<)iN(xH _m{=@0YGUeG˶f$^ i;8N|_G< {hbT^ fM8,nIndp5-9hWˍ=VH?%iWW,BIY{EXOQ<!6485B8RfHr+Una>)g9z(|%.D{tՃoF4v ,t_w޼ۘ*nX6[$l62stU
e($(oNsrixSNŨ>Et6;<7Hp7w/=	@2FHIBhC#?O::2!vߵZMJʲ\Ĕmj׶[/xs.tsh{4mU<LBwC;Mo}#eKZNqi	~瑄X
r	i =,<!c_$	 -ϕK6ux¦b,M e,<>(yEH|K1yiO:%jLD}Xݼ)ڡb6bB@MTMJ)W|{-8J=Y_żuȏ>%kӕjOͯ\'hx³<<A\*{J?A!n5v`t;<,24TW>+^քLbҝ+^v>2{mDnx,\$kx"d-hJǵ
(|St'|uRje`U+^&ݷ"<먎VݍT-dY4Yc*^W?Eۮ'6`wfU0x*(@P,<
 OxTR6d$5݉<)݆-H{b-Yy}M
uV]@WiWqB
M	R}H T[Ȅ?V>hNo0"|<'΂|i؟$esK`]կ%1>N0zXU_;8c	2CkޱU:G&=ǳŚ-0րR1ͭr8,va]{4͢f؜4/uZc8Λ>i_u|Bnry+x%<-\(ă)\4n&TE/Q&e~YΜ*GvmP
jyR7h`ʪ;0a^PL=aju0G e@s:W
8K(;_.zڎ#+3LSΘ o \co~ZX*$8`!b y$ن3B&?S$kvB1P6N^ e.pIНx~?N@{=fʟ;ȃbM( 0b?9Y~]ExS ~LUIĵMwa>ƘY#{S^tOJHHP.	 :$fTH[Jy2ېN{'Wz׭ٓ*^SmzVuuV~5{NtnNy)Bj%ڜc SA
Q͛VkxY.B?;;x5j1D
A$?!Æ^w-Sl\]zy#fF$q`(( <
N!(/U3WfiveLYg)ܵhuȪ.$]D;(ݱ-`'g<'V:jf/J"8eO-dީ-Pށg,DSxuPn0+V9pۄ!@! 6EmU;V8cQP5;;;>A?"SrXdprRc`'0! ȡp<7<KΚrjB`'K[qԢ+֕\TJ310}rCߣxq1l(B%+M 8+VCx 5N5Rx)?!.ݐh>'ͷ`W:za3贯pW2A<]#Κ@iTp>a6zNhl<kF.=&t:?1xXnSL$X,")(i9,5"NOGc0=	rsM${O"1dU]WU}N\ў.d:N&:֑ǒ44,OES$W\$\եL2*tD(viqݣ&4#ȋT:_?޾;N3Q<R5URfT_O_YD9}J їzHa-č|mrEGD\1Hk^opBAzR0e-+#uD<k7_U?&0/R9u-9M\yfմf_𳧙HYG*x=~ɾX:[R=Dcjt"gib"ȕ3)3%}	2aB!&ߦ(ַx`R)_̓
dFek_u۟,};7[{btA-/"o	x^2`;[޺7OI4N%<Jv/dCOzUkA.5K7}^)p~!qau`!.KJnh~?MCr@$3{L$Kx7ӹ$`C^I
A*%ͦ9qYFcopxÓ>k?e/5yШB1Sg@RU%XG!NDʐDzĕDK_!;y$F.&:?Zǧa稹wi9Q&1;ۧ^|&7)B(ڨT҃d7	ljORun(HO&dH٘GmT4N	>1E2"v3:9Y"<-XţgX)bfIc<f3Ҽn0	]S[
<w?˲cmY8npS5%hҬDn^:QREnLG2tPʦ֟+QXtC?U{沛09=t`j)U͐\X2Q=0P͟|cq("1#;;|V  .PUUIuj%	7E7M7Q &:^e<WY2$`g03+oƓ?d
,R<XjT8]be}zVA$K1	2TQ<Ov]74m9-S6SQz%w^B] )WShp聫
(12t^A*乺.ȏPO>;ZDo@aIщȲ+"9d.4Pvxϒ6\edo\8͆٨ E\\K۷_6LY83TȬc㭜\{=<}C(BZ2Bn2qb*u1fAy]Ga:003yt7j8S[,
a!Hp5p{^L[j:v3.0q
a) hh|FU:|89ܝN&P*givA{oyȾ|7T.ݷOQk4MiW5a7QVnۗMl0%ũp|V-tAhk8lt|qN{u[uCy)q7˸N)z4];{ڹ'GO#ƪFF#]G$O,egYfI`v	UV8}&_rFJeh5eW7ڡ=Ym&FC+g*Pv>c}_f1qTkkzwގW*zMzFM
[cIYWPRʮe7"H2MQIL&V޳=s4솴s=|L`ҠiVv4w$6=W'ncD_dzU~y3ymF)wm#~l52oCtЅmӕ<k}m(gXwp+r扸X;%F2WtW2zvYێNfReu"9_n^O%ʸ8Kd26ƭDKƛBܒ@W^nWrrѦm_/}c:fq>U&٘zw7oݥCs^LP#4΢_yM0@.դxD 40000 envexpand -u2& Q:f540000 ultid R`x=CyBGe 9X~'x# 100644 main.go 'pu]8pmȶ xpackage main

import (
	"flag"
	"fmt"
	"os"

	"github.com/ultisuite/ulti-backend/internal/envexpand"
)

func main() {
	in := flag.String("in", ".env", "input .env file")
	out := flag.String("out", "", "output file (default: stdout)")
	flag.Parse()

	if err := run(*in, *out); err != nil {
		fmt.Fprintf(os.Stderr, "envexpand: %v\n", err)
		os.Exit(1)
	}
}

func run(inPath, outPath string) error {
	if outPath == "" {
		return envexpand.RenderToWriter(inPath, os.Stdout)
	}
	return envexpand.Render(inPath, outPath)
}
gx# 100644 main.go ulmmzCrȇٹxkoH3^K7213;.7;n^
da@o&agWU6!ɒHg׫+p*i-VTgpO"=?8N-p,iH'z0+afςHzy-EI;Q/[<~2)ƑH[^E<5"L$aChdmJ'F\NӸ'@w
t3c .Lkz(\}2hsJ`ΎG8ܼJqBgKi~uy,,ő@<],Lcw;?JPmxXޤxH9NpǛF
/Jd	.;P<Ll<
LPEQلy,@)v{a;{.4	;;g.@b@~#^([1s }JdkFEqV0N^Kisha	FJI{sv3#bt^y=B
HZRgaO0AL%E4+[`pj~w{q@#!xbaHI%*!oaA"v:32~3mo@QA`=g笰 wkdƙCv  >c=1ѷUkX	@C	?|210Y,"_s%悫1Ԣ@Xs{ˌއ*=<
Z,k~3$Q\ڜq4颳/Öh!c}dGx9'4kgP5@֙r"1&R=V˻xa<܍n4;h0\GW֡Jv	vTjbկ mWiqvTEZElmnV^PEiqDD-yx9`{lO4gb/!(]0΅z;B6m/5\d% 1FjC!0݃JmJwrܢ,,-3^]:Gc1`^5evR])D5J	$-@pjp2}|Q@(UPNq:W"URUNG %TJN/ɋ``'8tgaJY>'8 t?.έ-4i;$Ub:UpN޸P[jgV"ZVA;$PyU"A[5H|6P`?%25Φ!VmIGt()|[Ec%!6E' ]9T턘V
{Hq:73Y Y;쩂 aj-K-p&[Wx!%pRI"x|BX$_FAx%7K7|M"o'{g?+tjR m#VR$Ss:dXVT-<BX:  :_[sWϏ*=ԇ0c}D7-і\R6[=q4N9צ,``/hOO!Rk\ODҲT6ǃ>O-CG *ueƋGBI*g~63Tέ[EMUɕAt p	awcvg5w(8wnYp0.7mUMDV%{cAZ'4\5=l[[EYYGPvYmBSt溬UE~UPtUq;kyU5eevA<{ kӊZOiz'|eI}K7Rz:gLum(0X2LՓ]ɦ$rήBI[i1㸂{Ur |BC1ݨ?|(f$V:z:O:P"W ǄVۢQl>tt{isx*|gn|J\!OQxmTj)ejV*PДi\R(d&_X]s"v!+Y5au=j	 e^phmȲK@	l~窏{!C.8>2l  YF2?5x100755 compose-up.sh GC9zqgIj↋100644 docker-compose.yml u`:aI&W240000 immich &XKf0fXDy100644 init-db.sh I*Z*QK40000 jitsi i!%ɛ8sOu40000 nextcloud Oħ2X^/໘UC40000 nginx %8f>9i2]DגGRhZxSN0Sk$Ju &UrQVm=ĞpO2'iRZ&vM䜿>{nw"")L>g1<X,߬VK1PD1 0$1
c!S1S}&$!7w]z'hGrBgQRՔh{`&',@(C_!ɢ88ca,p8f"6E10̕SxPpz8uLJ8>mk,2sqy6ail%bZ)zFYL³7qX]B+D*_e&lM/K~80E%0Ʉ:d6؎ʬ. c?f_~=(%[,TI2yFv^Q8QCnS),!M9dqIgR=g+oL\firz"k}prRT>3fY<@l"{k}lVYqGs;dr}!Qw[f2}
Xfy()U1Fm3U]}ecWԢV-nk9Yʥʼ]u`cUm<8Aېa(xˢ>3PUΧ9:%|xV[o0~WXKuyjm)2I,BچUىC%ЮB
w9vs HB< tB)MHs"$$&BXB4%8K`EùhԳ9gSR0s1Y,%c[i#IHd`
LTr$ɜrLI" M'fe,@qĄ\6jmfݱxnN-g &)IYR(h1.^ Pyb*g Ttbi92h
$yz;,ﱳZ,eD$|*zS!zY?2u \GE~'TQsQSfYp"9U**!iŝdFeBx}>r?@	[+@Ѷ&c$Ø`?PJK*-<E,ս|2UKKl_,^_.IC
N^ kNNz{k`r9jsd\)a4BVAU(B0dg<XM(Qn3+s  BnX bw2SU=y"ALɌyn_3&B5-q/q14Rj|fbh:a.;X^um/}lumm]|o(q61Qm`VjШU${;]r˦ޟ׾UuM~{}(TgG^j~?ڇnΕ`޽I(_9G@Xu>νG䖘Sݮ`)IV:?Ɋ[VOx5 100644 docker-compose.immich.yml ÿZ{zEKWxwservices:
  immich-postgres:
    image: tensorchord/pgvecto-rs:pg16-v0.3.0
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${IMMICH_DB_NAME:-immich}
    volumes:
      - immich_postgres_data:/var/lib/postgresql/data
    networks:
      - ulti-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

  immich-server:
    image: ghcr.io/immich-app/immich-server:v1.120.0
    restart: unless-stopped
    environment:
      - DB_HOSTNAME=immich-postgres
      - DB_USERNAME=${POSTGRES_USER}
      - DB_PASSWORD=${POSTGRES_PASSWORD}
      - DB_DATABASE_NAME=${IMMICH_DB_NAME:-immich}
      - REDIS_HOSTNAME=keydb
      - REDIS_PORT=6379
      - IMMICH_MACHINE_LEARNING_URL=http://immich-ml:3003
      - UPLOAD_LOCATION=/upload
    volumes:
      - immich_upload:/upload
    networks:
      - ulti-net
    depends_on:
      immich-postgres:
        condition: service_healthy
      keydb:
        condition: service_started

  immich-ml:
    image: ghcr.io/immich-app/immich-machine-learning:v1.120.0
    restart: unless-stopped
    volumes:
      - immich_model_cache:/cache
    networks:
      - ulti-net

volumes:
  immich_upload:
  immich_model_cache:
  immich_postgres_data:
ˤx 6#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE DATABASE authentik;
    CREATE DATABASE nextcloud;
    CREATE DATABASE immich;
EOSQL
:8Ӥx4 100644 docker-compose.jitsi.yml Ncw8.\g݀wux_x-jitsi-env: &jitsi-env
  JWT_APP_ID: ${JITSI_APP_ID:-ulti}
  JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret}
  JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
  JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-changeme}
  TZ: Europe/Paris

services:
  jitsi-web:
    image: jitsi/web:stable-9823
    restart: unless-stopped
    environment:
      <<: *jitsi-env
      ENABLE_AUTH: "1"
      AUTH_TYPE: jwt
      JWT_ACCEPTED_ISSUERS: ulti
      JWT_ACCEPTED_AUDIENCES: ulti
      PUBLIC_URL: https://${DOMAIN:-localhost}/meet
      XMPP_DOMAIN: meet.jitsi
      XMPP_MUC_DOMAIN: muc.meet.jitsi
      XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
    networks:
      - ulti-net
    depends_on:
      jitsi-prosody:
        condition: service_started

  jitsi-prosody:
    image: jitsi/prosody:stable-9823
    restart: unless-stopped
    environment:
      <<: *jitsi-env
      ENABLE_AUTH: "1"
      AUTH_TYPE: jwt
      JWT_ACCEPTED_ISSUERS: ulti
      JWT_ACCEPTED_AUDIENCES: ulti
      XMPP_DOMAIN: meet.jitsi
      XMPP_MUC_DOMAIN: muc.meet.jitsi
      XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
    networks:
      - ulti-net

  jitsi-jicofo:
    image: jitsi/jicofo:stable-9823
    restart: unless-stopped
    environment:
      <<: *jitsi-env
      XMPP_DOMAIN: meet.jitsi
      XMPP_MUC_DOMAIN: muc.meet.jitsi
      XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
    networks:
      - ulti-net
    depends_on:
      - jitsi-prosody

  jitsi-jvb:
    image: jitsi/jvb:stable-9823
    restart: unless-stopped
    ports:
      - "10000:10000/udp"
    environment:
      <<: *jitsi-env
      XMPP_DOMAIN: meet.jitsi
      XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
      JVB_PORT: "10000"
      JVB_STUN_SERVERS: stun.l.google.com:19302
      PUBLIC_URL: https://${DOMAIN:-localhost}/meet
    networks:
      - ulti-net
    depends_on:
      - jitsi-prosody
,x ~100644 docker-compose.nextcloud.yml pJ߰iBiyyӢ100755 init.sh t>&IJ100644 nginx.conf }~ϤU	Ůw26¹xUmo0_ai2Z_2Q	am5MQb1j کj%w<'w9%bAG 0r/C8Nf :'XGV#B?N(#9DTB`b0<IHBgE: ʉ(+J  -xc= So<a*8&KCK.5{vFN-i6`|c;Mh[?'mmNϫ ݺez^wm@?Qt!">&dF]g]zu?*Ae~®iv;2]p+3\^1O8=rh4НҖi"3AmVQ
e
c?N!jŰ^zؽP[rn}%tخmVѴ~FR&{7ض֞UVG]oz.Vi*ʽnzjg@?mЛ@?Ϭ<g! }r"9+P]`eTBL긋S&a4Zv!ej:ű$xb!cK
;wS%H0-R:=/e>Z% 
pL1x|J~xJU~lVՒtzD
>mQ<x3畡ӯV*Ј
[lՄ}U"k,'Dǆ`iUu\(4/>Һ7K<,[7l ʞa -[[7rYyaxT]k0}B-c58 `d&%Mu}ndMs=HGoRUOqcS.MI+_T3GF'3m	A$!@8ZJ A)`+Bq12FpRS=6>+w7aE5׋zkPh\hv	Io`8Puu?ڌgUFeh)V3Ӓ=
צgsfb,hX{೮2mI2@?>	R7EFBсJ8z^f^
I64"I
ޏnr4-F,i?=:?f?%6KXEjء[ez	v|u8^M.diJ&8R7"Bt,=i@dt(q 	8v
")Z;k'Լ3xiEk؃AMw{5HjykxkPwIf͞Hġ7/:WА:@BheKĶURix
hyP`9(ƺ0VPXc:J'ݻ#Xh݈8Ѿ;ФnqHZqxn:>J1D(xeQEϳ8-r:xRKo1a	 *͡PP*豊+m<<`oyB3=<ڀ)hXpBd?
B拃yַLMˢv7`^ɝhAE"V٬nf5oĜZp4߁t$kǭ:gKwںX,l*{]lTu./ƭ`BiS5czU1-r	V>)D7#JG
܂	k#&!/%(;4@jW-ivv`MA	7N{6_1N{-{BbaQK|x{׷_wf5+mU2(	iqҶ
~gĿ)!t¸QBV}wO7/W"#Nkjz5i&5i+d<\6L9<6ȭzlY'x1 100644 default.conf.template 0MLNyM#R.HxVˊ0+./Eb{
4!2c:tQu#KBG%r&4JWFstu>bp#Xg+x
up+F OZKpɅX=%gpc4+y&)QހJhHr*#jKzBK.GI}&4WWAJ%)iV%b^~A(۰2%)Cƭ̶i,f!~*HѿQw`bQq͐}>v? Wݫ[HV& 7-(bXl&m(P֙` +pև[_{;uԠEN_llѝ^Cw_/Uax?E/(`Ε" L&hhH?%k>htr#Q*SC;/4.Czԋ=Fvr9zvEoLpXXY@Vd P<ϡsO2'iͤ[x}TI \`ٵt㐀JGڬ7CgO(USQc|;]=:o`2^"T41̳$q{2NɯJ;G_FG
%IP0ct;,8Ӌ.t()?1)xe饬\~\))֐eok~ s6]t2j9.6Ϩ$c3ߣzocoߴ$(=ʬ694i2ϭsT̠"A7j-ɠO(Ni3NeV纤)E^Uv`~l:^F/ꀳi0$UpB!vXK8E?F=
tvyA`Q9`<KTu!b_{IhǼ{ݏ.ʽˢ?SpgjqS0zAAMF`2鮣uZř?Wou8 u8)\{Ï{z^kc#!7xYsjW=ao1l  !cï>-ʧ{%'_2q'%u%U|wy%JqF.o{Lf.'
Gſq^kLuOWF?1z1W:u~GwC|B+]hdSBh@n{ XpO"EhYfcJp=]l0-!Рvۆ<'np96Ιi&nEeh.ð?b>ɽoFDn$S3	
f(5Gonmi l)	HZ(=G-<B(4EfOF	($9 ojӂXܩƹm(18e<C3ni#mDas%U`jۂ8*Oʩ
;kAA$8P%δxW)b~-Ĥ|A-^%&C'xׄ`)۷ctqyrDf*.׎XI({0 ݫ"Y.I_h~PEۆ̶M@OHۭZpVA}1%舄 86xl#7]Pғ	N@gl>=?\s#]*1o^[!Yt/1*}i[kp%=85OlįD':]x#F7MV'DK>oB3`uW#nu؁oU{;S?@[آ_a]Zm~wɏ@ʶ\|HԶ+b%xN4-ti5;Nʘwxrˑ_)3L2:7>#s:]\7a(9zfOOo($LxG\z-O! W} 1L|IܮxJNE<A%v	4êP>ڥpLb_gjs#S}?4S19?F+n`Vʮ_[-a`zcP9ZШO<LPZ&˒AX$O'Ng.r3Er1wfQI(WOR9FGgqI8&.	sN-AY~{wwDӵp/IWQ2P/u}.|8bz :
-@NoόzMohO;3YȥvFEDrWIynY?IL>LǕpZSp슸8zǘqOՊEǪ-D0=WcBIAA=$'wNBY;@IbVw_G۸;h9t0ic-#hVke9{?>	T1׶Zt*^QNzckR"rWWzG[5͌xl?W.#'ϰe2[g$aF$	1E|Ce+>Kx(M!O:.>|<1;~1t-Y2XCFfw)9R?ddp)rVss5cR6W2<0GZyѕyU2*8cňזYX[ 9N О$rrBLc<a	T|5g`7nEi	/0jt	TTԚ{o:IXN;_Sˉ3Kzr@.le "`mk.DK+優CRLnubqvǔb{5OcJOccaFèBc嬯Y!NuG	&;6|qhϫf  vIdHj!- +UwDJDu :cɼwQju-/2~TNU Ae6qk!\d:?m
7q\pe(};ஹk#AQ;tM[*v>֏~*bVRvX#Ryd9+ҭˋ'эEcT~	-	@HCCNlj (Ӯp$פBx*zlz@TҧIMZ >\<LKqU$XWd*KuWj #j;vm5˭9oaui|_?rm0pj}U,0.F58kҷԉd^@ ]q?E~y(H\ěz`l+=*\
$ulBǲchϠO),.poꏌe2HO'#j4kpu4{5t/=l&r9qVXg d^{6pU~΋9^my_#׆A}߼?~1[7 ж̏ /Ujz<K|krL/T,'%p|F$i.v2DWOnXBȑ>yT$qU,.9k |$Ja8WAKܾKjZ/w}~҂V6hT&Z?ܶ׉n&aV_chyF_*nI',	RTqωG\/S #bwjqmV.gtC7@ߒsUSL@D(c8 ([{c1;Ob[$`!BNN.xrQ(ΕqSS,?rQd'N3,O/-S}M޻B~Յ[u3Kq`B!
A`|}Av}A.TR/ޢMuL0Q8A,ǌLF̪6{p`<Kk={:v=(6-H^3X琖8뎺P14vF#/Pcᙼ|B> o;oA8>6:A P"|LV@#du~/Q(b7qدOY73:dhgNCad_z?q	lD=#0)9+W4KF&H\e /;ib eԖ4B+-<_޴i._!Zc*LJ](xkj{,ۼO0Nl=q֘o!.m;E
Pes~0ZsWJ<195=x(f_8	߂QsW#	l O)>Yش_TOwn'$(w[]9kw`l/.E'toeTnn0j(-9#{ϡgXDw"ݼU3[*%!Co08ۉV8<Cl
fMv-Lז%!HU?(<%^{QKn4;jU:/$"| sI5`01;WPױv[ܽ-=(׽o(!Vȃa/p]r_'	(O30Ĭ]NAs|1azFLE4!*	|,?o?ENe{W\"808oO</)OXۄ.añ~%F-k{^2?׼WShsv<ي0GoOG ]ٿm
ɲbK 6LjD7PVR-_K6+KNZT$;4s.>ׇm[w|4cBڧ^2H#-]ŧLB8׈U8Wygl=Ʒ-o[@men;Gm[m?D7/5 mOk 3+4Y84*{R.I}.}8k/]vjZ@JPMI
JY3=,ä}.c=SYWk='ZϪ]\fGF45~Xw>s  $HI=3.i䯄:!Kru_*[D=0jXΤi}u79yk)G;KƦukA EaO8x?G{5>]93	99@Tpw:қ(ltܚIqG:R?A2eJƝ:7nrKsxn*DÏREE.A!>ᓱ<rC`%u_*oxYrg}98Dأ?1Vm٦"`y "c'L}J_uCDF?/uq/IǙ{sm%tD0ef5/kAMq^'m0xR.(]s^a~:|c!ŷqͥ)<@1ts"JTW2C٧C[_.ѝewEFSTڏ+mG~)E6Ca<D]efWTg<#_FP}*7:B"t$'SlXrr%YN8B 8d<B,_k	Wf"K4|0$9 L[6HVDxG40000 api >fzh@ ykPj40000 auth |6Ûu40000 config sU%vhnH.40000 envexpand #lROt}rOLv40000 mail d5׭'?Fm40000 meet Lx?hZad^r:40000 nextcloud )g$|ob
Y:n40000 permission 	e%Pu("S1@޳40000 photos C W^dA4[40000 realtime UcN
¡|`o40000 search [vbvbΰܕ_7q40000 secrets 1rG햄"!u40000 securityaudit >qVlnq}	1WMxf40000 admin 8cq>ަh9`p40000 apiresponse *ܖGV{40000 apivalidate SU5[w\Tps40000 calendar  ?N>-40000 contacts VpoHh]䪞^24]40000 drive }6 #eۅ`ָ40000 mail W`.VȔR40000 meet c?(igD40000 middleware 
.G5pSot40000 paginate !Q8[+ã*m40000 photos HIza40000 query {pY"dpv_cdxt 100644 handlers.go YT͹Ӿ݈59!2i100644 service.go 32$Q=hЅ:|100644 validate.go "[c<wl{z
7c*-xVo0~n
ɩ2=l0meƩ$MD!Ĥ}w_.X|æ27,6:Ĭ p&#0<܌Rc
b*LZ^XFSbd?23BpxDnY6bu!s+xDd|c%Wę,\̈́B8,NX+*cb	Wy0MbhH"29_V63z	 )U-رl4Nz\c~͠&үP.P-Fu'mz$mѥ,$D 3>+tp KB+KKPٸeC-K:uxųK@=-W	(O=!xt%Rz&y$7v3ٯֱ	IsO㶹+;c{i;7</;^Yjw7+'deMǟhއRxAqLomSrfS EqE+ʷDȇ
ŵs}qr$)}Tg GCM1AgQWi=j6:\[C0Sꋏ
O|y$~$|2a#i<{
l7h)m5<Q;ޢ[ZWZjQ;ޖȍϗgc2tfȗhkol!X?D}a60숟q'{eCFh Yo	ku*QwZ1eV켭Sy#ejf:tvU7wP_i_͡+jW%y| vSpz[xծ#2_Rң+ƳO13is]qXOM),@<NLE?.Mc"<x*A\ 19;1=U!91'5/%h@>;=fst2|2KŚ\Ez%E)z%J@=]jSRJ\\̒TMbtMeHkBLeQ;kC$ĜbԢ"+[dTO-1هU3.0YKckV~(&ɲA< 4<qrGd6vInmv4'oڤ q*To3 b)V$䗦%rY$&qMP+<gEn	u(@hZOdU1/j6IQ^[7f4DDO3 'jShxTNAm[QoFn̒s VM@7dep.L4
2b|P/ݥ#^;gwΞS-o MYn2PV>HPk5}h>B$R7c}fMPIcK?p4{fbċghfCnn0d7qhקv)Z#!4TXIs3$q6\o(˹ԣV=0P(zK׎	5vUehp'M|Inu9eˏq׺Wo\1#'-'$hZY_;Sa6%~n>%NJr,˂xt9C	ZN_YTR6dzz;.HoeDRlyrSJWF(peYUZje~{2ԅjFt(bq]Qǂm(b6u.(jSDLgzlH}ܑHqVȊF2'n&5duN\}.)MYh޶[])]b4(Ir9N|#=9PNPa(bXTOG۽ڻGW#+瓵hebWoU|lokM dZ2xXmsF~E!RFvڤ-2C tlpnȇt% I߻w6vI}}voWK3x#82jGR\InEA8"BIwxHe7P~XM\?^4>]522WsP
{1-"
!D|˰y%kaRᯒP^Ua|	% *
X)#݁(lt`#|	}R|ɾ`\ßQ#	@-ItL$FmNWx70g XuJ6{)Z%B5	(h4&<V 	J&#bZ
@H$"i:$мHgӆ7=aʂF/GD@|5.3/1a1
&	ɳ˜b)a{G+b3Ԉ԰|y4ݎ:lH4\{LY=!hd,^l)eK`C>'%
sŨwYgx:[mvp2<bd|YufN\XQN\7:B_mZ(r V]aCRa~"eA/<<NQ^ctF={i.2' ?T ݴ@L!GJ3SaAH=4x?	kN1XoEB\vF[ۖ˚;?x/U1Cvuͯ֕S91n3l1\Bae3fl2V$Yfj44aZ.ܿa3I |	A!5fL,-8(:ՙ2]cMl]y;N }2e VeIO-|]t]j|Ǥjmut*ҁrQg>"ӳbi
-Ka@R<(ȜjGN[pʋp{3W{+ЫL8f걶ZK["}3`饊3ٜ W?_bOJ@f{T-W'cx<}WuyeUE0A 0u_Whz\K<kv4<BLSFBCEh5٨PNA[Ub߻Yw2fxƆaQ]!w7b}aK5&pxU¡|A^L}qf!;==(0)߾=D4NeCk-ydFB^XysaSd+P6$SAz!lX@ꊹbJ$"f~+pPƽκGXX9)SЄZlt<|+VN=LK

(_'vQJf9CMqX)i聅O<-IV36;cKEec_](S
j|RJRIP7Z*{/T{h7qug=#&䲲@9ۤKŪBt'F~/zǵYDrE8QPLB>CNXJr37ezѢN9ti+[ڔWd6E@sVݳ+8UM:@}>&J[UQ=D>1(ڋ̬\-^^Y)ZȲo|]ձP*N/6!+xMO1@_1iࢉ`Mbx5;Ncva1&:~׶J%v 5d`OvHxv(q-m5"2#R7>J1n[\6.VRBC֏1`W0Źun=vqN0EV˗]By<|jgOS4,3W0#Go;#r~=#4Y v~NWrB?y0"ۈ}MKFl^b6^\;i?],xz 19;1=U!1%73o@h&Wr~^qBnbE`i~IbPjaijqS~JWIeABqj	⒢j.NĊ"N%
y%f&
	YyVJ@!I iZ.Ҽd5Ĝ̔Ē`E
Zh6M~Ǩ*CF`:
@Rn)бI
yyy%e72yhؠL[0s oXx 100644 codes.go &F*qHL0100644 errors.go gG	#\TaŁ100644 errors_test.go f"@K5],c100644 response.go Eq/88^jHlƘ100644 trace.go 6O'vEmYM100644 trace_test.go ׂ= {^P0$[@&xn E+v|AQH}L\¸cBF*+axPA	eZ
^˷  veM'[qכ>|~x8uw(q/D/P]"OoҔ~C)C%-;g;iv
Dc94N8"%[c]aJK<TpJ֩6jaňcʓdLaK7N<}H\4 {pEFg#HfgmA.1%ݳ%xSpackage apiresponse

import (
	"encoding/json"
	"net/http"
)

type ErrorBody struct {
	Code    string `json:"code"`
	Message string `json:"message"`
	Details any    `json:"details,omitempty"`
	TraceID string `json:"trace_id"`
}

func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details any) {
	traceID := TraceIDFromContext(r.Context())

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)

	_ = json.NewEncoder(w).Encode(ErrorBody{
		Code:    code,
		Message: message,
		Details: details,
		TraceID: traceID,
	})
}
,	Hxzpackage apiresponse

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestWriteError(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/test", nil)
	req = req.WithContext(WithTraceID(req.Context(), "trace-123"))

	rec := httptest.NewRecorder()
	WriteError(rec, req, http.StatusUnauthorized, CodeAuthInvalidToken, "invalid token", map[string]string{
		"reason": "expired",
	})

	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
	}
	if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
		t.Fatalf("content-type = %q", ct)
	}

	var body ErrorBody
	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
		t.Fatalf("decode body: %v", err)
	}
	if body.Code != CodeAuthInvalidToken {
		t.Fatalf("code = %q", body.Code)
	}
	if body.Message != "invalid token" {
		t.Fatalf("message = %q", body.Message)
	}
	if body.TraceID != "trace-123" {
		t.Fatalf("trace_id = %q", body.TraceID)
	}
	details, ok := body.Details.(map[string]any)
	if !ok || details["reason"] != "expired" {
		t.Fatalf("details = %#v", body.Details)
	}
}
hsx package apiresponse

import (
	"encoding/json"
	"net/http"
)

func WriteJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(data)
}
BTxFpackage apiresponse

import (
	"context"

	"github.com/google/uuid"
)

const TraceIDHeader = "X-Trace-Id"

type ctxKey int

const traceIDKey ctxKey = iota

func WithTraceID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, traceIDKey, id)
}

func TraceIDFromContext(ctx context.Context) string {
	id, _ := ctx.Value(traceIDKey).(string)
	return id
}

func GenerateTraceID() string {
	return uuid.NewString()
}
&xcpackage apiresponse

import (
	"context"
	"testing"
)

func TestTraceIDContext(t *testing.T) {
	ctx := WithTraceID(context.Background(), "abc")
	if got := TraceIDFromContext(ctx); got != "abc" {
		t.Fatalf("TraceIDFromContext() = %q, want abc", got)
	}
	if got := TraceIDFromContext(context.Background()); got != "" {
		t.Fatalf("TraceIDFromContext(empty) = %q, want empty", got)
	}
}

func TestGenerateTraceID(t *testing.T) {
	id := GenerateTraceID()
	if id == "" {
		t.Fatal("GenerateTraceID returned empty string")
	}
	if id == GenerateTraceID() {
		t.Fatal("GenerateTraceID returned duplicate values")
	}
}
.xY 100644 apivalidate.go oQi[W#\hA100644 apivalidate_test.go k-E)kh_ƚiM{Dָ!x7
package apivalidate

import (
	"encoding/json"
	"errors"
	"net/http"

	"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
	"github.com/ultisuite/ulti-backend/internal/api/query"
)

// FieldDetail identifies a single invalid request field.
type FieldDetail struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

// ValidationError reports invalid request bodies using field-level details.
type ValidationError struct {
	Details []FieldDetail
}

func (e *ValidationError) Error() string {
	return "invalid request body"
}

// NewValidationError builds a validation error from field details.
func NewValidationError(details ...FieldDetail) *ValidationError {
	return &ValidationError{Details: details}
}

// DecodeJSON reads and decodes a JSON body with a size cap.
func DecodeJSON(w http.ResponseWriter, r *http.Request, limit int64, dest any) error {
	r.Body = http.MaxBytesReader(w, r.Body, limit)
	if err := json.NewDecoder(r.Body).Decode(dest); err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			apiresponse.WriteError(w, r, http.StatusRequestEntityTooLarge, apiresponse.CodePayloadTooLarge, "request body too large", nil)
			return err
		}
		apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", nil)
		return err
	}
	return nil
}

// WriteValidationError writes a standardized validation error response.
func WriteValidationError(w http.ResponseWriter, r *http.Request, err *ValidationError) {
	apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, "invalid request body", err.Details)
}

// WriteQueryError maps query validation errors to API responses.
func WriteQueryError(w http.ResponseWriter, r *http.Request, err error) bool {
	var qerr *query.ValidationError
	if errors.As(err, &qerr) {
		apiresponse.WriteError(w, r, http.StatusBadRequest, qerr.Code, qerr.Message, qerr.Details)
		return true
	}
	apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidQueryParam, "invalid query parameters", nil)
	return true
}

// WriteNotFound writes a standardized not-found response.
func WriteNotFound(w http.ResponseWriter, r *http.Request, message string) {
	if message == "" {
		message = "not found"
	}
	apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, message, nil)
}

// WriteInternal writes a standardized internal error response.
func WriteInternal(w http.ResponseWriter, r *http.Request) {
	apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "internal error", nil)
}
?Cx6package apivalidate

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
	"github.com/ultisuite/ulti-backend/internal/api/query"
)

func TestDecodeJSONInvalidBody(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("not-json"))
	rec := httptest.NewRecorder()

	err := DecodeJSON(rec, req, 1024, &struct{}{})
	if err == nil {
		t.Fatal("expected decode error")
	}
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d", rec.Code)
	}

	var body apiresponse.ErrorBody
	_ = json.NewDecoder(rec.Body).Decode(&body)
	if body.Code != apiresponse.CodeInvalidRequest {
		t.Fatalf("code = %q", body.Code)
	}
}

func TestWriteQueryError(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/?page=0", nil)
	rec := httptest.NewRecorder()

	_, err := query.ParseListRequest(req)
	if err == nil {
		t.Fatal("expected query error")
	}
	WriteQueryError(rec, req, err)

	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d", rec.Code)
	}
}
}Qcxt 100644 handlers.go yqχ{x%|,G100644 service.go 
(顧_v)5[I1wM100644 validate.go YJDrwyjJpz++xVMS0=GbcS939aB(-9@;+ٲ1,oo{R\슮dRM/Jd 5.Q\t@pf]]&,*7\W0phL,RyJ<%OK쟺3O ," !d0gg*3pOF"vc29l	YV)i2¡}z@DRTJADm.Q+QGP4j= U%L:bZ<Zj<\umP\#8|>[03HLga=Ϯ^vL)q,*A#	2mfwTa m {5 Ym..8$]3ԧm4z(8'=2*Z膇E oraӂf"2Knү}uhKFKa[^w5[ZuK944)T'֦-0j'ZCĐ:58na%猪l1lAN;!2x1/)NhSL&N62ԛ%vgĞ8u'pT1duvԡVS-j4UaG,god *>Hss#1tQw(Ǒɦvy"o
LmM4=+֐2ֈb 1L-G{'+cOjǡp焳k秴`&L!KAygl-žuԪΰ)M%~.50?]oL:	xSAkA&ِdWlE!nՃH `1
Iilζܽ՟ ͣB7MVZ1}oõք/D4Z7mlNy),|{"VWztÆu_ՇC}H.ٮ.K`ھ&"T2$nBOl4&_,Tsuc[9s8? {N5Mz]t,Sc
Hp#\@F!o1Z2ߔKvX@u;I|-^{8L>V.fח-2n^)hnm<ȏ'yYl$D@	Nm7+	LV_4WjZ3RW!c}YCFh.G%?Rn tcN(o^.KsF݂㨓=%$Q.]`{1'wzC<%7b>OSY1Agx:C 19;1=U!91'5/%hrf#S;e\RJ4RAV^jEIrN~iXx;F%"Ԓ̜bč:Kss*5lm89ajm
i@tz%2SsR!:t|Sf(jrqւ1ZSXTB%pK@X5/xa eř qf`RKJ2s@&{0sCiN>
 ~؋ޠ
x _100644 handlers.go ۿFu>qx 100644 service.go Դem<AV100644 validate.go 7-yrHxvqf100644 validate_test.go t;cِCa.>xJ@S9H+ޅ=GOWΤ58)3YXՓ^Oߗ!NAjyI(g0y}+y(^%QJǊ	)X`,⡒^ȋ#ė	[Ż?txFF_W}f~@Ȭ0p[ç۸UM?]ϒs-c}5ԿSi3	|xt 100644 handlers.go ej-ei_fHuAq100644 service.go /W{(100644 validate.go 9}PUtr<;Yq93-i/"XxTJAI(V.JZ(H"jԴR
f'ffg$H\P)^'(}_333*xg|̞岑[bã~#7"Ba!y%es$OSc^?}:۸'`#SŮg;1՚xuugۏ٦x/zRu 6gvH<J*PXK8xEx*AZXw0 I Pqݥ8"x'&M݈dv	̀Liq:pT/ 쓗zu Nɷ؊y.H4oP)LS5"	YqM~T7z3ꇇ35cs0USTfZ[H}ʪJ2bR?яTK&iՆjH20af:ŬIC 5LQ)$Ɩvv*8'|It,*	 4XFeN }{*Sk\KfRF;u&Pc̄_X`y m#C>j^CJyW29 G>Y2}r{b7 ĩ&Ĺ~2(y	Qgz	}A2BtA#[4\$2E\rw"'MJޭߊX]>=xuSMo@U׍-!
)LU*EPT=n]ww]Z +8s7pC?]QSvv{3o?|t.B!xu76kCy\ l]"$D[߳5߳d2LyD3/^	F{Lշm\VSZ:a9g<0$ >t:UGމBa냈hKMQ@>۬NtvV'FzzOũj[]+&t7,=¢CHHcm
+1*L~!{CROOI/3ș0RVq%cVbJP?n-ol[,כMcUv;˂<RXF/7M,xN`^-ٻ)ߴ.}"F Tj֧pmi}Z(O|?+i:>4JbnC8{%wB"va'dTt[_̢En=0oZ-S+ THsxmJ@`WE/< jQ`"BlvlM|A}wC"vO7|.'<	dv	Ts#*qM;A{!5	)AL WpH
UƇ0x2SjƱb9^)iB'gaE/J䖠c)wH}K9?_:vn
T+@:M\DPrGX,`5X/Ԋ	5ޘA +c,`aCϤxY6Qd|5[uLTYͺ	jx51=+xt 100644 handlers.go _Du*5'100644 service.go G6F"ϋbf100644 validate.go :7g_
q{C8.0xr6|dԗta4ݴl4݇Nl46ėIf8G~81M	9=01UCuJD8"wR1*(m4|6$0#/ϲdHD]!Ma)NBSϳw-	Nh:c	bׂ=ℯIT0*pS:㈇81@NO"?G7MhDzGqgP$Sߡ#c
"x6eAՒ|)V,裣C`Q1K"=#a`Đn!Gt@dAc|ez.
>r߂\TI*|tpGꁺѼwj	8ӫǂGQVQpAxo|	s9FS?ShO<OIByACT851W3,0᣹0Z:LI4\ZX6H:M;8bA_X4HSoe3V$mVGdCZUrghJFYfYj iXj	>&

`IvxS$Jpmg 2m@0CeE	c.NZ<c$x/<W>cǼIL+8Q6{ @}4zVcId}02uH9䙊(Ո|AH]ঀlymeelhn"+oVͱ~n
bsϪOPF`QOoj#om۬NoUٶR i+DY\	},~7ì	,yS|dCh^5iq+ϸT`O)N<J@sX8Km'Pd0&6ϖ&5a
lB	k{ȥBPQ3 ~lKT:f
e\",bJ\t=F4~no=	x@Jf&Xr^wLV\;R
sKn	4bإeejF{u֙NI}=ΥVr"{Ų	$^1O@)(c_@	=<kBPD7fWjعJrũ -(L.3eN\h".edtԢ,Kb9=aS@=U3^}mƈC3m]XLYۻzۺWpRėhxH0+*;iO[L3FjY2!Y];e*9fi
K5fu⠱045\Ik`gkA~:@O^Y(8ZMmͰ5~qæ}*[,[PfP/KU[w&|OZehI:Fr3GpYx-Oj웏mo`+(&ZW;Z6yɌh$mID#S``CkE׹pthy7b|ӺY}53D^#a*G8S_yҼ.r!^M{#Kh_a]}ٛo_EԀ]:FbDX'j}[w(ͨrןhk&lL(a5~X|S-X1؁pD_iqZJԆo*a\Kb[ihO􅓥Y0@&Yd'?sG\x[sFD`ds+>l|{WD$+==Ox0<a帿9>[:^(NY^(LԂׇ&QHq	Kjg:Ho3ۍ;~h^ARJ_ϠzPYگ2{ƾi,='Y{ߪoA0JOu_qiQjQǎqt$A^I54bs]ׅo4s8l@U
 gc?\%ivSG4W#23'Ō5Yv`oFdA߲	<7|T5	m'(4IR*[؍t!8x6TDGX˙?w@U-#h1H+;a8,TNՂ*Db#?>wL ǿQZ0\[Z=ÚF5xɎ:,4Q	hqߛc'3꘰n
/;͕IANl@90X ux4f wOؚX"ygPqZDL@Xdr$a/Kg3''i^=XWڅsY:[W#Tm;ZZ{^*i$JR:'vG
 ׼Q,xn X(Br8ijqu@J8WD+Es) TMxP -,[Yjs{	@7",SY`:nIirS'%o䤾e{">q~1{tp6d܇һRE@9@-|>'>T<"vo%~֘P_:ͼ-70<=@d[;i|Prak%]T|p)A*m8{O74MACb^(jֿnĕÜ
⛦i,4xAg!P<X<
^,T) Rqť5ǖ l-qLKuZM9oj%|uY!	]I@-j(q!W#w׀JbaX XCEЌ<`lY6\sIOQmrƊ[+*pM`89yɦpxe*bV$"ɸj*TU=Ys/ogw>=|?/'`#^pPc|u/ɧK>MNa|61nOԐ4ގ"U$*Ow4E3d/ِR-vfwX76B;<~"9xg
8:D8>ۜsSVM_;2!W+=(N7(F"-J|Wh1/QImf5aC#plow>]XJ!CFb)1:8L֍}"pS҈$ukzS^Q&it Qelg~@ލIH$Zx|{AnN;?Y%[f"E+<Qs1ܔAx$>9X4+H,P`ΡZ0Oqa~CB_S3)^;|NaXمfu͗=^i%88;<vki=SrH6'ga37]Y+oXfi39(YPDdbvv*0U;F,"ۂKNֳ_bqG˩y1>1K=m N¹w*8K,}
6Lơ'd鿡5GnlJQXi7b%(g%)!
KK#NkS_&v@]-GQˠkh"
(qi(iRu	wJO4Ble㮀dB6W2{|6%w-AF%m۠|-8`@ T
L,	7ppY,\wte|}-wс7z$_ԭA hk\QS:23쪍.=J$]TEU.YLarv8AT)OaǴpEW?)Z<ܤE/FK쑱mDb,1
c	,Hz(sGF/	*eoذEsII\{=:=׭xsy5R5Qͱ҉j¹nLq65[
)MLΥl)ȿ8B-ͅ,v"GD4:f+qY>++lq[QϐgS*|)sJE#_J6|kչJR{I&7œ٘zKxG3I7xofi6JM;vHnڮ"$Z>E+߮ۿ(˥3R35߬0HME-1dq	.G Jaa.Tz{X9dS(56.[Z8ѝ1QD5F3H6"'7΂}!\wSC6?.ڄ , o{pￃ.zwZO{):Ewƹv7#4tQi[Nc7%δ+8|nW.Z'Ҫ]8U!GKRR~J$	 8牄|y1#Dbb kiIy4B?T'7!blG9D3S,e?BwO1K~80 cb@P.C}K'uo}c}t"	,WP,n8OėfU%";ewH.=./ye>-9gCݼŧ'@rD')h.	WoDLUzOc0xdLUa=yCÁw\g<|cФEbMmB,u-Iw:^ǥo0zUWWB$8a-m%Fjh`_q˰5Cnya-~Ư>5\u̭=sNdy{Y^YZ踚;(8)JPRbx>f'9xoM2RH\nAenm[fd=e:3p3%!7߀Wm8RџDoGVo|>f/HJ!1y}H 1EoL&c<ӛ3~TYy#Uɿa2[	ԐRJuR0];Ț2 	0F[="V3	3ebGt1RN/JS;bI)qx	A3坞4{hrtZ Ooq]MA<@Q5t$pbThIMw`$ȥi69[1u/t1XC	?xRCb'$d	9
j!KaP0@o3޹@(¿Il\j]I[#ՒZq9R/.0\\h=\,A&j ;	-KfF4+1@6x*L:n8XNDũJ$^/2U(peg+I"`Á}u4H_w#xV6$|ba >K]"յ`0bmu\{Ι㜏:mN/C _akx2иVhQ~qrO"[\mio4O&ol,YI#W,L`Re$pCo|Ba
m-Cyzi/OG1*EagY %x^4Epʉ}\^ gxR7M>ol
q{Ƨ#z0O;Owi
PQAE^*Xh-R@$;Ek5h2|.HtMt[8eȟY&!Af+Z\PW+|d[o
fd_1Mш>T$!eɈw֌i$a(0ȩ)NCC$NKXQ$6G;8K_0$\䋱 &&RS\Л sR"Ÿj&U6IZY*pFpj-=<l+E_48A$w1䉽Kϭނ
I:l/*xmM1ԱOh+6~IlųjU\nq^e&McI >IJ
H"d,bhVPwQ0`+'OmqXVG!b>ɱag7ޓ	~͈. dوiW(Qu-+ \ۂ_>w5B F~ S 8/Vxx|+D9.P;|:SgInR\E	E-DknCeI9Dᕂ>i>QΞ/10H͆A601uABRcH!L)aR&/Jyd@Y6< 5擟U4T<;*>E8:8UL#<RX)RI !X"̲%)*Ap	m' _}^Iv1yk
ֺ6Lp{u>g^=<RS0z;S"*j"Fc'&;&l/ܨ4%cztXy(g)&V(hg<ϑQMD͏YQx2]6g99zTZ|O~,	;u8\Աn%CIwNX3p;QpɀnP`N-K饾N3jS'q3,]y5XB͎-Z*%J 5VQq
>z	H-y8	qX@(
,Л1yOLSf~
vg7	jzoc$`st͆Lj^!=6&2\lEȝ@;>9"yWl	{fcJ`B/*EK׳1?/0_RYMUJm5ǑM5 lrL[)QQbBOlD[U6z¥[6ZQ*.5).q=;6[fU
HwlqO|\_?#p5g|U/طYmi檡A=QjWD*T
ِ(OHb7-h>E050%0ܢ]<If,U]4B
[0zv'ck}i"S7z2FНsE{GD6خS":2_K=vh-[ߖe)U)>%=:㡱Kuk8A!|f9KL_o$ hCg֚s=Qpu&\ RHLsmT~K!Q,M8Q5m2jiŖ$R4OwcEvv5f;Le"Rwx|Dϳ=3iemRz9b-}潲pR`t=E~kj=8N@̀
kj~QEſC2R|%cKuN, +ר]ǥ/|ܲo;ci-5޴_Z"ekXX5-M>١5UR+zNT͠NCRl>._-KDJ:y[MeK=FY36Wݕ*F]WU'29[S2ڶͺZG	Y47ЧR^SkwA_!
U2NzH	KO9*GD3DuqL~}vlޤ?O$lVZ
CbzP	
ז0~y|rv{k=,LM9$zb1f4S '$r=ytu9FzbjzW,:`xJcb6"Ay"2.'ۂg;DYړȦ67CИxt 100644 handlers.go n0yT72'`pIT100644 service.go =mP714$'If/100644 validate.go <VUv+G}m9.X4;xQMo@U#%DB4X# ąKB+8zKƹh_K`wmqϼ73۟zc/i"򋞵-ydC>;WT,.*!j֝YV՚aw!0	Cq.s7~x~t=3]JT1eȚ|@f	TJw':3-RxYfNc$exȯ*;ܣ)685$H"j_;2<Ă#rGpwn<f?dLY(¨-h-^_c֎,goQJ0-b\x/`HxG?!["X.fɩ
@/7Ng>׭zNhK%?ݣ2*;`Viuj%+ċZ7ehR F[=^7DY9O'*x^package meet

import (
	"strings"
	"time"

	"github.com/google/uuid"

	meetpkg "github.com/ultisuite/ulti-backend/internal/meet"
)

type Service struct {
	cfg *meetpkg.Config
}

func NewService(cfg *meetpkg.Config) *Service {
	return &Service{cfg: cfg}
}

func (s *Service) CreateRoom(name string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
	roomID := uuid.New().String()[:8]
	if strings.TrimSpace(name) != "" {
		roomID = strings.TrimSpace(name)
	}
	return s.cfg.GenerateToken(roomID, user, 24*time.Hour)
}

func (s *Service) GetToken(roomID string, user *meetpkg.UserInfo) (*meetpkg.RoomToken, error) {
	return s.cfg.GenerateToken(roomID, user, 4*time.Hour)
}
z,xeOKA٠d.u䆬I:TIa}IwFO XX}?A[EO7j?фbDp!ՒV}z[FӋB$j
wqnPiPZHÇ>oJ:x̆pI2^k
${YE}e#}aK]HM=wlk8{9hs>4z]K+;wyܣR'ԁh!`*26}ڂkʸjE*)eηp)BJ%4JMQ#u{k:+_0rx F100644 auth.go ))h0VMtv4!100644 logging.go cd^N: >&100644 rbac.go ~n#ӽX>s@<8.100644 trace.go Ʌ2ʁk75100644 trace_test.go !)Ãl2	j}6>%[\=DxVKO1>gi;R-R$U9#$;~vSTZ8$V|e 5/
$u#!i2ֆR-iZBnvpmz3 Jj>
t#hkOPXْ͆cYM0돰!$&!Ex=	6lsnEA"t9(rq;$9)TF\|Ϙ@ߑ@JO[~CD#o|^US(%UzakP+^`++6 y;.Ё<=#t
KfTop5&|@9$5k>;ML<
ڲ25!D7W%nRx'wK`%Jp4ƨ3߃IN*D(YF(}^CLhاy٩LXhLHϤ[9݁9ma~t*)[s`itbXȃaoP07={[]?rnX'/x0֧sP\[~m~mg .hB81ˉGP[xݵs*ۤ]`8L.	@̝9GWolc'8Uu -mS׬ Ci?$5lV{&xmQN0=7_ai!L-	KA'E\Z=Mg4't@!ٻ@ EQ\SrmG"k2g,E%}yw`t"x\m'(:w`,  JB`k0ިjԨqHE\x]Jiwv߱:r!l{12?⻩'7Ȋ!hﱉ4ea92R"&Q;7rQ{{pyrpQ)zLec_k&ٞQ~\A&>h22QU<iq߼̵XxRN0=/_azjQH;LchqZoh$l$mBH 9FbP˲p+uBB;X$?nܒJֺX*ʡTIj(i2 U2VN0'u) C  Ma>D/xcBHuH;??mms>r 8OB6p9IkMIٔʲ+(Yuџt[;+3J8$_!Κ>vH\LбO}O,0cc˯Ix:aE~Cىħ^goX0Czbq۶l,ۏRW;*b*|*ǿ~_|ʾx[[gWx"package middleware

import (
	"net/http"

	"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
)

func TraceID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get(apiresponse.TraceIDHeader)
		if id == "" {
			id = apiresponse.GenerateTraceID()
		}

		w.Header().Set(apiresponse.TraceIDHeader, id)
		ctx := apiresponse.WithTraceID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
C Yxkpackage middleware

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
)

func TestTraceIDMiddlewareUsesRequestHeader(t *testing.T) {
	var gotTraceID string
	handler := TraceID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotTraceID = apiresponse.TraceIDFromContext(r.Context())
	}))

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set(apiresponse.TraceIDHeader, "client-trace")
	rec := httptest.NewRecorder()

	handler.ServeHTTP(rec, req)

	if gotTraceID != "client-trace" {
		t.Fatalf("context trace_id = %q, want client-trace", gotTraceID)
	}
	if rec.Header().Get(apiresponse.TraceIDHeader) != "client-trace" {
		t.Fatalf("response header = %q, want client-trace", rec.Header().Get(apiresponse.TraceIDHeader))
	}
}

func TestTraceIDMiddlewareGeneratesWhenMissing(t *testing.T) {
	var gotTraceID string
	handler := TraceID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotTraceID = apiresponse.TraceIDFromContext(r.Context())
	}))

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()

	handler.ServeHTTP(rec, req)

	if gotTraceID == "" {
		t.Fatal("expected generated trace id in context")
	}
	if rec.Header().Get(apiresponse.TraceIDHeader) != gotTraceID {
		t.Fatalf("response header = %q, context = %q", rec.Header().Get(apiresponse.TraceIDHeader), gotTraceID)
	}
}
(bףxS 100644 paginate.go *{-;6?"100644 paginate_test.go ]n䶠A[i4+"cxLpackage paginate

// Slice returns a page of items and the total count before paging.
func Slice[T any](items []T, offset, limit int) ([]T, int64) {
	total := int64(len(items))
	if offset >= len(items) {
		return []T{}, total
	}
	end := offset + limit
	if end > len(items) {
		end = len(items)
	}
	return items[offset:end], total
}
lx xpackage paginate

import "testing"

func TestSlice(t *testing.T) {
	items := []int{1, 2, 3, 4, 5}

	page, total := Slice(items, 0, 2)
	if total != 5 || len(page) != 2 || page[0] != 1 || page[1] != 2 {
		t.Fatalf("first page = %#v total=%d", page, total)
	}

	page, total = Slice(items, 4, 10)
	if total != 5 || len(page) != 1 || page[0] != 5 {
		t.Fatalf("last page = %#v total=%d", page, total)
	}

	page, total = Slice(items, 10, 5)
	if total != 5 || len(page) != 0 {
		t.Fatalf("past end = %#v total=%d", page, total)
	}
}
	xt 100644 handlers.go >7I%v1p100644 service.go s.]c$*fN{g100644 validate.go NZ
sN0½O-@!xRMo@Uč7!*EHR2!%EJQh+coUYoEUc =V/zI7N3f߼cdvh;v#D ^H(:P5Lo#RxЅ_JO2:>F	/f*Id,O-7:HClm *5_cٮa>GL׬jsksl1k6eynM'!fD\v\zyYD>b(sJ6\J.Vw~0y*xg@n"J#z4p	$ï
5wT|!!l	WF!~Z}+vyCi_,=;|18԰A:q4r<ljljtZZZӄǮx#zW5]+H0nphgPx `'N*EO01>n St˷xX1$q tuxUQԧ,7$pƔ):7e#l+kk.=Ijv\ u'VxVKo@>ǿb+r@"Z	!B٤K]g6}n bx:%蜑VjY/J4AI/#[iż
xs}[OLGuyUs	ddbz!Z	ђt,|yfj	qay7'/` ,3g@/uC0r΄&MXUjsvm19!bV8Ӄ{_ab}U1]JwX#1G%0-	Hkһ\ 0;2 fik8cvܝ5(L8&؁eqI=!'$V>	)Ec1$jT1NĶj,d8"Uj~`Om'1`g&ѫ<`09(M}v5=*W3Rwoȇ4otWB$d=0l:һCC
Ɏ\eG+|R=ic_hJӸO'Z3Zk*;ԤFM6QKq!H")YF]PAfS2S%eDNS/ M޹uPV=0f<߅Z8em<xc^-hOX4{v}&8kw p ?R	Y45_0 #PWT35kŋqIAn/0-[l(Ykc$8p#0HQ_NjNrQE@iz8v"&<#фaI&^¿`bk)0S=5V
IO(0wS`x
L
lSЎ3Bxz 19;1=U #$"&WZi^BYbNfJbIcqqjF"юƶe 3S&a ;xM 100644 query.go uXQ'\LNUj#100644 query_test.go J
ĘjO6i xXmo72]?xslMU|rRI5GR׸hDR$/X~>VR_B[Ql_֖sh^TG2qa5r%}I63S`t">5+Np2	Q*뢲ý\TEfXBgbjRXJVjsBp*_?`tF\u"]b"Y`(↑ZiAK[܀pv:gЅKVWȊ4N)?3,	VUx{QL)g vobp*S}4l0 `%9EY-ODZ>VҸp.ꁣ/)1dѣ"F6I25G>e,-tOfFI
8]fF
͔.+en]:4҈-zSf?kZgoY96ޱJcT֠RDVJR5Qxw,^.6<\'NvV%T-d2PbX<BTOKQJzHe{2.qsPb.K^.uLsY"aw1+"X#xC1f0:gdRGH7A&>[i<P#Ja5df7-ilYz*
)7!Lcz bF0A8|oI~*T1?@7Dyn8?1PzLkWs6o))c"1_65hZegXɘglOaFf̔p,|ŒC-pLN
rFwKM*.sW=5(J.t8Rּ.WB)n9vuJa,sܭ8*:o7yqVwx@ӨrvϘl>e_1
r*ܠf,K;\-\nԧe_V{Q[oLכaK'
!,FoMtَ-$og ɭmk.64W- ^l_ۉǡ:;gCGI8[H*.vEr7?/*)?,H|H㎧8Kxf~g#H29Zav+nA^C2Zk
mKl B7Щɦu~om
3hB?B܄|'EvR\To$*S9<_,^'}+4[gYP;}utkr2<{MCx;S'_͓S2q3]7KE܍ _ ɑ-Tl,2O PFY|åW2y!D|.j,#9lb;ʚ5s7d>)ey{P	)xXmoFbR"cL8IޗBX~5;37ֆ(R癙gg'Vk!_ Z&R1ݲL-x=+̟2G%RODjw:	,q UӹXuR[l;mGÀƬbknwv+Xʻ17{Un0"PغݚȽCGZ[D& `O<V HNuoIA:Pc☔eY˗\O9{>'pR8)Ql5>[oiŽS5odGROc-TZ*	b
<HJȼ|ل C<~$ɖdטzo(1+YXKF~9t,m-W&)3,0a,[ţ,3))W\=}o\eppjB*9V3hczE8{*6ˊbX[ՈyWӋSRxBS*tPߥK,/U`pLQýIdĕ=0dj.^hkYZ^>\okL5>pa_F#5TŘ'J7*N\YH0PWu[볐	eyU
6~!MifgE.jS
%J9:VE[~խo;o~!ma/nx
>b\$KVV.TܝW0$\>\uԋ^o=1PsٱO+]d@@{7-SabE#]1S[n.LuPרf=lvh!_B	2KU6|ǯy'蒁ZRnȶW
>yG>edd0<N?6yy@ĘeB^Ń0aه̖Pj6I9MCyOknJM실	y'FjYo2Ѧ#Ɍb"\haD Y@r=`f>K4tte1<>֏Na:.XŅ/Iqmz-{ 9zi_@,XIDb<q,$p7'Y=8}H:!c˱"KS{Y B4+Ū8^+;D9"H^DΎq;_7ՋᯮL;+rθ\MXx^4JB_zЃZ_E~+~TD-.[s&0B7dj-c`SFtz*K2gqex# 100644 oidc.go `s}L.;"#A{ث,?xRN0=_1P%(eũ .h%RCcG@U}ǎbϛ&nF?N*IR*f:%xo~6vIb_6D'~-홒1XmkkeI5 Iۚon,{zx災ggo8S3!@V@yyv+_
-`m,aUt˙Зg^Nqx[ SJvǠ:' m4#I7GD1m!	
<!iRFox[yUT>uc/l8lh2TF<Ѷ"f(Crb~0ZOuZxy|饨ެGLڔfv7]6|`[Ь^#6>*m)1,*.,jaWCL?M x% 100644 config.go 6bkaxȘ/kWtοxXmsHb)eM!Ab@IT1串hܺ\t?2==ڇ]xQg7ɭ'}^5E^<$pc2*Iv6!æ廋CJ̞7gE\dazQM]'{L;CD7][P#	]s]d5{wckhoI	KZNZ<35eq_ɨ,"nS4|`OF_$<f>F	Q;t䔾*O<O+&y&-󜱮O,Wta&8#:_IXG7XTie؎wI.H@#Z`
'Lt}U*Q$Ȑ*r+N^6iuhk&)KMMHmwrt'-{(E-cVQ9DyɿvYǠ0	SȞbuI9>6mL"\m	S9A8})54$~ۤ > s,ZꆳYfI0$MJ&LJQoY&yL{b-ӤuQk^^t-AGw3Vs֋20_`!2*IJ۷II!ʒ1sECGup(2Eh7Y{＀,߄Ц{38CFGajw\Kx_|^A7b|ib&SkMT:5R=ou+چ-6[{uu?21IIKSK<W?0BOl-Xؾit$)gdPmg*<2R\[,,hf0p՝Gic=h}@='tEf	GM;b+]~%DAg=OB~,u9KrCUOj=_Kn|NZȌ';֞RIvV	'.{4qp&%&Us#,yK'"CB*vތ֒"%KB!!!U(U8Ǿ%'{/=U@'\m;I1i6:ǥi󘱴媽VH=rOyHTmjM~AqlM0R2yө7VQoNzN%L
<)7.}"PD8Lv)x`_RUusjyG~$;\õ.i*?ˀ?8"l|5b{2[m!Br8gOh[77xƁ m;?kۭJ5Y`9 :zj^\0+P3ƵsOwRs%)Q@)cAdal&w%w̕'yՒOM<!Nkz«ΙۋtM䕋"Nd7$:3,Q_@Q_aj5IQm(`5 q$B#M;~9!_h@DLQg}Gw%/Zp
KHQn==I3	G]I:ϕ;#L1l0(`D""W9 ؒ:<&GQ,WO}5(LqYLdEU%e	5&!$t[*Eַiw)MH[Dn	+0(_A*	-w_;Pk(:k5TV3GA8i.-AȖ[p㯘=cwZG6f׮0Hڒv(boٴ4ƺFJ=|;zs5XK0ORP"xO 100644 expand.go X>ooŷ\!W100644 expand_test.go d}!3%e93xNpackage envexpand

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
	"strings"
)

var placeholderRE = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)

const maxIterations = 32

// ExpandString replaces {{NAME}} placeholders using vars.
func ExpandString(s string, vars map[string]string) string {
	return placeholderRE.ReplaceAllStringFunc(s, func(match string) string {
		name := match[2 : len(match)-2]
		if v, ok := vars[name]; ok {
			return v
		}
		return match
	})
}

// ExpandMap resolves {{VAR}} references until stable or maxIterations is reached.
func ExpandMap(vars map[string]string) (map[string]string, error) {
	out := make(map[string]string, len(vars))
	for k, v := range vars {
		out[k] = v
	}

	for i := 0; i < maxIterations; i++ {
		changed := false
		for k, v := range out {
			next := ExpandString(v, out)
			if next != v {
				changed = true
				out[k] = next
			}
		}
		if !changed {
			if unresolved := findUnresolved(out); len(unresolved) > 0 {
				return nil, fmt.Errorf("envexpand: unresolved placeholders: %s", strings.Join(unresolved, ", "))
			}
			return out, nil
		}
	}

	return nil, fmt.Errorf("envexpand: expansion did not converge after %d iterations", maxIterations)
}

func findUnresolved(vars map[string]string) []string {
	seen := make(map[string]bool)
	for _, v := range vars {
		for _, name := range placeholderRE.FindAllStringSubmatch(v, -1) {
			seen[name[1]] = true
		}
	}
	names := make([]string, 0, len(seen))
	for n := range seen {
		names = append(names, "{{"+n+"}}")
	}
	for i := 0; i < len(names); i++ {
		for j := i + 1; j < len(names); j++ {
			if names[j] < names[i] {
				names[i], names[j] = names[j], names[i]
			}
		}
	}
	return names
}

// ParseLines reads KEY=VALUE pairs from dotenv-style content (no expansion).
func ParseLines(lines []string) (map[string]string, error) {
	vars := make(map[string]string)

	for i, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		key, value, ok := strings.Cut(line, "=")
		if !ok {
			return nil, fmt.Errorf("envexpand: invalid line %d: missing '='", i+1)
		}

		key = strings.TrimSpace(key)
		if key == "" {
			return nil, fmt.Errorf("envexpand: invalid line %d: empty key", i+1)
		}

		value = strings.TrimSpace(value)
		if len(value) >= 2 {
			if (value[0] == '"' && value[len(value)-1] == '"') ||
				(value[0] == '\'' && value[len(value)-1] == '\'') {
				value = value[1 : len(value)-1]
			}
		}

		vars[key] = value
	}

	return vars, nil
}

// LoadFile parses a .env file without expanding placeholders.
func LoadFile(path string) (map[string]string, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
	return ParseLines(lines)
}

// LoadExpandFile parses and expands a .env file.
func LoadExpandFile(path string) (map[string]string, error) {
	vars, err := LoadFile(path)
	if err != nil {
		return nil, err
	}
	return ExpandMap(vars)
}

// WriteFile writes KEY=VALUE lines (no quoting; values are used as-is).
func WriteFile(path string, vars map[string]string) error {
	var keys []string
	for k := range vars {
		keys = append(keys, k)
	}
	// Stable output: sort keys
	for i := 0; i < len(keys); i++ {
		for j := i + 1; j < len(keys); j++ {
			if keys[j] < keys[i] {
				keys[i], keys[j] = keys[j], keys[i]
			}
		}
	}

	var b strings.Builder
	for _, k := range keys {
		b.WriteString(k)
		b.WriteByte('=')
		b.WriteString(vars[k])
		b.WriteByte('\n')
	}

	return os.WriteFile(path, []byte(b.String()), 0o600)
}

// ApplyFile loads and expands a .env file, then sets variables in the process
// environment. Existing environment variables are not overwritten.
func ApplyFile(path string) error {
	vars, err := LoadExpandFile(path)
	if err != nil {
		return err
	}
	ApplyMap(vars, false)
	return nil
}

// ApplyMap sets variables in the process environment.
// When override is false, existing environment variables are kept.
func ApplyMap(vars map[string]string, override bool) {
	for k, v := range vars {
		if !override {
			if _, exists := os.LookupEnv(k); exists {
				continue
			}
		}
		_ = os.Setenv(k, v)
	}
}

// Render reads an input .env, expands placeholders, and writes the result.
func Render(inPath, outPath string) error {
	vars, err := LoadExpandFile(inPath)
	if err != nil {
		return err
	}
	return WriteFile(outPath, vars)
}

// RenderToWriter expands and writes dotenv format to w.
func RenderToWriter(inPath string, w interface{ Write([]byte) (int, error) }) error {
	vars, err := LoadExpandFile(inPath)
	if err != nil {
		return err
	}

	var keys []string
	for k := range vars {
		keys = append(keys, k)
	}
	for i := 0; i < len(keys); i++ {
		for j := i + 1; j < len(keys); j++ {
			if keys[j] < keys[i] {
				keys[i], keys[j] = keys[j], keys[i]
			}
		}
	}

	for _, k := range keys {
		if _, err := fmt.Fprintf(w, "%s=%s\n", k, vars[k]); err != nil {
			return err
		}
	}
	return nil
}

// ParseReader is a helper for tests and stdin.
func ParseReader(r interface {
	ReadString(byte) (string, error)
}) (map[string]string, error) {
	var lines []string
	for {
		line, err := r.ReadString('\n')
		lines = append(lines, strings.TrimSuffix(line, "\n"))
		if err != nil {
			break
		}
	}
	return ParseLines(lines)
}

// ParseFileLines loads via bufio for large files (alias to LoadFile).
func ParseFile(path string) (map[string]string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	var lines []string
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		lines = append(lines, sc.Text())
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	return ParseLines(lines)
}
D"XZxTpackage envexpand

import "testing"

func TestExpandMap(t *testing.T) {
	vars := map[string]string{
		"POSTGRES_PASSWORD": "s3cret",
		"POSTGRES_USER":     "ulti",
		"POSTGRES_DB":       "ultidb",
		"ULTID_DB_URL":      "postgres://{{POSTGRES_USER}}:{{POSTGRES_PASSWORD}}@postgres:5432/{{POSTGRES_DB}}?sslmode=disable",
		"RUSTFS_SECRET_KEY": "key123",
		"NC_S3_SECRET":      "{{RUSTFS_SECRET_KEY}}",
	}

	out, err := ExpandMap(vars)
	if err != nil {
		t.Fatal(err)
	}

	want := "postgres://ulti:s3cret@postgres:5432/ultidb?sslmode=disable"
	if out["ULTID_DB_URL"] != want {
		t.Fatalf("ULTID_DB_URL = %q, want %q", out["ULTID_DB_URL"], want)
	}
	if out["NC_S3_SECRET"] != "key123" {
		t.Fatalf("NC_S3_SECRET = %q", out["NC_S3_SECRET"])
	}
}

func TestExpandMap_nested(t *testing.T) {
	vars := map[string]string{
		"A": "1",
		"B": "{{A}}2",
		"C": "x{{B}}y",
	}

	out, err := ExpandMap(vars)
	if err != nil {
		t.Fatal(err)
	}
	if out["C"] != "x12y" {
		t.Fatalf("C = %q", out["C"])
	}
}

func TestExpandMap_cycle(t *testing.T) {
	vars := map[string]string{
		"A": "{{B}}",
		"B": "{{A}}",
	}

	_, err := ExpandMap(vars)
	if err == nil {
		t.Fatal("expected cycle error")
	}
}

func TestParseLines(t *testing.T) {
	lines := []string{
		"# comment",
		"",
		`FOO=bar`,
		`QUOTED="hello"`,
	}
	vars, err := ParseLines(lines)
	if err != nil {
		t.Fatal(err)
	}
	if vars["FOO"] != "bar" || vars["QUOTED"] != "hello" {
		t.Fatalf("vars = %#v", vars)
	}
}
̧
x X40000 credentials w$Dosӂf}40000 imap WY] fc40000 rules A6\Fpy'40000 smtp >d^lq,)bb40000 webhooks  npwy,`jIxQ 100644 manager.go vZw\֚BgM100644 manager_test.go ,vbf\wi"xW[o6~~+ 4JfCm]4XiN8KJQq&"ѷ4YH>T4]FR2&yJHT*UQV[ʫ[&IELeьxmߺzARjy/w$8RԊT2&>9KTA߱l-10AoV5?z:[)<XfUͤ@]?KZRf[*[ ~ވ\e!I>f'OtxbG;P"Ac"xGzF
|'ރޕ3d<&v턔$@7

P]5H*sa=U pjy)o.r56py&]ÃcOީl (47 <#(Z&OfxF>|Cn#We:] GBm	DqəjjLnHt(FЛehxS*'ؒeQmdnҢ5b?N#Mf oˆ(Eʴt@L,cW/~o<BoH-~dR}KAN6F˭ӉQg06Hmq3iPBZ	JG{'>}Xi=e&\@+2{=B0~ qh}2QٙD?DE&Oyn
QqlLQD#v
ٓ`>qFUIsehѱOĘcrO).8fq4~CHk˰w|1"%Wn*s+`Z5YX6TՑ+Q.NeOImڞّvH&PN]2BvϤ,n.{Gtz]R]唋آ|'|sqJ߲~G%\[NDԹcyX
vW6
a7=Ch^qPE3LC;j߳37+Kr'/6mhBk@ekUU=}ObkpG XYuS176d)0pl̮6S=Cq03G͡26}7;wp{eZ<-3[ -c/6ｓ
EfRY=O=vÊ';mi\ivaa6Ls408!qx>N
9R4og/G_GßO%ݕMiU3-`,xMO@_1lBURDăx܌vJm|ENBqygJ!(	j2UipԪL2=ϩ:p2.)5ZM垰y^MCd70%d썣i9q#8w}ӿ?X%y1^-!$l$F:Sgd
ql%oifXe|$xQ\G\ˢ1ReA5{`=zBE/2ePL5v֮Jq`	~n>nQT}/	lmK(;exG 100644 parse.go 8Aڶ?wO]100644 sync.go VN4@w(o~&ֱtxSQO0~D
	c>4M*CoӸGD;Nj0*|-T| D7$c	`!o^׿m1 {URVɁ-\pmC2pl-fT^B	ceuό8Ȥ_mv_]r%(|8Gb3!I'<D!aOj4Fqқۉg
b74A*ͩ"M)(H+)ksH#Ԝaþ|&.KpC=ɚaA^-EѴ cowQjAs'\aI7NuqS y:BAszydJ")1a~
.8X..#hT.}ڡ²NhE캺p7r%9Θ,JK=*xZbø] +lbhw}Sκ.G8fOD`|XWFS0d{Yz1NGM7鷺fnPLTӷ^"g<5#aJ:2lcUՈ_,ƤÈ?+$y=Ь1?$H_=K[,c[n},<ND717q|?p`aPGtM|pIeioƤ}WD5F
xYsŖI<KnpCCMg<"]{29gZ`*E}W%h6uyF+3dyY|,lE2YVVA;|KYgC):	ߠn)VtJe"Drzz<;	SfaYIډJ$LAsٔg,y*K/w%fXB<4$>LqQ	UJ0p$cGpf!~j6U1
yƖ62Լ
ٱc(XXrY;Waֆǋ6,){2P#8 mmB'i/7?pѱj{OoKE@zGdq3@9o>/HSDBc(;a=lcB$,Լelt:l%hE}QW:R.sAC𔫀kDw@L+ʋXmiNui _ЗFDċ/>`4Xr!#(SBYGF%LZ!Xap5`5s/+%K3%)l`yZKc&s*ct4U@xLA/SiixK>,6T5@]JtB%l4Ņ9HnnLa"Jb(̼#J)7))Y@"۲W  y P --u*xmJW(JEyij'ڻ9g]m,"/3R

 L`&^E3j001U_eu/k )C;)͈/OSZU*[R9'@䡞m<'S(M/:PB>08~vc}g3Znr,`P(SŊWW#r҂Gc!VRDF$V&PTy쉂4J |.>w Bq@b^k}
,t
9o5f0Z-n-:82N.!+qkjqc -@_1&kbD	sꌃxG|6nil:9P¹#4bXxsQշj/Qϖ-P&cN]Üƙ~(݈ҎWa1&\n[~]@IJA:.&|S-tp5cl|!%_Տ*a&LD>Bp!`	?G׃)ޜٛ|ɘ&ƥ-9'LV3W"37PаKa+	Cظ̮o0h=оl<g}\/\aQ>Tbcgg	"l%KVwTg´aB2ɻDh*0if{H#Kýh0<f'>ScQSe3O[4nZ\p\2[LfyTE0g]ՃO
g=-Smx'@9K>1q'B/@X }}c"W8y.ǏS/t\Jvv|yzjF۽uT(:)[{[[M\U½3, X>ްӊ2':U@)|ivNSMg:I*c$Fwm%>:[
_`@mB(5C"AP$ծM TA[i9M92wJ~}E cǠetKKTV9e>I/Ƀ-CיɆ| ":?0݂bE`5Ւ?sOŮ;?G.vRaJ'WtpWAgV-lqn PafY)nu`f[$m(謩,c<ȗ1lcfix$:-`]s}!+d z#*,(zaDfav9Ct}pYWsxr,oyw.3nkVfzgQ-" dn2|'|ZiLKkNNײ)'<|~O|~	AS$=EQp$ Ǔ`D[gvmAh0y	Fj[G~RkY+jC?b-or2nߋx&7u*Đe75k!{uo߸oׇb5F4LEq\$1l`b^:3 %6eJ%"-q(D
 :΂LasLWIaW!m-qS}_Șʱc8oe7rCf߲SS_>}[_iZlѴF<u^s"˙!q<$\MWQ܂AVx% 100644 engine.go jBܘY9f.w& ExXmo6,
VR)ddd7qi+Zm52JTݑ䗤Y-K`e]RJjq2UY.gO(.H^Y/t	U,zjio9?K|_?,Y.:e}\3Q}\+9Oks2(!خElp)t]Jg$ ilÝY'bBQr
HR1(S
	"P8W	ss}Y<,sHxZe\?\j-S<V2ud>|lnf<E~j᣹)Z6׹(xzlZE̴YUO>Tly5ZtR\rMY1 粊\~+K]WP,].N@ko^b;w9ht512Q2NČ<ǋLB\g1rKX\[NwdG"yq*;Ư5<CZq#)
a+(#Ȏ1uW#0G]sZC(\ ˵S}lkKoJ-T}ψFbQ+$]Y#&lU!czxr(V߀f?뫋86]\(;b?	!2 
м:\W?<C{||qd^߮AN4>Wa321aq*BFt	#%0D6@IZ&3yё. H28L13shpiQ?6Db	;*S]?K20Ȅlh<ALI՜a)ֳXɃ``9.AOTt8YV@)t3":XKQxQǎT.øɁND"EZkaaQ6.ohDv)lGC0:OM((~AK=i)E@ik<rnw'ѠAp02Fc2ؽ0޽qFD%b݂xӞvֶKFDʂFmQ:_w^-ҥ
p
RYst}I 4tO:߫d,1覼5IgӂfKΞF0j w"g厞rxVt\?z,hnY5 3`ڙҠ2HͲ!LB"Q؂FX}TbUKfMt|rxYzv*f~"tB9:4ul']C/K1￹AgHǻxƘ=Owf4ж4|R<q$Qp&ɶc\BC#7F0,329v~qRvh9&#{o!"Խi?q{d.ο33xvcg*{<2n$E;gK͒}yD|Us?}Yϟ{!te5T%,ԧ"1o?$Nksky&Lυ{GuOjy+JnoR|i ݤxJ 100644 outbox.go <Ώg6rS100644 sender.go ռߡ$6KFxWo6
Nv^!֭kgbD,*&('w}e%'NU*!	}/`TF@,//jQ"PAH|\]5in.efOW	Q~dѨTܜIx]Ij%RbC|^ˌKW=P~i#鯾-ZIU.JMȜ<{bbYU1yΈ<8 jdIsYw1C!fz5[*R)yB"<Lz׺VFdT!S7f::r0<1^C	ĩ _|BF6Y)QcJɴ܊J^A3	w}ok	Q*њ翃FC @*PIOw|donx12bW<k
9wc|C-:&\hWI& /oQ>&l|j&'k:8&MQ*!d9!yFslj[Mf;{'2dw5ڜ#kO?ITFȚMMS_.Ěz;=#d4d9Y/+PʘhJAZ2	!`QiGM* 0n0hRs#yU GDb9°S='.@y儔yK-0-!`
۰`rtzW@w1yɨ5]jh:'*M!FdA*fkKoGC;l_mVf{`^.1k؂F]|z+FS{:	c1iKI>rVZmΐy=HlȨ-cuRYsZסq 0qEGeR'RyQWljl 4AiviؐXݔpG
vFr.G7e3JpכkG<ƃ
|y̖8M /^#[[x:?ONHL>򾋭C+f\k.%	P+m©;
d=4g-mHBh:1`Zlez@d{S-:9˶wa@t\%Q`LL!J?p O8)PK'AmbfzrYzB yHZۑX1\[*ҥBx/j:/Lճ'-ޣf/i=7jg̚gC]E1.QRvNmbPų'&Si؁\'C_M轢V=l2kxКTKJIo0b*}:٦"	G:1yq"2o!(.ߘ&l_Y0awNl#K6\W~xWO6+|%qaӤN(cmoH$n]b&g;mZzش
Q}Nɒl*tyYQJ?hd4/!xV<Y}L1MdT&Eo!#T|ࡿLzw~R,u3UgSi/'*^T<Bg,ǒ)u^'uGzf(^~19<o^{k1 ;kV%!whfu]	g)@ 4vd(%D/<CG(Co'ti	ҭ0$[#^礒אfrϿ0B<Ji`H3E:^h!MW\$\4YTи$C%l.!1Ѹc6@LA_ⳳ)M!XG͡@iLy8K>BpfuMΕ-1P::&fKɈd)oG]&3ӄ`Ϛس,t8ȡ:y3 "\aeq= HX[^tBKJԽRԆdÕ/<KM֪{|1bRAM:jq+@	O2Z`*g4%]st&o0YpSӀ2\#T$U ʈLoLJ`Sm90GP="Jau㘁1sIkj
KвEc~t{	1M8yyH>+5\9AQ;驘اb& ml~S 8`t$7& <ٶY#z̰xb:1`N̓`c8+тA!5-/2k:090qޡ|R>LZʟvtAp	n-A9s!=Ut`(w>Oǣw{mCz`TjAKa9ւȅЯw`yXfbr܂2%;UV?	]hW>)z	GXn:H`pKw5-&|h]UW~Gܓzav`+ޛ^=]䯇JXQXkߡBu36%z~q׽X6{2D'\+\9u}Eвߴ4NHO/6ȵ,+;ϖ6+	:#!5޼٢NSf^u'˄ux' 100644 executor.go uo֠p6FKE(6xVYo6~~TI@@I8>,V"H@-q>AVQr9<x#ĭtݬDw/~R\zRGELD%-
	z(K*z=Z
ԦY"zUBz8Ly(QjiN}I
dAoּ.캫L`Z>Q3kvЋqO{	,0ZѠ @9G:SL?ƿhN*jr+#y!xm =SK.%GҜ)Q+<euQ$&h|^``"}ZCob-uEY]Fi8t]`2xUľ|;!"yTl
3,re		mI>?}vad,Ed[Z>O`KzL&,ŋ*GQQB!'6 ^צZi%%Zp0wn\u[A[0giix4@ȇ3\LN[m.k _̮.ӭK|=&0C8B'*縄lD{[?u?򦶴͌MѕQ|xmY"62LD]	}3mʈ8">EwZ!gѪT6Æ6,>nV+^uGcKjNɭWQ-Yo@EξETMT7Դ¼ϔVꎝw'Nd\N7/<-%@QUYLx\VaN
6ӥl	uvy&̖~`BjH9UY#+YH$:[iۂ$2yK1=cmͻMZBXZll幏Yf{>
Zغwv,8'OWptRշc4.񪁝zzfmYοG_&sC#~?sbʺr2&pkմFfs?o&OA	¶S(9umc0_t3cL{;`Dеމ.u[#䭓t2B3q{UNz{DbkLGH/c!VlތOvzXL{]DWw#Y^2VL` *%f ?řx# 100644 meet.go (o̗aVi--Q)xrpackage meet

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"time"
)

type Config struct {
	AppID     string
	AppSecret string
	Domain    string
}

type RoomToken struct {
	Token   string `json:"token"`
	Room    string `json:"room"`
	Domain  string `json:"domain"`
	MeetURL string `json:"meet_url"`
}

type UserInfo struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Email  string `json:"email"`
	Avatar string `json:"avatar,omitempty"`
	IsMod  bool   `json:"is_moderator"`
}

func NewConfig(appID, appSecret, domain string) *Config {
	return &Config{
		AppID:     appID,
		AppSecret: appSecret,
		Domain:    domain,
	}
}

func (c *Config) GenerateToken(room string, user *UserInfo, ttl time.Duration) (*RoomToken, error) {
	now := time.Now()
	exp := now.Add(ttl)

	header := map[string]string{
		"alg": "HS256",
		"typ": "JWT",
	}

	payload := map[string]any{
		"iss":  c.AppID,
		"sub":  "meet.jitsi",
		"aud":  "ulti",
		"iat":  now.Unix(),
		"exp":  exp.Unix(),
		"room": room,
		"context": map[string]any{
			"user": map[string]any{
				"id":        user.ID,
				"name":      user.Name,
				"email":     user.Email,
				"avatar":    user.Avatar,
				"moderator": user.IsMod,
			},
		},
	}

	token, err := signJWT(header, payload, c.AppSecret)
	if err != nil {
		return nil, err
	}

	return &RoomToken{
		Token:   token,
		Room:    room,
		Domain:  "meet.jitsi",
		MeetURL: fmt.Sprintf("https://%s/meet/%s?jwt=%s", c.Domain, room, token),
	}, nil
}

func signJWT(header map[string]string, payload map[string]any, secret string) (string, error) {
	headerJSON, err := json.Marshal(header)
	if err != nil {
		return "", err
	}
	payloadJSON, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}

	headerB64 := base64URLEncode(headerJSON)
	payloadB64 := base64URLEncode(payloadJSON)
	signingInput := headerB64 + "." + payloadB64

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(signingInput))
	signature := base64URLEncode(mac.Sum(nil))

	return signingInput + "." + signature, nil
}

func base64URLEncode(data []byte) string {
	return base64.RawURLEncoding.EncodeToString(data)
}
3	x h100644 calendar.go J͋&-4_G100644 client.go yVPsuvF+J8M?100644 contacts.go i5|5+V1*.GERғ100644 drive.go dv\M_8iv
9xX[o~K u$guzxɹlFU6	gf$Q/qprH7ù|	wq'Y"EqYDGi&wc:0_sRXLd5;0WʱTx<M7	U|P|N`
4J|A\݋e0/2-+Zfve"P"dӗf/-fhM- 1R8i򀀳(lv ">^r\<|}5HP8OeE3gߎiP:#V(CK6>~LyۘF,3\ƩNvJryn	oe_d #~9)=W?No/lBA Q^0⓳kW"OP굗\ʃD@e{3uM}ƪ^*#j$cbkr"%rFߺ	}9{(ߙg
0>_|?M zAB L"X> [=>l, ?'x,GTQ oF,	#L?X!JZqށM8JlА%k6!-.ݳ
:T]JlatVC&}mBהl&QH(?Izw'Vk@hA.H˼qv\1d_.ق}t2Basa6G
CЃk-ۃ8Tt'7eim߆'׫71Lb:X.yvvoq..ԟ%qMh8"mFt,`!4iWz9\n[9
^D:$\%L$Iưtm%:LhE}ؚ4=*J!>0Mti Q	{j0~SL&'T^0ǎʆ2ewm"#"5O"]'l2ݜ5_	`@>v7og[CwƭK3ϔ__noAdr>]
%[0x,ᯫ ]6wH~V_Շݶ]"&xq>_a6tU6eWŋָ0>۵@p&lڊ 6p{+]MA9(+^v^M K]MZW֩PM>/LحPS||ᾋ?)Ǧ@mގmb"X!z+}gPe,~[g&(gWT&J*ҌE%kl;a	B3𨇇?RLNуӺ (iܛzG<l IٛYa,j*ipDu-K=FZ^gӹгNn9.'.v~TDK.n2d8`զBbG}G
ʾ8
v2<4Nu1b0ZxSW姓zf+fIU{%_&qf uI	_Z8PPu	*qCI]uֽ譯Þwfv|nL3*-]ɣ΅ET4=
zQ}{|אvk#6B(}Ҙ^!e ?w'ާ8nn[էuee*/x-?k뒷j֤fպl-xSt\`KHVv1fZr-Jm]hu[C
unph ZxTM0=[bjbS-49tlCJ=^-$ow)-z147pBO6?B֭"j,m4-k7H幕5">Ƃ-!3xPP6w"`d7y"ȊZ67B̘1tE}F|?u8aַ$Nm
,0ԢDs	g<9?LV{3`=\#O,xqfHR4,1j	z;ҵVHl4*AtYRTn4Pg7}$д1 jtuK7xסɈ䭴UύN@yNtY׆3#Ug(O]s?S2$
?g&uĐ2K>G&.''G1OKnTDm0ӿ#f_9շxجlI~k܏gZYL۪]㢔G4/$xXo6
@
dàTRi8ن4hrkwGR2e;nH(f$0Ҽ vziR #Oy!=r_Q@X1m.3N}?BIOJ [CR(MCC^[Pd{/}E 	9'])
p|֎ NW^4JQEk\,V5p\ݦ	_.#4 H%j?yMqV<02H#/Qȓ"'(W<]Q{wI)xPF,ҹ2dy[/L(q\2.<NngYgw}do^Uʌnݤ#~D%LٻT s#}}~;YfA.^8M|
E(-s#~%V=v}az(u]trt<B@N~ʙU%1.ӕl! N\G,8
Tʲ,
=V@ϐ0~
s^yw`k|`<DK(3nҩU֓YTu	AQ_Z7c{%Wu)D센 ܵ`l^ɴYVޫ-p5MN3 YMT 笨P]ɉy&17ev4;
^B-0d캄R|k2*B(:{w,<	,I;%SN#;/hY9bн&28Yf/Ew+ܲ`mfT\>+XQAsTyb= &o5y)uwȎyYҤ
ZyķDYҪP{nh6Z.Svx$?'3Lq{_6D톃Ճb	¨)Z&xqј*3ًYB~HpeB1Ќ3+,L=zL-h(>=t)7[?ƴҧ{W,'7u2ޠ/ns8}3z{<v.CBt|oV10h=ߚ1ΎpHW)ʟٯ{<lՌԧ=4̨/F`trj@jZ5r[?]U
kJGau7Xli^0ն=a81$Ƴ3a#vr9z";;|&}ˮBL׏0'gɜKDp!<S[R!,LJϐ6TȞ@Øm6(}qq\֣v	45o:Ka0>+ |KpT*A}jaW3D\EڌG/[;hSXcgz_Ш품'vH?K7InUƑQI5nu^z% بV(̂RhW+,]Z/ vP OFd6$Df<A*C<d /)jwLh"'AA-G9^Z1o	#VY_=W
lT+gVW~Dqf%?jf2ݗ@vϹBa|q5q<uNq^=NŬ:uת?"G]AD^r;
uV;ׯEnkaVN .մy4ywQ(L.Ypk$ь͛@oCM[BS/xYmo7l
v眛aI,i&dAsIˆb;Na$RHNO%g%i^ͳnWu%5u;QZ|22Q^PUٚ-r
nW՜튜ejWs+~f@飨RtK
,8`!f>bdQ&$Ou%`f(7_3V	{m>~1<{CaiȻs\2%[Ԏ)[X娒T{(852S#şB(%R=
2_ķ5ʇDBh^ 0)$}/F7RT2ro:`xJzgKY>b%hWqBnc!5xqqtz]vu6eUD1XPQ6@(*GWZף᰺))J^FQ6>$v1?ip5saH@gjMfF.yy
إ˔U:XY0BC;Pl_tU:є?<{7̚f`Mw=O2EsCI}f0l'|1ڊ8=1GDju.D={R)q=%ig\2<R#)=p=MDդ8շຌw!g;,l{Quɨ]8!yo_Ju^%ٓh0Q~na+mњg"B.<kC~أwz:]ݠ`=+rסUdR,%7}>n;-P80XQu5첇ZYОADOO[{mVj"yng'Y~S|NSZB%#<>׃zJWh2]G $8?&嫜laVJ
1(8FB~KI6O&7,/FuW/§fVb-G9A\
_/w"|I1OKZ8:<'*V^WI5X}@X'^oI,t1uX֮I`7onn6Q&\T@9uo2bQ)lF{H`.Tq8VA.3MA&\Q`i=j"X!}ӔĽ|)h)G&!fa1}m @ν騁VCw7)`7DhQf@EfPf{6hSc}:42Wء,g:%<BхxxQ,@+JspaЗ$\<5SVCuvt`
f4+OM~L4Pf ծj̼}Պ5ՙU*ޠ,٧&-_blԢ0d8=+l-S<>ap}I9rH^a3E35nLruDyR*[VPcrJVt6/TV5Gr[\xsijOW`
g\#> EGcf
:(ꓨL*)&otKHrcd+))lc)l&n{s,i	@PjOyT&"-٥BqqE9YvG`1`hKnr_$hQ29]	HJݲ!Z8^3>ڣ	57$M.jXzl/$<G!tf=\`ߣҲ 	ؘwo[&axW 100644 permission.go carQ##4iۦ100644 permission_test.go ?\/D
"mxUMk0=ۿbM6Nzq $]J)WZ$g$d[Mj͛Ѹc:Bk!8N*6JNeAh`5TRP(R/@-Jcudq\VH㈬75FC0:'Esǳw=q"J>;'qJZ^k)ixi-(;ӥ4i0V>0V=wJpuX,hAdB͗H5ְ]X!5]0A{?VՠGD[lt@~
5AH[)|k-!m\LOV4ki\BCK}rJtGW-$Tulaځ"B0l^1oWܐwۗ=gJs˔j_p5YJg?|c%Ou=~Ʀg`Tkjg{Vx*h6m#kWӥP>Oii$afm0-PhEbWHzԇ|5C̊M%lbj`Bm#wM!O$*$)
i!,ɩ5 S?osrܴۿɖ\SOyi|W'Upa_];hͧmkg/._1nUD;FNƭ1uV.y9܋X|]m%!*7[u6h)#t0>ښ"[.'wW²\&OŢRӗGGurEt:r0tQ8u`Bi|1$R< .p "9qxUMo0=ǿ5 9CPdvADmy.'ʑ۸ml7S~|Z{EєH*TYkc!HVU8pkA]mhj5|8ԟ9;1. E.Ӻfg3t%RUqwKOSq!4#l.
zvVI(OwdyFTnH)V7첟,Y&y1Wk>s̙]~V"6O9oc3L1|ϛkF?hdO'3ՃQco H)|SȀU8\p
|c)
}t:*ghP49k'|4~"iLu!岯74rkr^, w(/M/3)
2a.1>i6l$ҒWmer/9v^n}&\gi&
ii7ѣcȗYYnߡ6!%Ұ /jx% 100644 client.go nF,"#{!侼xUN@]_qk	d#tQ*HDm.L	x=I{C>T"ǹs$aLeY"Sղ+>W6.yPķϙ`2BwU{TBk%fܶ\RC7<V4|Zc뫷<DV,}5 s҅\iH(Bu`f.s9x	{@uC=r_KJIue^ՠnU[f 9$E*nE̢KO vU=kR6Kt(+(|.&x"I)gGj=kKAATWm|6>ctCjWhl3-HtO=uW8gXʪ'?v*/G	u+"s5Bs~z/
$U2G᣶L]*;O#J&h&ukp=R"5.iރOJKuxsBM)ia`b[("},d/$|"%:s
+y=t$&!9JV.[!ȝ,Zfܡ,S9ZDCycNe5&nn+45MX4	kk]hƌ61ۙN"B|#1_y+Vu./YC@]Sž~lvS<@oz[*%BJDDxFUH{1,6T[D7]c|ÏgEkng#΋o{)ui՘~^԰x" 100644 hub.go ݯʶLoBB0n"\9vxo0ǟaJӐ@i@{944UUw)@cߏ}TY k_-!MXϲ4h6Zk+ZV<EYy YN)NZxpF- tҺhi+`oy(6i6;.wr2\FL5u.Esٰ#zqu2Dvl)W1-G(Λ-LXÎ_1:+]<%XF$_; #h] th&]蘞gV#X@Ձ|c5;Oɧ# 70M\`2l;ƀ|x뎱sܶ;lHrv!uQu2q]ʃ3Fω+qzʬvu)4`ט}U<MY/)\쭵f--+h(zq7L}ZQ$0Z)Xy
GO?BᓭJ+Ԩ]{G|K2EvJ骎"]&CȊ`sRQib
e+S_q.c<^mg1q7Yd@85%l,p}v8,A%jxa"bوKXHG碛[~uA+uolm(F24q^iM,3%H*ZXK9$[R&M[;Ӌ7v*PVJtjѵxDs.zM0yg&(ӽ@"ޗ3s^v'tDPCl?٫nkq+'^vwh]q^Rg磱KYYc5To\xs 100644 handler.go   F|MW100644 service.go Qv'96ixm100644 validate.go yi&)kTl',MxJA[J֕Z6*`Q`s7R[ObA,l|bi|̇kJdAPِ9Je [ٯh`_Z2bӾ+px/ujMBkXrDd.f@m/ٴdD}ԆB]F~ZxQo\	%.ʥhPi>~Fȑ^
M7!V9a?S笈;Fv%jc߱T}$˷xWs7r$-@ZR;viqȶ$={w%q>$j~`8牴ȥk<Vqمj{r<~q@?E'2Dٲ+PXe^k{黂ÔMZb^kGNt_WYoI2<*Jfqņ;'\}+gDŏuPyklbI	?ITbO3Q\ﰕis3	,)2-mhQ"T=hPTf6?N>kCyi _eNFd>DLb$.Dƴ30F[	׬/*&羪28X߂h`C/Sca!PԄ]4B(dr! S$e.
*)!QT'/u(lQ@糩X-$e+	=%PbK!,Z_NH=ܶh{I,OG!jZ! -ܜ}ЇL$V	fL7Wͷ>0,loi!̋(8vc/_MJӻ]-0ؚnu%X2|gYq+<u%
|g:)X-kˈl	fܖ	w܆60*±@@U)*z!VC2#@	Dtwk$Up>" \h&؍h&E:-6
wCm+[ZԙcPdyH0Lr|k-ȓ@9[[dl)r\V#nAY'HYMöZ*0lt[)8kPn]yfZQgFOH;5h$}mDn^RZ}=~WvpT4{῏~Tl#6vBa>7qh?ó'iֶpH1=jk::fFb}6Rk
Z
ɬkO]g\RZ7#i STWRb^Aק7Yl="-rTD~MF[).-wn'+XfJ>|G^'=3hmh:@d<yX/*n`u.9@YIo9@3÷$PBtƻIPZƯ$g\ԈH4f!Jk)lFtU3=rRs7ubnx9FD*-#{*JlYvIq(avNHDLpU[fx[DRzRj Gp|oڜN`geUgl}`iXRqoπX@1KaFdJWϴ#me%Jdˆa}dY0Tk,vQnQu.2:/2bDKxSM0='b)..rN]qY7u-*]HHh#%g;YCi}S3%s">,j.yL7YhTKQ|RQ#=h@[қ5,"n=HR)sf\ryס|+Q5BOÉXA|NCc t\16O=%<>۳-.-NXe8!̢V!GBռGυE,W zQgt>FpCVY?o=wҟ弃O-G9siRihoϝUwBly
:o+ugM?T *Q&vQ(O(xH0F%ogToN!U2+J}l/䬋x' 100644 provider.go h'G\g5~ xPxIpackage secrets

import (
	"os"
	"strings"
)

// Env resolves secret from KEY or KEY_FILE.
// KEY_FILE enables runtime secret injection via mounted files.
func Env(key string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}

	filePath := os.Getenv(key + "_FILE")
	if filePath == "" {
		return ""
	}

	content, err := os.ReadFile(filePath)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(content))
}
&x% 100644 logger.go ͅmiT̿7Ogߵ,Cx?package securityaudit

import (
	"context"
	"encoding/json"
	"log/slog"

	"github.com/jackc/pgx/v5/pgxpool"
)

const (
	ActionLogin            = "login"
	ActionTokenRejected    = "token_rejected"
	ActionAdminAction      = "admin_action"
	ActionCriticalDeletion = "critical_deletion"
)

type Logger struct {
	db     *pgxpool.Pool
	logger *slog.Logger
}

func NewLogger(db *pgxpool.Pool) *Logger {
	return &Logger{
		db:     db,
		logger: slog.Default().With("component", "security-audit"),
	}
}

func (l *Logger) Log(ctx context.Context, actor, action string, details map[string]any) {
	if l == nil || l.db == nil {
		return
	}
	if actor == "" {
		actor = "system"
	}
	if details == nil {
		details = map[string]any{}
	}

	detailsJSON, err := json.Marshal(details)
	if err != nil {
		l.logger.Error("marshal audit details", "error", err)
		return
	}

	if _, err := l.db.Exec(ctx, `
		INSERT INTO audit_logs (actor, action, details) VALUES ($1, $2, $3)
	`, actor, action, detailsJSON); err != nil {
		l.logger.Error("insert audit log", "error", err, "actor", actor, "action", action)
	}
}
mWs!x100644 000001_init.down.sql ѸEivGg8Y100644 000001_init.up.sql YYQ]Ym_+100644 000002_mail.down.sql 9YߕZ7c*100644 000002_mail.up.sql fO>i8+100644 000003_admin.down.sql "A
z8JFSL Z100644 000003_admin.up.sql ٟw#
NȔZN":100644 000004_mail_credentials_encryption.down.sql ՛b)[˭<i3'100644 000004_mail_credentials_encryption.up.sql Y~ u->K~,100644 000005_data_integrity.down.sql [t!"\"
4100644 000005_data_integrity.up.sql qjTbD-_
\Oɶx yDROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS users;
DROP EXTENSION IF EXISTS "pgcrypto";
DROP EXTENSION IF EXISTS "uuid-ossp";
J';)xN0<d/޼yTD!0]hnwY<Q{0GN<cr.8vޘaq?C[:ѐ6{N@Q9OYk@1+te|c,ږ.\=x,E]mՎ</^.'䚛xRt?"6#^Q!kr^c9$ ܏EYS~@\L xSDROP TRIGGER IF EXISTS messages_search_trigger ON messages;
DROP FUNCTION IF EXISTS messages_search_vector_update();
DROP TABLE IF EXISTS webhook_logs;
DROP TABLE IF EXISTS webhook_templates;
DROP TABLE IF EXISTS outbox;
DROP TABLE IF EXISTS mail_rules;
DROP TABLE IF EXISTS attachments;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS mail_folders;
DROP TABLE IF EXISTS mail_identities;
DROP TABLE IF EXISTS mail_accounts;
xECREATE TABLE mail_accounts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL DEFAULT '',
    email TEXT NOT NULL,
    provider TEXT NOT NULL DEFAULT 'imap',
    imap_host TEXT NOT NULL DEFAULT '',
    imap_port INT NOT NULL DEFAULT 993,
    imap_tls BOOLEAN NOT NULL DEFAULT true,
    smtp_host TEXT NOT NULL DEFAULT '',
    smtp_port INT NOT NULL DEFAULT 587,
    smtp_tls BOOLEAN NOT NULL DEFAULT true,
    credentials BYTEA,
    last_sync_at TIMESTAMPTZ,
    sync_state JSONB NOT NULL DEFAULT '{}',
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_mail_accounts_user ON mail_accounts(user_id);

CREATE TABLE mail_identities (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
    email TEXT NOT NULL,
    name TEXT NOT NULL DEFAULT '',
    is_default BOOLEAN NOT NULL DEFAULT false,
    signature_html TEXT NOT NULL DEFAULT '',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_mail_identities_account ON mail_identities(account_id);

CREATE TABLE mail_folders (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    remote_name TEXT NOT NULL,
    folder_type TEXT NOT NULL DEFAULT 'custom',
    uidvalidity BIGINT NOT NULL DEFAULT 0,
    highest_modseq BIGINT NOT NULL DEFAULT 0,
    message_count INT NOT NULL DEFAULT 0,
    unread_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(account_id, remote_name)
);

CREATE INDEX idx_mail_folders_account ON mail_folders(account_id);

CREATE TABLE messages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
    folder_id UUID NOT NULL REFERENCES mail_folders(id) ON DELETE CASCADE,
    message_id TEXT NOT NULL DEFAULT '',
    thread_id UUID,
    uid BIGINT NOT NULL DEFAULT 0,
    subject TEXT NOT NULL DEFAULT '',
    from_addr JSONB NOT NULL DEFAULT '[]',
    to_addrs JSONB NOT NULL DEFAULT '[]',
    cc_addrs JSONB NOT NULL DEFAULT '[]',
    bcc_addrs JSONB NOT NULL DEFAULT '[]',
    reply_to JSONB NOT NULL DEFAULT '[]',
    date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    snippet TEXT NOT NULL DEFAULT '',
    body_text TEXT NOT NULL DEFAULT '',
    body_html TEXT NOT NULL DEFAULT '',
    flags TEXT[] NOT NULL DEFAULT '{}',
    labels TEXT[] NOT NULL DEFAULT '{}',
    has_attachments BOOLEAN NOT NULL DEFAULT false,
    raw_size INT NOT NULL DEFAULT 0,
    in_reply_to TEXT NOT NULL DEFAULT '',
    references_header TEXT[] NOT NULL DEFAULT '{}',
    search_vector tsvector,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_messages_account_folder ON messages(account_id, folder_id);
CREATE INDEX idx_messages_thread ON messages(thread_id);
CREATE INDEX idx_messages_date ON messages(date DESC);
CREATE INDEX idx_messages_message_id ON messages(message_id);
CREATE INDEX idx_messages_flags ON messages USING GIN(flags);
CREATE INDEX idx_messages_labels ON messages USING GIN(labels);
CREATE INDEX idx_messages_search ON messages USING GIN(search_vector);
CREATE UNIQUE INDEX idx_messages_uid ON messages(folder_id, uid);

-- Auto-update search_vector on insert/update
CREATE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
BEGIN
    NEW.search_vector := 
        setweight(to_tsvector('simple', COALESCE(NEW.subject, '')), 'A') ||
        setweight(to_tsvector('simple', COALESCE(NEW.from_addr::text, '')), 'B') ||
        setweight(to_tsvector('simple', COALESCE(NEW.to_addrs::text, '')), 'B') ||
        setweight(to_tsvector('simple', COALESCE(NEW.body_text, '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER messages_search_trigger
    BEFORE INSERT OR UPDATE OF subject, from_addr, to_addrs, body_text
    ON messages
    FOR EACH ROW
    EXECUTE FUNCTION messages_search_vector_update();

CREATE TABLE attachments (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    filename TEXT NOT NULL DEFAULT '',
    content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
    size BIGINT NOT NULL DEFAULT 0,
    s3_bucket TEXT NOT NULL DEFAULT 'mail-attachments',
    s3_key TEXT NOT NULL DEFAULT '',
    content_id TEXT NOT NULL DEFAULT '',
    is_inline BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_attachments_message ON attachments(message_id);

CREATE TABLE mail_rules (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    account_id UUID REFERENCES mail_accounts(id) ON DELETE CASCADE,
    name TEXT NOT NULL DEFAULT '',
    priority INT NOT NULL DEFAULT 0,
    is_active BOOLEAN NOT NULL DEFAULT true,
    conditions JSONB NOT NULL DEFAULT '[]',
    actions JSONB NOT NULL DEFAULT '[]',
    llm_config JSONB,
    match_count BIGINT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_mail_rules_user ON mail_rules(user_id);
CREATE INDEX idx_mail_rules_priority ON mail_rules(user_id, priority);

CREATE TABLE webhook_templates (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    url TEXT NOT NULL,
    method TEXT NOT NULL DEFAULT 'POST',
    headers JSONB NOT NULL DEFAULT '{}',
    body_template TEXT NOT NULL DEFAULT '',
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_templates_user ON webhook_templates(user_id);

CREATE TABLE webhook_logs (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    template_id UUID NOT NULL REFERENCES webhook_templates(id) ON DELETE CASCADE,
    message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
    status_code INT,
    response_body TEXT NOT NULL DEFAULT '',
    error TEXT NOT NULL DEFAULT '',
    duration_ms INT NOT NULL DEFAULT 0,
    executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_logs_template ON webhook_logs(template_id);
CREATE INDEX idx_webhook_logs_executed ON webhook_logs(executed_at DESC);

CREATE TABLE outbox (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    account_id UUID NOT NULL REFERENCES mail_accounts(id) ON DELETE CASCADE,
    identity_id UUID REFERENCES mail_identities(id) ON DELETE SET NULL,
    to_addrs JSONB NOT NULL DEFAULT '[]',
    cc_addrs JSONB NOT NULL DEFAULT '[]',
    bcc_addrs JSONB NOT NULL DEFAULT '[]',
    subject TEXT NOT NULL DEFAULT '',
    body_text TEXT NOT NULL DEFAULT '',
    body_html TEXT NOT NULL DEFAULT '',
    in_reply_to TEXT NOT NULL DEFAULT '',
    references_header TEXT[] NOT NULL DEFAULT '{}',
    attachments JSONB NOT NULL DEFAULT '[]',
    scheduled_at TIMESTAMPTZ,
    sent_at TIMESTAMPTZ,
    status TEXT NOT NULL DEFAULT 'draft',
    error TEXT NOT NULL DEFAULT '',
    retry_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_outbox_user ON outbox(user_id);
CREATE INDEX idx_outbox_status ON outbox(status) WHERE status IN ('queued', 'sending');
CREATE INDEX idx_outbox_scheduled ON outbox(scheduled_at) WHERE scheduled_at IS NOT NULL AND status = 'queued';
p ix! DROP TABLE IF EXISTS audit_logs;
	xjCREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    actor TEXT NOT NULL,
    action TEXT NOT NULL,
    details JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_logs_actor ON audit_logs(actor);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC);
G}xa ALTER TABLE mail_accounts
    DROP CONSTRAINT IF EXISTS mail_accounts_credentials_encrypted_chk;
i x 1ALTER TABLE mail_accounts
    ADD CONSTRAINT mail_accounts_credentials_encrypted_chk
    CHECK (
        credentials IS NULL
        OR substring(credentials from 1 for 5) = 'UMC1|'::bytea
    ) NOT VALID;
X=AxALTER TABLE outbox
    DROP CONSTRAINT IF EXISTS outbox_retry_count_nonnegative_chk;

ALTER TABLE webhook_logs
    DROP CONSTRAINT IF EXISTS webhook_logs_duration_nonnegative_chk;

ALTER TABLE messages
    DROP CONSTRAINT IF EXISTS messages_raw_size_nonnegative_chk;

ALTER TABLE attachments
    DROP CONSTRAINT IF EXISTS attachments_size_nonnegative_chk;

ALTER TABLE mail_rules
    DROP CONSTRAINT IF EXISTS mail_rules_account_user_fk;

ALTER TABLE outbox
    DROP CONSTRAINT IF EXISTS outbox_account_user_fk;

ALTER TABLE messages
    DROP CONSTRAINT IF EXISTS messages_folder_account_fk;

DROP INDEX IF EXISTS uq_mail_folders_id_account;
DROP INDEX IF EXISTS uq_mail_accounts_id_user;
DROP INDEX IF EXISTS idx_outbox_account_status;
DROP INDEX IF EXISTS idx_outbox_status_created;
DROP INDEX IF EXISTS idx_webhook_templates_user_active;
DROP INDEX IF EXISTS idx_mail_rules_user_active_priority;
DROP INDEX IF EXISTS idx_messages_folder_date;
DROP INDEX IF EXISTS idx_messages_account_date;
DROP INDEX IF EXISTS idx_mail_accounts_user_created;
QZwxpCREATE INDEX IF NOT EXISTS idx_mail_accounts_user_created
    ON mail_accounts(user_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_messages_account_date
    ON messages(account_id, date DESC);

CREATE INDEX IF NOT EXISTS idx_messages_folder_date
    ON messages(folder_id, date DESC);

CREATE INDEX IF NOT EXISTS idx_mail_rules_user_active_priority
    ON mail_rules(user_id, is_active, priority);

CREATE INDEX IF NOT EXISTS idx_webhook_templates_user_active
    ON webhook_templates(user_id, is_active);

CREATE INDEX IF NOT EXISTS idx_outbox_status_created
    ON outbox(status, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_outbox_account_status
    ON outbox(account_id, status);

CREATE UNIQUE INDEX IF NOT EXISTS uq_mail_accounts_id_user
    ON mail_accounts(id, user_id);

CREATE UNIQUE INDEX IF NOT EXISTS uq_mail_folders_id_account
    ON mail_folders(id, account_id);

ALTER TABLE messages
    ADD CONSTRAINT messages_folder_account_fk
    FOREIGN KEY (folder_id, account_id)
    REFERENCES mail_folders(id, account_id)
    ON DELETE CASCADE
    NOT VALID;

ALTER TABLE outbox
    ADD CONSTRAINT outbox_account_user_fk
    FOREIGN KEY (account_id, user_id)
    REFERENCES mail_accounts(id, user_id)
    ON DELETE CASCADE
    NOT VALID;

ALTER TABLE mail_rules
    ADD CONSTRAINT mail_rules_account_user_fk
    FOREIGN KEY (account_id, user_id)
    REFERENCES mail_accounts(id, user_id)
    ON DELETE CASCADE
    NOT VALID;

ALTER TABLE attachments
    ADD CONSTRAINT attachments_size_nonnegative_chk
    CHECK (size >= 0) NOT VALID;

ALTER TABLE messages
    ADD CONSTRAINT messages_raw_size_nonnegative_chk
    CHECK (raw_size >= 0) NOT VALID;

ALTER TABLE webhook_logs
    ADD CONSTRAINT webhook_logs_duration_nonnegative_chk
    CHECK (duration_ms >= 0) NOT VALID;

ALTER TABLE outbox
    ADD CONSTRAINT outbox_retry_count_nonnegative_chk
    CHECK (retry_count >= 0) NOT VALID;
Vx	100644 README.md ]y@n,,100644 administration.md }g[g-7I100644 agenda.md 桂` zNi_100644 checklist-execution.md 3kAp9G?;o2100644 contacts.md >M[NK%`^)100644 definition-of-done.md r٦%?nG\V \100644 roadmap-3-phases.md 0,K4<B100644 ultidrive.md %F(9s,!x+Y100644 ultimail.md n£EONe+
100644 ultimaps.md sKHrP6v100644 ultimeet.md H(j%pSxf100644 ultiphotos.md Hk-Gt-4Qxw# Ulti Suite — Plan projet

Alternative souveraine et open-source à Google Suite. Chaque service est conçu pour être aussi fonctionnel et compatible avec les outils Google existants, en s'appuyant sur des technologies open-source éprouvées quand un acteur fiable existe déjà (ex: Nextcloud pour le stockage).

## Principes directeurs

- **Parité fonctionnelle** avec Google Suite, puis dépassement sur les features avancées (IA, webhooks, automatisations)
- **Compatibilité** — import/export natif des formats Google, migration progressive sans friction
- **Souveraineté** — hébergeable en propre, données sous contrôle de l'utilisateur
- **UX uniforme** — même expérience web, desktop (Tauri) et mobile
- **Interopérabilité interne** — tous les services communiquent entre eux nativement
- **Open-source first** — s'appuyer sur des briques existantes fiables plutôt que tout réinventer

## Services

| Service | Équivalent Google | Fichier plan | Statut |
|---------|-------------------|--------------|--------|
| Ultimail | Gmail | [ultimail.md](ultimail.md) | Partiel (backend avancé, incomplet) |
| Contacts | Google Contacts | [contacts.md](contacts.md) | Partiel (API proxy CardDAV) |
| Agenda | Google Calendar | [agenda.md](agenda.md) | Partiel (API proxy CalDAV) |
| Ultidrive | Google Drive | [ultidrive.md](ultidrive.md) | Partiel (API proxy WebDAV/OCS) |
| Ultimeet | Google Meet | [ultimeet.md](ultimeet.md) | Partiel (JWT Jitsi minimal) |
| Administration | Google Admin | [administration.md](administration.md) | Partiel (squelette API) |
| Ultiphotos | Google Photos | [ultiphotos.md](ultiphotos.md) | Partiel (proxy Immich minimal) |
| Ultimaps | Google Maps | [ultimaps.md](ultimaps.md) | Non commencé |

## Architecture commune

```
┌─────────────────────────────────────────────┐
│  Clients (web / Tauri / mobile)             │
├─────────────────────────────────────────────┤
│  API Gateway / Auth unifiée                 │
├──────┬──────┬──────┬──────┬──────┬──────────┤
│ Mail │Drive │Meet  │Agenda│Photos│ ...      │
│      │      │      │      │      │          │
├──────┴──────┴──────┴──────┴──────┴──────────┤
│  Services partagés                          │
│  ├─ Auth & comptes (SSO, 2FA, OIDC)        │
│  ├─ Contacts (carnet unifié)               │
│  ├─ Notifications (push, mail, webhooks)   │
│  ├─ Recherche transversale                 │
│  └─ Administration & quotas                │
├─────────────────────────────────────────────┤
│  Stockage                                   │
│  ├─ PostgreSQL (métadonnées, config, auth) │
│  ├─ Object storage (fichiers, médias)      │
│  └─ Cache (Redis)                          │
└─────────────────────────────────────────────┘
```

## Exécution

- Checklist opérationnelle complète: [checklist-execution.md](checklist-execution.md)
- Roadmap officielle 3 phases: [roadmap-3-phases.md](roadmap-3-phases.md)
- Definition of Done (backend/frontend/sécurité/tests/observabilité): [definition-of-done.md](definition-of-done.md)
A+﹚x	V# Administration

**Équivalent** : Google Admin Console
**Statut** : Partiel (squelette API)

---

## Résumé

Console d'administration centralisée pour gérer utilisateurs, organisations, quotas, sécurité et politiques de tous les services Ulti Suite.

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API backend montée sous `/api/v1/admin`.
- Endpoints list/get users, set quota, delete user, list audit, stats globales.
- Table `audit_logs` et logs de base disponibles.

### Partiel / incomplet

- RBAC admin strict non finalisé.
- Gestion utilisateurs limitée (pas de create/invite/disable/reactivate).
- Quotas essentiellement orientés mail, pas multi-service complet.

### Non commencé

- Groupes, unités organisationnelles et politiques avancées.
- Provisioning SCIM.
- Gouvernance sécurité complète (sessions actives, revocation, politiques avancées).

## Points de différenciation vs Google Admin

- Interface unifiée pour tous les services (pas de consoles séparées)
- Politiques granulaires par groupe/utilisateur/service
- Audit log complet avec export et alertes
- Auto-hébergeable avec support multi-tenant
- API admin complète (automatisation de la gestion)

## Fonctionnalités

### Utilisateurs & organisations
- [ ] CRUD utilisateurs
- [ ] Groupes et unités organisationnelles
- [ ] Rôles et permissions (RBAC)
- [ ] SSO (SAML, OIDC)
- [ ] 2FA obligatoire / optionnel par groupe
- [ ] Provisioning SCIM

### Quotas & stockage
- [ ] Quotas par utilisateur / groupe / service
- [ ] Dashboard utilisation (mail, drive, meet)
- [ ] Alertes de dépassement
- [ ] Politiques de rétention

### Sécurité
- [ ] Audit log (qui a fait quoi, quand)
- [ ] Alertes sécurité (connexions suspectes, brute force)
- [ ] Sessions actives et révocation
- [ ] Politiques de mot de passe
- [ ] IP whitelist / blacklist

### Services
- [ ] Activation / désactivation de services par groupe
- [ ] Configuration globale par service
- [ ] Domaines mail et DNS (SPF, DKIM, DMARC)
- [ ] Gestion des tokens API et webhooks
- [ ] Monitoring et health checks

### Multi-tenant
- [ ] Organisations isolées
- [ ] Branding personnalisé par organisation
- [ ] Facturation et plans

## Briques technologiques envisagées

| Besoin | Option open-source |
|--------|--------------------|
| Auth / SSO | Keycloak, Authentik, Zitadel |
| RBAC | Casbin, OPA |
| Audit | Temporal, custom event store |
| Monitoring | Prometheus + Grafana |
gxXˎWŐc+,ʡ1bf$LHjUu13Z0EA^^$VUcedAd[yzK<߿r3	Re&=IeK_θ.Xo<B[<Gp8G~gz"CKN4</KeF+nZJB/x"X*4`*!	K2ׂ-ª<kYƙQ-ʃLܔIl:rs,="Hw~Gnd}~ĎX#L^cΓWZSa1/d"N׬gdcI)7]-<-6,.3dhƣD^D)>$(Ġ\KxʒOʌa;[: 7-;3	[
7x2Ye"!5,Ӿ}2Hð
$jǐ?qbbCAaj
ޜ}`ifKVo׉.UWJ2,xSH|
']_5]ỵQu:#%S_}|LB2_ZNG_)%"VDGBϳR6K:7w[|)u:PV7e
aQ?vߡ#v>/#Ձ(:9h"C)#;<& WSij)گ=jJ^!My7JUN I֔[ʸPQv0%,f$He@GD#%B jEw]U*D:1t)]l
=:PEtR(6a>.R0@2ILz݉襇:]*J*W<0Z)ߐ_	 ,rp͕C SHpTȪ0:T$+S.&PE@d$VyN'@2#M  \f /19Ya90gڋ춁}TAV!NUYk~Yύ"Ll:ivY8IjZM_n粴)]s76 /m3K	xf-0v
vSz@TM+	{Il`^.m}1 9P8N ҌOQx`XQFFV[] Dv"^?:
Ji.U|ϕ/ 95DRXfCkW虣2pmr=o;$:.Mv:²K7\;%?uBz-tf#4VGmCц!L Ezi <K>}i=4+c f[x✼ӞA[cc1l{Xi_T|KBxG
yeW7M&q?l[H&0E `!d3AFĕNEyD@ؕ+s53fh;l>{!NTb-%ٳ1^Oq,鹉P~_pndv~qâ!"ozRmA݂L}~EFG52:up aVrt&_K*;Ǔդ^D2Dq)& ++^fKyTt} .5D8cny좈6. ~l{>T˫}n\\׃Ê*{$-F3ٰJ)HI&8Q_]MΎ'9^fũSұa

49̝Mڑ؞@_	([-A"Ϗ.NDx["J0G#piTI
Ns81?4H%>2Re7{f	:ӠF7`;ù'p"6vڳg@1Հha1SE8[H>*=]FM}ʜk"nO\;^G4't#lVsuDQI2ZiGB!wOt&AT0-cS4?gE\g7W%:zвDX4>ku>Ŭ}>Uk0_[T޿Ro4qZ,TzxlN UszC.4&jPϧ=#ah`_]:\Zqՙ[	%(`4kP^&'~*"ʒ&`[hpXFaU-&k={mI =J+McܽԪ;I$I&xbɻċ}),4ԆMmhM-fz'ODۆK1K΁B XY|z|쨽3Gj&+	Vطx}Zr#Gv+2ءPl)
	RddJ .TUgVB1	o+ğ̗ܛ*-B>=f>yŤѵJ?"RnӦe!rUϵ;ZϿfײ2^%MU*j==nb)3)ezʦ'OW}}٠C!TBzQA2{{Co.T]%6?Kij+2,OL1-xXH.gǣU6VؘcP,[̔JTycJOnz7Khn!%Ϙ>8R&{l\jKKŔ&ı gP]9={6/GR8={JÌ\Sl%+Q:e^j)ߊ]yRLX`z>%+0M]͆!ߚ2V4',b\j_`Yk7GUd17eQ%k2$끨q;:șfYOMA6R+]ULjfU7į{.n?_O0	j-E~XEMJXvޏGumurZt]Xӭ1[//qfЯνVk9Oowws#!ZWdϝ77u|wƬ/^(p{5mYd^ʊ^9ʺK߹eNM{Tʬ4TpUUb'#ڈL4+ؖ%[S[a+'t~+l~i&]$fLYE\3:DV:Y?O4}V NZ/6-uo*&*ğ4s`>R6,u-˅.a%XbqJv{kl-C;n;m>E:` j
уf*>!)bBzPŲK .`һy~J=jۀ̣ڏ '89;H&gW!zǓGg@|Tw0y"m]-J:.Q{&
6dNGXO`u+EQ(SkkAwU;+Ȩ5 _Ga*míE袢׻<*y#B~Qqvrv<9=tmMMT]|$HR?\A#@ܕ5TPa$ejqS(
- B֘Ega *Jwp ,C
b(.g4&\*zKJP0~?/>5 <&2X%wQ.2uK҄mv!a?uQ!~?TyID&`p,A`3*VxuVpbtYSKtꥂT{	0 ~WPFYy۱&M!,xLT2;-*?hKiIˏp4/	TCΠYIȹ,$|O* 'a581">,/?5oC׷Bh*(T1­ejT7ke{3Mi>qϮ/$lec_*qꔒkϋ b])3KFARސȠ!O~V˲9Iت,-;i:49G1[FO(R	`Me[X EO~ž627k+ٸnL{mh	{}͒q"	0K߿_*W:s~oʧmsַ569! L$߱:F,@tH$A?8M5;LycGT[Na!tű1Frr%%vө{ZhL抛u@~S]NS>!*' sH$h%!X	%q[7  C^SQTJ2 ?肠'I!k$h;HVabmD.Ǡ2;>T{Iec#Kpâ6P%5LֈpKD#{\31ε*.Ĝ	=Ú#Q?D3HT4caBƟ%j"M$嘿Wg"F./cVbC9O٣#r6wHnI))ADO2\sI<~p煘P`<T]&/#]g1`̕{''㓫3;OߑF,qQ`5>ǕMKSwFH1=cՄKǭ&7kEt-aL4?aa&{ &0.Jta&-cהuHWzJe
i$+QTUaЏ#=@2%x$DIFdD%Ed3مDL\L
B(iF$rbD->$^Z;|yzY lyAG0؇mћZ138gUGUzdG ny}߲AO,Ve	tD%Fͼl5Omx8b&6:fFnm{CDEa!cU5\ۺZvд{r)H!w$״8Azlw	>ME="j:jM摵xnpvx3ji|n{E<L0Tw363V_{MK<Mvɛ%ǟz&2laȕUW rnכ{WDn\GroT>3?FǒS0ZB;?.l\A A+r$Ze_ E5}F-;hsjtz|\9i'D1nہS:q8{>p3]ryȠY(B#6fXR/9(g
F+|ə"rKh;%JY1ꮫo!A[:SeC.yrG@w\HdY1@SFwe۝N	lKyI> ScɟS*wl)k:J DFR-뀰?_Er2QޞZk_ذ	]]C)0-O8ʀlqK^gKʱƎ)o?!j<e܄v
C6cm`|b9!^Fkv
kje?\a6D6`ȢUevsx^fH3Sb0):[DX\Mv{[vǊV2H	?1\̡WLRBEeWtBnDd&?"*	SrGKNyxe>k v9\uH=R!$3=D۶{<|`bbv~	uE`CG|<\lҤb?&9R$X$K kger7]C90ѪP=z9zXQN,hƑYx$j4vIUOdB$LVPX;9_|c2Vdʳj &QqtxR\@X%^TxGԾϬ*I%=&.ĕ,&5w1ETW	2fpAHͫs婁DFU먀7Fd.;B||+4OKȈKJwpP}(س%:'>l1ыlyKPa`86hPj>2h@β6%W<ݪ\=Rzῌ1PZH;v%Bk%'z+:x_'M&w|(,`esd&6p7q:{fH<qYU]c,BMGm*ҕo] c#O
Ra%W<SqXwDh*5(.%ȠnOd;g[/ILJ#|kfrnAV'`НŴYHf+u|+`}ޕ1+
 I[tvf:u\A5uj+q_C>>0qw5τהc@	ݣӷ<vGYjUaA)tM9s3)"|re]5׷Fj_ݍ0Y<}@`\K"Ot3T"7ջ(|O+<?PKyDNUǠwI%)WB4IcXe'jdU392Ŝ]+ǋГ٧lM?Z+~gaytXI1l`E)3s3	\SQ%f>e"s)*"\-(!?,+Q|T<:vl*n2	p|t\XYP\C~hr2viٙ뾠To;B̼X]A:VNw[l!s̏""L
VP!-JJ 实zypEp`b&q,$tXM%u5(	
lOxn&|~s^WM-ܑ=| ^ذ
jiKr7-;"./ywܴs}F0z~ĻJLw
|!t%T"Xs:%]O:>wjE&R]tk;s1ds$W:/8V":W}͒W2j1-^LCN:̆oS5g[0zt|Մ(๭>pˮxa_pKJO=E. \}ZA+tn,a@;';g=A<WTvEGvtӆB!;7_}}"Stk[;}mqJH|Okli8;fKjxBGٷPs01e:sh!IhxA`[a?S=}8 4	K豋FzjNa(ތq1e.4qI5A{A.Huz vОpc89?x2# Contacts

**Équivalent** : Google Contacts
**Statut** : Partiel (API proxy CardDAV)

---

## Résumé

Carnet d'adresses unifié partagé entre tous les services Ulti Suite. Gère les contacts personnels, professionnels, groupes, et synchronisation avec sources externes (CardDAV, Google, etc.).

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API backend montée sous `/api/v1/contacts` (si Nextcloud activé).
- Endpoints list carnets, list contacts, create contact, delete contact, search.
- Client CardDAV Nextcloud intégré côté serveur.

### Partiel / incomplet

- Pas d'update contact avec ETag.
- Pas de sync incrémentale via sync-token.
- Recherche globale suite ne s'appuie pas encore pleinement sur CardDAV.

### Non commencé

- Import/export vCard et CSV côté API produit final.
- Fusion de doublons côté backend.
- Enrichissement interactions (mails/réunions/fichiers) en API contact.

## Points de différenciation vs Google Contacts

- Carnet unifié cross-services natif (mail, agenda, meet, drive)
- Import/export CardDAV bidirectionnel
- Champs personnalisés illimités et typés
- Fusion intelligente de doublons (IA-assistée)
- Tags et groupes dynamiques (filtres auto-mis à jour)

## Fonctionnalités

### Core
- [ ] CRUD contacts (nom, emails, téléphones, adresses, organisations)
- [ ] Groupes / étiquettes
- [ ] Photo de profil
- [ ] Champs personnalisés
- [ ] Historique d'interactions (derniers mails, réunions, fichiers partagés)

### Sync & import
- [ ] Import/export vCard, CSV
- [ ] Sync CardDAV bidirectionnelle
- [ ] Import depuis Google Contacts
- [ ] Détection et fusion de doublons
- [ ] Auto-complétion depuis l'historique mail

### Intégration suite
- [ ] Suggestions dans Ultimail (compose, destinataires)
- [ ] Disponibilité agenda dans la fiche contact
- [ ] Fichiers partagés (Ultidrive) dans la fiche
- [ ] Lien direct vers Ultimeet

## Intégration Nextcloud — étude technique

### APIs CardDAV disponibles (confirmé)

Même instance Nextcloud qu'Ultidrive et Agenda. Endpoints :

| Opération | Méthode | Endpoint |
|-----------|---------|----------|
| Lister carnets | PROPFIND | `/remote.php/dav/addressbooks/users/{user}/` |
| Lister contacts | REPORT (addressbook-query) | `.../addressbooks/users/{user}/{book}/` |
| Créer contact | PUT | `.../addressbooks/users/{user}/{book}/{uid}.vcf` |
| Modifier contact | PUT | Même (avec If-Match etag) |
| Supprimer contact | DELETE | Même |
| Sync incrémentale | REPORT (sync-collection) | Via sync-token |
| Recherche | REPORT (addressbook-query) | Filtres sur FN, EMAIL, TEL, ORG, etc. |

### Champs indexés (recherche rapide)

BDAY, UID, N, FN, TITLE, ROLE, NOTE, NICKNAME, ORG, CATEGORIES, EMAIL, TEL, IMPP, ADR, URL, GEO, CLOUD, X-SOCIALPROFILE

### Avantages

- Carnet d'adresses système auto-populé avec tous les utilisateurs NC
- Partage de carnets entre utilisateurs
- Interopérable avec clients CardDAV (Apple Contacts, DAVx5, Thunderbird)
- Même backend que fichiers et calendrier → une seule instance à maintenir

## Briques technologiques envisagées

| Besoin | Option retenue | Alternatives |
|--------|----------------|--------------|
| Backend CardDAV | Nextcloud sabre/dav (même instance) | Radicale, Baikal |
| Client CardDAV (JS) | tsdav (TypeScript) | fetch + XML custom |
| Parsing vCard | vcard4 ou ical.js | — |
| Recherche avancée | Meilisearch (indexation custom) | NC search intégré |
| Auth | Partagée via Ulti Suite (OIDC → NC) | — |
J}x%# Definition of Done (DoD)

Ce document définit le minimum à atteindre avant de considérer une livraison "done".

## Backend

- [ ] Spécification API et contrat DTO à jour.
- [ ] Validation payload + gestion erreurs normalisée.
- [ ] Ownership et permissions vérifiés sur mutations.
- [ ] Logs et métriques ajoutés pour endpoint/worker concerné.
- [ ] Migrations DB fournies si modèle impacté.
- [ ] Tests d'intégration ajoutés sur cas nominal + erreurs clés.
- [ ] Documentation d'exploitation mise à jour (config, limites, dépendances).

## Frontend

- [ ] Flux UI branché sur backend réel (pas de mock en fallback silencieux).
- [ ] États loading/empty/error gérés proprement.
- [ ] Navigation URL/state cohérente, sans régression UX majeure.
- [ ] Accessibilité minimale respectée (clavier, libellés, focus).
- [ ] Tracking erreurs front en place (console propre, fallback utilisateur).
- [ ] Tests e2e ou tests ciblés ajoutés sur parcours modifié.

## Sécurité

- [ ] Aucune donnée sensible en clair (credentials/tokens) dans DB, logs, réponses API.
- [ ] Contrôles authn/authz testés (accès autorisé/refusé).
- [ ] Inputs externes validés et bornés (taille, type, format).
- [ ] Endpoints sensibles protégés contre abus (rate limit/idempotency si nécessaire).
- [ ] Surface WS/Webhook durcie (signature, token, origine, timeout).

## Tests

- [ ] Tests unitaires/intégration ajoutés pour la logique créée/modifiée.
- [ ] Cas d'échec et cas limites couverts.
- [ ] CI exécute automatiquement les tests pertinents.
- [ ] Aucune régression sur les parcours critiques connus.

## Observabilité

- [ ] request-id/correlation-id présent dans logs serveur.
- [ ] Métriques métier ajoutées (succès, erreurs, latence, queue depth selon besoin).
- [ ] Alertes pertinentes définies pour incidents probables.
- [ ] Healthcheck du composant concerné vérifiable en environnement cible.
- [ ] Dashboard ou vue de suivi disponible pour le run en production.
rx1# Roadmap officielle — 3 phases

Cette roadmap aligne les travaux backend + frontend avec l'état réel du projet.

## Cadre temporel (échelle relative)

- `duration+` = court
- `duration++` = moyen
- `duration+++` = long

## Owners nominaux par phase

| Phase | Durée relative | Owner nominal | Co-owners |
|---|---|---|---|
| Phase 1 — MVP fonctionnel | `duration++` | `@BE-Core` | `@FE-Lead`, `@PO` |
| Phase 2 — Hardening | `duration+` | `@DevOps` | `@Security`, `@BE-Platform`, `@QA` |
| Phase 3 — Différenciation | `duration+++` | `@PO` | `@BE-Integrations`, `@FE-Lead` |

## Phase 1 — MVP fonctionnel (priorité immédiate, `duration++`)

### Objectif

Rendre Ultimail utilisable de bout en bout avec un backend réel (plus de mock pour les parcours mail critiques).

### Livrables

- Corriger les blocants backend mail (ownership endpoints, bug outbox planifié, provisioning user OIDC).
- Finaliser pipeline IMAP/SMTP minimum viable (sync inbox, envoi immédiat, envoi planifié cohérent).
- Câbler rules + webhooks dans le flux de réception.
- Exposer endpoints manquants MVP (brouillons, pièces jointes, labels/dossiers de base).
- Brancher frontend mail sur API backend (lecture, actions, compose, recherche backend).
- Brancher multi-comptes réel côté frontend.

### Critères de sortie

- Parcours validés: connexion -> sync inbox -> lecture -> envoi immédiat -> planifié/replanifié/send-now.
- Règle simple + webhook exécutés réellement sur mail entrant.
- Frontend principal mail ne dépend plus des mocks pour ces parcours.

## Phase 2 — Hardening (production-ready, `duration+`)

### Objectif

Sécuriser, fiabiliser et observer la plateforme pour exploitation continue.

### Livrables

- Chiffrement credentials au repos, validation inputs stricte, RBAC admin.
- WS sécurisé par token (suppression `user_id` en query).
- Retries/backoff + DLQ pour outbox et webhooks.
- Observabilité complète (request-id, métriques, dashboards, alerting).
- Normalisation erreurs API + pagination cross endpoints.
- Couverture tests backend/CI (intégration handlers, workers, migrations) + tests e2e front parcours critiques.

### Critères de sortie

- SLO minimaux atteints en staging.
- CI bloque les regressions sur flux critiques.
- Aucun secret sensible en clair en base.

## Phase 3 — Différenciation (IA + automatisations avancées, `duration+++`)

### Objectif

Dépasser la parité Gmail/Google par les fonctions différenciantes Ulti Suite.

### Livrables

- Rules engine avancé (simulation, priorités, conflits, actions composables).
- Webhooks templates versionnés (preview, signature HMAC, retries et observabilité fine).
- Tri IA configurable par règle (provider, prompt, garde-fous coût/latence).
- Recherche globale multi-services (mail + contacts + agenda + drive).
- APIs fine-grained pour agents externes (tokens/scopes).

### Critères de sortie

- Un utilisateur peut configurer automatisations et IA sans code.
- Résultats traçables et monitorés en production.
- Recherche cross-services opérationnelle avec pertinence acceptable.

## Règle de priorisation

1. Corriger ce qui casse le produit (blocants/sécurité/cohérence).
2. Stabiliser le coeur Ultimail en réel.
3. Étendre puis différencier.
TXxL# Ultidrive

**Équivalent** : Google Drive
**Statut** : Partiel (API proxy WebDAV/OCS)

---

## Résumé

Frontend reproduisant à l'identique le comportement et l'interface de Google Drive, potentiellement comme frontend pour Nextcloud ou solution similaire déjà fonctionnelle.

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API backend montée sous `/api/v1/drive` (si Nextcloud activé).
- Endpoints list/upload/download/delete, create folder, move, create share.
- Client Nextcloud WebDAV/OCS intégré côté serveur.

### Partiel / incomplet

- Pas d'upload chunked gros fichiers.
- Couverture incomplète des cas drive (copy, corbeille, favoris, récents).
- Gestion quotas/permissions simplifiée non finalisée.

### Non commencé

- UX frontend Drive de production branchée backend réel.
- Édition collaborative docs/feuilles/présentations.
- Sync offline-first complète (résolution de conflits).

## Approche technique

L'idée est simple : on ne réinvente pas le stockage. On fournit une UX identique à Google Drive mais le backend de stockage est interchangeable :

| Mode de stockage | Description |
|------------------|-------------|
| **WebDAV** | Connecté à un serveur WebDAV (Nextcloud, ownCloud, etc.) |
| **S3-compatible** | Bucket S3, MinIO, Backblaze, etc. |
| **Nextcloud natif** | Ultidrive = frontend custom pour Nextcloud (API OCS/WebDAV) |

### Contrainte clé : montage local

Peu importe le backend, le stockage **doit pouvoir être monté comme un disque local** sur l'appareil de l'utilisateur (comme Google Drive ou Dropbox le permettent). Cela implique un client sync desktop (via Tauri ou en exploitant le client Nextcloud existant).

### Gestion des permissions

Les permissions doivent être **simples à gérer** — pas d'ACL complexes. Modèle inspiré de Google Drive :
- Propriétaire / Éditeur / Lecteur
- Partage par lien (public, restreint, expiration)
- Héritage des permissions parent (dossier → contenu)

### Option "frontend Nextcloud"

Si c'est plus simple, Ultidrive peut être **purement un frontend** pour Nextcloud :
- Nextcloud gère le stockage, la sync, les permissions, le versioning
- Ultidrive = interface custom (même codebase/stack que le reste de la suite)
- Avantage : on profite de l'écosystème Nextcloud (apps, clients, WebDAV) sans tout recoder

## Points de différenciation vs Google Drive

- Auto-hébergeable, stockage sous contrôle total
- Backend interchangeable (WebDAV / S3 / Nextcloud)
- Montage local natif comme un vrai disque
- Interface identique à Google Drive (migration sans friction)
- Chiffrement côté client optionnel (zero-knowledge)
- Automatisations à l'upload (OCR, classification IA, webhooks)

## Fonctionnalités

### Core
- [ ] Upload / download fichiers et dossiers
- [ ] Arborescence, favoris, récents, corbeille
- [ ] Recherche full-text (contenu des documents)
- [ ] Prévisualisation (images, PDF, vidéos, docs)
- [ ] Versioning (historique des versions)

### Partage & collaboration
- [ ] Partage par lien (public, protégé par mot de passe, expiration)
- [ ] Partage avec utilisateurs/groupes (lecture / écriture / admin)
- [ ] Édition collaborative temps réel (docs, tableurs, présentations)
- [ ] Commentaires sur fichiers

### Sync & montage
- [ ] Montage local comme disque (type Google Drive / Dropbox)
- [ ] Client desktop sync (Tauri ou Nextcloud client natif)
- [ ] WebDAV natif
- [ ] Sync sélective (dossiers choisis)
- [ ] Offline-first avec résolution de conflits

### Intégration suite
- [ ] Pièces jointes Ultimail sauvegardées dans Drive
- [ ] Fichiers joints aux événements Agenda
- [ ] Stockage médias Ultiphotos
- [ ] Partage dans Ultimeet (présentation)

## Intégration Nextcloud — étude technique

### APIs disponibles (confirmé)

| Opération | API Nextcloud | Endpoint |
|-----------|---------------|----------|
| Lister fichiers | WebDAV PROPFIND | `/remote.php/dav/files/{user}/` |
| Upload | WebDAV PUT | `/remote.php/dav/files/{user}/{path}` |
| Upload gros fichiers | Chunked v2 | `/remote.php/dav/uploads/{user}/` (chunks 5MB-5GB) |
| Download | WebDAV GET | `/remote.php/dav/files/{user}/{path}` |
| Créer dossier | WebDAV MKCOL | `/remote.php/dav/files/{user}/{path}` |
| Déplacer/copier | WebDAV MOVE/COPY | Headers Destination |
| Supprimer | WebDAV DELETE | Standard |
| Créer un partage | OCS POST | `/ocs/v2.php/apps/files_sharing/api/v1/shares` |
| Modifier partage | OCS PUT | `.../shares/{id}` |
| Partage public | WebDAV | `/public.php/dav` (depuis NC 29) |

### Permissions — mapping simple confirmé

Nextcloud utilise un système bitwise simple qui mappe directement sur notre modèle :

| Permission | Bit | Mapping Ultidrive |
|------------|-----|-------------------|
| Read | 1 | Lecteur |
| Update | 2 | Éditeur |
| Create | 4 | Éditeur |
| Delete | 8 | Éditeur |
| Share | 16 | Propriétaire |
| All | 31 | Propriétaire |

Types de partage supportés : user (0), group (1), public link (3), email (4), federated (6).
Options : `password`, `expireDate` (YYYY-MM-DD), `publicUpload`.

### Montage local — solution retenue : rclone

| Plateforme | Méthode | Dépendance |
|------------|---------|------------|
| Linux | `rclone mount` via FUSE | libfuse3 |
| macOS | `rclone mount` via macFUSE | macFUSE |
| Windows | `rclone mount` via WinFsp | WinFsp |

Avantages de rclone vs client Nextcloud Desktop :
- Plus stable (le VFS Nextcloud est expérimental sur Linux)
- Supporte WebDAV ET S3 avec la même commande
- VFS caching configurable (mode off/minimal/writes/full)
- Bundlable dans l'app Tauri comme sidecar

Stratégie pour Tauri : rclone embarqué en sidecar, lancé/arrêté par l'app, configuration gérée programmatiquement.

### Déploiement Nextcloud headless (API-only)

Nextcloud peut tourner en mode backend pur :
- Image Docker FPM (pas d'interface web servie directement)
- Reverse proxy qui route uniquement `/remote.php/dav`, `/ocs/`, `/public.php/dav`
- L'UI Nextcloud n'est jamais exposée aux utilisateurs — seul Ultidrive est le frontend

```
┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Ultidrive   │────▶│  Reverse Proxy   │────▶│  Nextcloud   │
│  (frontend)  │     │  (nginx)         │     │  (PHP-FPM)   │
└──────────────┘     └──────────────────┘     └──────────────┘
                            │                         │
                            │                    ┌────┴────┐
                     Routes exposées :           │ Storage │
                     /remote.php/dav/*           │ (S3/local)
                     /ocs/v2.php/*               └─────────┘
                     /public.php/dav/*
```

## Briques technologiques envisagées

| Besoin | Option retenue | Alternatives |
|--------|----------------|--------------|
| Backend fichiers | Nextcloud (PHP-FPM headless) | ownCloud Infinite Scale |
| Protocole sync | WebDAV (natif NC) | — |
| Object storage | MinIO / S3-compatible (via NC external storage) | Stockage local |
| Montage local | rclone (sidecar Tauri) | client NC Desktop |
| Édition collaborative | OnlyOffice, Collabora Online | — |
| Recherche contenu | Meilisearch (indexation custom) | NC full-text search app |
| Auth | Partagée via Ulti Suite (OIDC → NC) | — |
Gtx<# Ultimail

**Équivalent** : Gmail
**Statut** : Partiel (backend avancé, incomplet)

---

## Résumé

Client mail unifié multi-comptes avec backend de synchronisation. Un compte Ultimail gère plusieurs comptes mail (SMTP/IMAP/POP3) avec identités d'envoi/réception distinctes, catch-all, et libellés unifiés.

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API `/api/v1/mail` avec endpoints comptes, messages, threads, envoi, règles, webhooks.
- Workers backend IMAP sync et SMTP outbox démarrés au boot.
- Schéma SQL principal mail en place (accounts, messages, folders, rules, outbox, webhooks, attachments).

### Partiel / incomplet

- Outbox planifiée: incohérence de statuts `scheduled`/`queued`.
- Règles et webhooks: briques présentes mais pas entièrement branchées au pipeline réel de réception.
- Threading, pièces jointes et identités d'envoi encore incomplets.
- Realtime WS non finalisé côté auth/events.

### Non commencé

- Tri IA en production.
- Tokens API fine-grained pour agents externes.
- Expérience desktop Tauri/mobile branchée au backend réel.

## Points de différenciation vs Gmail

- Webhooks avancés avec système de templates personnalisables (Slack, Discord, custom)
- Connectivité contrôlée avec agents IA via tokens API fine-grained
- Tri intelligent par LLM (fournisseurs OpenAI-compatibles, prompt personnalisé par règle)
- Hébergement souverain, données sous contrôle
- Multi-comptes natif avec unification complète (libellés, dossiers, recherche cross-comptes)

## Fonctionnalités

### Core
- [ ] Réception / envoi de mails (IMAP/SMTP)
- [ ] Multi-comptes avec identités d'envoi/réception
- [ ] Catch-all (envoi et réception)
- [ ] Conversations / threads
- [ ] Libellés et dossiers unifiés
- [ ] Recherche avancée
- [ ] Pièces jointes
- [ ] Brouillons, envoi programmé
- [ ] Contacts intégrés

### Automatisations
- [ ] Règles de tri à la réception
- [ ] Forward automatique
- [ ] Réponses automatiques
- [ ] Webhooks avec templates
- [ ] Tri IA (LLM + prompt personnalisé)
- [ ] Tokens API fine-grained pour agents

### UX
- [ ] Interface inspirée Gmail (migration sans friction)
- [ ] Desktop web + Tauri + mobile — UX uniforme
- [ ] Thèmes, densité, préférences par compte
- [ ] Raccourcis clavier complets
- [ ] Offline-first avec sync backend

## Intégration avec la suite (Nextcloud)

Ultimail utilise sa **propre stack mail** (pas le module mail de Nextcloud qui est insuffisant) mais s'intègre avec l'instance Nextcloud partagée pour les services complémentaires :

### Ce qu'Ultimail utilise de Nextcloud

| Service | Usage dans Ultimail | API |
|---------|--------------------|----|
| **Ultidrive (WebDAV)** | Sauvegarder les pièces jointes dans Drive | PUT `/remote.php/dav/files/{user}/` |
| **Ultidrive (WebDAV)** | Insérer des fichiers Drive dans un mail | GET + lien de partage OCS |
| **Agenda (CalDAV)** | Détecter les invitations .ics et créer des événements | PUT `.../calendars/{user}/{cal}/{uid}.ics` |
| **Agenda (CalDAV)** | Afficher les événements du jour dans la sidebar mail | REPORT calendar-query |
| **Contacts (CardDAV)** | Autocomplétion destinataires depuis le carnet | REPORT addressbook-query |
| **Contacts (CardDAV)** | Enrichir les fiches contact avec l'historique mail | Sync bidirectionnelle |

### Ce qu'Ultimail NE délègue PAS à Nextcloud

- **IMAP/SMTP** — client mail custom (stalwart-mail, maddy, ou implémentation propre)
- **Stockage des mails** — PostgreSQL + object storage propre (pas la DB Nextcloud)
- **Règles de tri, automatisations, webhooks** — moteur custom dans le backend Ultimail
- **Recherche mail** — index dédié (Meilisearch/Typesense) distinct de la recherche NC
- **Auth mail** — OAuth2/App passwords gérés par le backend Ultimail, pas par NC

### Flux d'interaction

```
┌──────────────────────────────────────────────────────────┐
│                    Frontend Ultimail                       │
└────────────┬─────────────────────────────┬───────────────┘
             │                             │
             ▼                             ▼
┌────────────────────────┐    ┌────────────────────────────┐
│   Backend Ultimail     │    │   Nextcloud (headless)     │
│   ├─ IMAP/SMTP client  │    │   ├─ WebDAV (pièces jointes)
│   ├─ Moteur de règles  │───▶│   ├─ CalDAV (invitations)  │
│   ├─ Stockage mails    │    │   └─ CardDAV (contacts)    │
│   ├─ Webhooks/IA       │    └────────────────────────────┘
│   └─ API sync clients  │
└────────────────────────┘
```

Le backend Ultimail communique avec Nextcloud **côté serveur** pour :
- Créer un événement quand un mail contient une invitation .ics
- Résoudre les contacts pour l'affichage (photo, nom complet)
- Proposer "Sauvegarder dans Drive" pour les pièces jointes

## Briques technologiques envisagées

| Besoin | Option retenue | Alternatives |
|--------|----------------|--------------|
| IMAP/SMTP | stalwart-mail, maddy, ou client custom | — |
| Recherche full-text | Meilisearch, Typesense | — |
| Stockage mails | PostgreSQL + object storage (attachments) | — |
| Push/sync temps réel | WebSocket, SSE | — |
| Pièces jointes → Drive | Nextcloud WebDAV (même instance) | — |
| Calendrier (invitations) | Nextcloud CalDAV (même instance) | — |
| Contacts (autocomplétion) | Nextcloud CardDAV (même instance) | — |
| Auth | OIDC partagé Ulti Suite | — |

## Voir aussi

- [CLAUDE.md](../CLAUDE.md) — contexte technique détaillé du frontend actuel
- [ultidrive.md](ultidrive.md) — détails intégration Nextcloud fichiers
- [agenda.md](agenda.md) — détails intégration Nextcloud calendrier
- [contacts.md](contacts.md) — détails intégration Nextcloud contacts
- `components/gmail/README.md` — arborescence composants
- `lib/stores/README.md` — architecture stores
޹zxQ# Ultimaps

**Équivalent** : Google Maps
**Statut** : Non commencé

---

## Résumé

Service de cartographie et navigation avec données OpenStreetMap, itinéraires, points d'intérêt et intégration avec les autres services (agenda, contacts, drive).

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- Aucun composant backend/service dédié dans ce repository.

### Partiel / incomplet

- N/A à ce stade (pas de base de code maps existante).

### Non commencé

- API géocodage.
- API routing.
- Intégrations Agenda/Contacts/Photos.
- UI maps et capacité offline.

## Points de différenciation vs Google Maps

- Basé sur OpenStreetMap (données libres, contributives)
- Aucun tracking publicitaire ou profiling de déplacements
- Cartes offline complètes
- POI personnalisés et listes partagées entre utilisateurs
- API de géocodage auto-hébergeable

## Fonctionnalités

### Core
- [ ] Carte interactive (tuiles OSM)
- [ ] Recherche d'adresses et POI (géocodage)
- [ ] Itinéraires (voiture, transports, vélo, piéton)
- [ ] Navigation turn-by-turn
- [ ] Vue satellite (si source disponible)

### Organisation
- [ ] Lieux enregistrés / favoris
- [ ] Listes de lieux personnalisées
- [ ] Historique des lieux visités (opt-in)
- [ ] Partage de position temps réel (opt-in)

### Offline
- [ ] Téléchargement de zones pour usage hors-ligne
- [ ] Navigation offline
- [ ] Sync des lieux sauvegardés

### Intégration suite
- [ ] Lieu des événements Agenda (carte intégrée)
- [ ] Adresses des Contacts sur la carte
- [ ] Géolocalisation des photos (Ultiphotos)
- [ ] Partage de lieu via Ultimail / Ultimeet

## Briques technologiques envisagées

| Besoin | Option open-source |
|--------|--------------------|
| Données cartographiques | OpenStreetMap |
| Rendu tuiles | MapLibre GL, Protomaps |
| Géocodage | Nominatim, Pelias |
| Routing | OSRM, Valhalla, GraphHopper |
| Tuiles serveur | Tileserver GL, Martin |
\3xu# Ultimeet

**Équivalent** : Google Meet
**Statut** : Partiel (JWT Jitsi minimal)

---

## Résumé

Visioconférence et appels audio/vidéo intégrés à la suite, avec partage d'écran, chat, enregistrement et transcription.

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API backend montée sous `/api/v1/meet` (si Jitsi activé).
- Endpoints create room + token d'accès salle.
- Génération JWT côté serveur pour Jitsi.

### Partiel / incomplet

- Gestion modérateur basique seulement.
- Pas de persistance sessions/participants.
- Pas d'intégration complète agenda/mail sur le cycle de vie des réunions.

### Non commencé

- Enregistrement et stockage automatique dans Drive.
- Transcription et résumé IA.
- Tableau blanc, sondages et collaboration avancée.

## Points de différenciation vs Google Meet

- Basé sur Jitsi ou LiveKit (open-source, auto-hébergeable)
- Transcription et résumé automatique par IA
- Enregistrement stocké dans Ultidrive
- Salles persistantes avec historique
- Intégration profonde agenda + mail (rappels, notes, follow-up auto)

## Fonctionnalités

### Core
- [ ] Appels audio/vidéo (1:1 et groupe)
- [ ] Partage d'écran
- [ ] Chat en réunion
- [ ] Réactions et lever la main
- [ ] Arrière-plans virtuels
- [ ] Réduction de bruit

### Enregistrement & IA
- [ ] Enregistrement vidéo (stocké dans Ultidrive)
- [ ] Transcription temps réel
- [ ] Résumé automatique post-réunion
- [ ] Détection des actions items

### Collaboration
- [ ] Tableau blanc partagé
- [ ] Notes collaboratives en réunion
- [ ] Sondages en direct
- [ ] Sous-titres multi-langues

### Intégration suite
- [ ] Lien auto dans les événements Agenda
- [ ] Invitation par Ultimail
- [ ] Fichiers partagés depuis Ultidrive pendant l'appel
- [ ] Résumé envoyé par mail post-réunion
- [ ] Participants depuis Contacts

## Briques technologiques envisagées

| Besoin | Option open-source |
|--------|--------------------|
| WebRTC / SFU | Jitsi, LiveKit, mediasoup |
| Transcription | Whisper (OpenAI), Vosk |
| Résumé IA | LLM OpenAI-compatible |
| Stockage enregistrements | Ultidrive / MinIO |

xx	n# Ultiphotos

**Équivalent** : Google Photos
**Statut** : Partiel (proxy Immich minimal)

---

## Résumé

Gestion de photos et vidéos avec stockage cloud, organisation intelligente (IA), partage et albums collaboratifs. Stockage sur Ultidrive.

## État d'implémentation réel (mai 2026)

### Déjà implémenté

- API backend montée sous `/api/v1/photos` (si Immich activé).
- Endpoints list assets, upload, thumbnail, delete asset, list albums.
- Client Immich côté serveur intégré.

### Partiel / incomplet

- Gestion albums incomplète (list seulement).
- Mapping auth/api key à fiabiliser selon intégration Immich cible.
- Pas de chaînage complet quota/storage avec Drive.

### Non commencé

- Fonctions IA photos (visages, scènes, souvenirs) côté produit Ulti.
- Partage avancé albums avec permissions granulaires finalisées.
- Expérience frontend photos de production branchée backend réel.

## Points de différenciation vs Google Photos

- Stockage sur infrastructure propre (pas de compression forcée)
- Classification IA auto-hébergeable (pas d'envoi à un tiers)
- Albums partagés avec permissions granulaires
- Export complet sans perte (original toujours conservé)
- Intégration native Ultidrive (même quota, même stockage)

## Fonctionnalités

### Core
- [ ] Upload photos / vidéos (originaux conservés)
- [ ] Galerie chronologique
- [ ] Albums manuels et albums intelligents (auto-générés)
- [ ] Recherche par date, lieu, personnes, objets
- [ ] Favoris, archive, corbeille

### Organisation IA
- [ ] Détection de visages et regroupement par personne
- [ ] Classification par scène / objet
- [ ] Géolocalisation et carte
- [ ] Souvenirs / "Ce jour-là" automatiques
- [ ] Suggestions de partage

### Édition
- [ ] Retouche basique (crop, rotation, filtres, luminosité)
- [ ] Suggestions d'amélioration IA

### Partage
- [ ] Albums partagés (lecture / contribution)
- [ ] Lien de partage (public, protégé, expiration)
- [ ] Partage vers Ultimail, Ultimeet

### Sync
- [ ] Sync automatique depuis mobile (app)
- [ ] Sync depuis desktop
- [ ] Dossiers Ultidrive comme source

## Briques technologiques envisagées

| Besoin | Option open-source |
|--------|--------------------|
| Gestion photos | Immich, LibrePhotos, PhotoPrism |
| Reconnaissance faciale | InsightFace, dlib |
| Classification | CLIP, BLIP |
| Stockage | Ultidrive / MinIO |
| Métadonnées | PostgreSQL + EXIF parsing |
mWfqB̿q2+9