123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162(* Claude Code
*
* Copyright (C) 2026 Yoann Padioleau
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public License
* (LGPL) as published by the Free Software Foundation; either version
* 2 of the License, or (at your option) any later version.
*)(* See Smtp.mli *)(*****************************************************************************)(* Replies and commands *)(*****************************************************************************)letreply_line(line:string):(int*bool*string)option=letn=String.lengthlineinifn>=3thenmatchint_of_string_opt(String.subline03)with|Somecodewhenn=3->Some(code,false,"")|Somecodewhenline.[3]=' '||line.[3]='-'->Some(code,line.[3]='-',String.subline4(n-4))|_->NoneelseNoneletreply(code:int)(lines:stringlist):stringlist=letk=List.lengthlinesinList.mapi(funil->Printf.sprintf"%d%c%s"code(ifi<k-1then'-'else' ')l)linestypecommand=Heloofstring|Ehloofstring|Mail_fromofstring|Rcpt_toofstring|Data|Rset|Noop|Quit|Unknownofstring(* "FROM:<alice@tiny>" -> "alice@tiny" *)letpath(s:string):string=lets=String.trimsinmatch(String.index_opts'<',String.rindex_opts'>')withSomei,Somejwhenj>i->String.subs(i+1)(j-i-1)|_->sletparse_command(line:string):command=letline=String.trimlineinletverb,rest=matchString.index_optline' 'withSomei->(String.subline0i,String.subline(i+1)(String.lengthline-i-1))|None->(line,"")inletafter_colons=matchString.index_opts':'withSomei->String.subs(i+1)(String.lengths-i-1)|None->sinmatchString.uppercase_asciiverbwith|"HELO"->Helo(String.trimrest)|"EHLO"->Ehlo(String.trimrest)|"MAIL"->Mail_from(path(after_colonrest))|"RCPT"->Rcpt_to(path(after_colonrest))|"DATA"->Data|"RSET"->Rset|"NOOP"->Noop|"QUIT"->Quit|_->Unknownlineletcommand_to_string=function|Helod->"HELO "^d|Ehlod->"EHLO "^d|Mail_froma->"MAIL FROM:<"^a^">"|Rcpt_toa->"RCPT TO:<"^a^">"|Data->"DATA"|Rset->"RSET"|Noop->"NOOP"|Quit->"QUIT"|Unknowns->s(*****************************************************************************)(* The message *)(*****************************************************************************)typeenvelope={sender:string;recipients:stringlist;text:string}letenvelope~(sender:string)(m:Mail.t):envelope=letrecipients=List.concat_map(funname->List.concat_map(funv->List.map(fun(a:Mail.address)->a.mailbox)(Mail.addressesv))(Mail.get_allmname))["to";"cc";"bcc"]in{sender;recipients;text=Mail.to_string(Mail.remove"bcc"m)}letstuff(text:string):stringlist=letlines=String.split_on_char'\n'(Mail.lftext)in(* the text's last line break ends its last line, not a line of its own *)letlines=matchList.revlineswith""::rest->List.revrest|_->linesinList.map(funl->ifl<>""&&l.[0]='.'then"."^lelsel)lines@["."]letunstuff(lines:stringlist):string=letlines=matchList.revlineswith"."::rest->List.revrest|_->linesinString.concat""(List.map(funl->(ifl<>""&&l.[0]='.'thenString.subl1(String.lengthl-1)elsel)^"\n")lines)(*****************************************************************************)(* The client *)(*****************************************************************************)typeoutcome=Sentofint*stringlist|Refusedofstringtypephase=|Greeting|Helloofbool(* EHLO sent; HELO after it was refused *)|Auth|Mail|Rcptofstringlist(* the recipients still to name *)|Data_sent|Body|Reset|Quitting|Closedofstringoption(* why, if it went wrong *)typeclient={hello:string;auth:(string*string)option;todo:envelopelist;(* the current one first *)phase:phase;accepted:int;refusals:stringlist;(* the current message's refused recipients *)outcomes:outcomelist;(* the latest first *)more:stringlist;(* a reply's lines so far, before its last *)}letclient~(hello:string)?auth(envelopes:envelopelist):client={hello;auth;todo=envelopes;phase=Greeting;accepted=0;refusals=[];outcomes=[];more=[]}letsendcphasecmd=({cwithphase},[command_to_stringcmd])(* the current message is done with *)letdone_with(outcome:outcome)(c:client):client={cwithtodo=List.tlc.todo;outcomes=outcome::c.outcomes}(* the next message, or goodbye *)letrecnext(c:client):client*stringlist=letc={cwithaccepted=0;refusals=[]}inmatchc.todowith|{recipients=[];_}::_->next(done_with(Refused"no recipient")c)|e::_->sendcMail(Mail_frome.sender)|[]->sendcQuittingQuitletplain~(user:string)~(password:string):string=Base64.encode("\000"^user^"\000"^password)letrefuse(why:string)(c:client):client*stringlist=send(done_with(Refusedwhy)c)ResetRsetletstep(c:client)(line:string):client*stringlist=matchreply_linelinewith|None->(c,[])|Some(_,true,text)->({cwithmore=c.more@[text]},[])|Some(code,false,text)->(lettext=String.concat" "(c.more@[text])andc={cwithmore=[]}inletok=code/100=2andsaid=Printf.sprintf"%d %s"codetextinmatchc.phasewith|Greeting->ifcode=220thensendc(Hellotrue)(Ehloc.hello)elsesendc(Closed(Somesaid))Quit|Helloehlo->ifokthenmatchc.authwithSome(user,password)->sendcAuth(Unknown("AUTH PLAIN "^plain~user~password))|None->nextcelseifehlothensendc(Hellofalse)(Heloc.hello)(* a server of 1982: no EHLO *)elsesendc(Closed(Somesaid))Quit|Auth->ifcode=235thennextcelsesendc(Closed(Somesaid))Quit|Mail->ifokthensendc(Rcpt(List.tl(List.hdc.todo).recipients))(Rcpt_to(List.hd(List.hdc.todo).recipients))elserefusesaidc|Rcptrest->(letc=ifokthen{cwithaccepted=c.accepted+1}else{cwithrefusals=c.refusals@[said]}inmatchrestwith|r::rest->sendc(Rcptrest)(Rcpt_tor)|[]->ifc.accepted>0thensendcData_sentDataelserefuse"no recipient accepted"c)|Data_sent->ifcode=354then({cwithphase=Body},stuff(List.hdc.todo).text)elserefusesaidc|Body->next(done_with(ifokthenSent(c.accepted,c.refusals)elseRefusedsaid)c)|Reset->nextc|Quitting->({cwithphase=ClosedNone},[])|Closed_->(c,[]))letfinished(c:client):(outcomelist,string)resultoption=matchc.phasewith|ClosedNone->Some(Ok(List.revc.outcomes))|Closed(Somewhy)->Some(Errorwhy)|_->None