{ +-------------------------------------------------------------+ }
{ |                                                             | }
{ |   GM-Software                                               | }
{ |   ===========                                               | }
{ |                                                             | }
{ |   Project: All Projects                                     | }
{ |                                                             | }
{ |   Description: Implementation of the SMTP protocol.         | }
{ |                https://tools.ietf.org/html/rfc5321          | }
{ |                                                             | }
{ |   Copyright (C) - 2013 - Gerrit Moeller.                    | }
{ |                                                             | }
{ |   Source code dstributed under MIT license.                 | }
{ |                                                             | }
{ |   See: https://www.gm-software.de                           | }
{ |                                                             | }
{ +-------------------------------------------------------------+ }


{$INCLUDE GMCompilerSettings.inc}

{.$DEFINE AUTH_CRAM_MD5}

unit GMSmtp;

interface

uses {$IFDEF JEDIAPI}{$IFNDEF FPC}jwaWinType,{$ENDIF}{$ELSE}Windows,{$ENDIF}
     GMStrDef, GMActiveX, GMIntf, GMCommon, GMINetBase, GMSockets;

const

  cSmtpDfltPort = '25';
  cMaxMailLineLen = 998;
  cMailTerm: AnsiString = '.';


type

  EGMSmtpException = class(EGMINetException);
  
  TSmtpExtension = (seAuth, seSize, se8Bit);

  TSmtpAuthKind = ({$IFDEF AUTH_CRAM_MD5}sakCramMd5,{$ENDIF} sakPlain, sakLogin);
  TSmtpAuthKinds = set of TSmtpAuthKind;


  TGMSmtpClient = class;

  IGMSmtpClient = interface(IUnknown)
    ['{0BEA826F-4B28-4F53-9C3E-A8FD7C6DE78F}']
    function Obj: TGMSmtpClient;
  end;

  TGMSmtpClient = class(TGMINetProtocolBase, IGMSmtpClient)
   protected
    FHelloMsgHandshakeDone: Boolean;
    FAuthKinds: TSmtpAuthKinds;
    FServerMaxMsgSize: LongInt;
    F8BitMime: Boolean;
    FOwnAddr: TGMString;
    FTransportLayer: ISequentialStream;

    function IsHeaderTermLine(const ALine: AnsiString): Boolean; override;
    procedure SendTextBody(const AText: TGMString);
    procedure ParseForExtensions(const ALines: TGMStringArray);
    procedure DoHelloMsgHandshake;

   public
    constructor Create(const ARefLifeTime: Boolean = True); overload; override;
    constructor Create(const ATransportLayer: ISequentialStream; const AOwnAddr: TGMString; const ARefLifeTime: Boolean = True); reintroduce; overload;
    function ProtocolDisplayName: TGMString; override;
    destructor Destroy; override;
    function Obj: TGMSmtpClient;
    procedure Login(const AUsername, APassword: TGMString);
    procedure SendMail(AFrom, ATo, ASubject, AText: TGMString);
    procedure Quit;
    procedure Reset;
    function Help(const ATopic: TGMString): TGMString;
  end;



procedure GMSendMail(const AFrom, ATo, AHost, AUser, APwd, ASubject, AText: TGMString; const AAskCanceled: IUnknown = nil; APort: TGMString = '');


implementation

uses SysUtils, GMCharCoding
     {$IFDEF TLS_SUPPORT}, GMOpenSSL{$ENDIF}
     {$IFDEF AUTH_CRAM_MD5}, GMWinCrypt{$ENDIF};

const

  cUnlimitedMsgSize = -1;


resourcestring

  RStrAuthFailed = 'Authentication failed';
  RStrMsgTooLarge = 'Message size %d exeeds maximum size (%d) supprted by the server';


{ ------------------------- }
{ ---- Global Routines ---- }
{ ------------------------- }

procedure GMSendMail(const AFrom, ATo, AHost, AUser, APwd, ASubject, AText: TGMString; const AAskCanceled: IUnknown; APort: TGMString);
var socket: IGMSocket; smtp: IGMSmtpClient; strm: ISequentialStream; // host: TGMString; //uri, protocol, user, password, host, port, path, parameter: TGMString;
begin
//protocol := cStrHttp;
//uri := GMSplitURL(AUrl, protocol, user, password, host, port, path, parameter);

  if Length(APort) <= 0 then APort := cSmtpDfltPort;

  socket := TGMTcpSocket.Create(vDfltInetAddrFamily, AAskCanceled);
  socket.Connect(AHost, APort);

  strm := TGMSocketStream.Create(socket);

//host := Socket.LocalAddress.Obj.ResolvedHost;

  smtp := TGMSmtpClient.Create(strm, Socket.LocalAddress.Obj.ResolvedHost);
  smtp.Obj.Login(AUser, APwd);
  smtp.Obj.SendMail(AFrom, ATo, ASubject, AText);


//httpRequest := TGMHttpClientRequest.Create(socket);
//Result := httpRequest.Obj.Execute(cHttpMethoddGET, uri, protocol);
//Result := 0;
end;

function GMQuoteEMailaddress(const AEMailaddress: TGMString): TGMString;
begin
  Result := '<' + GMStripRight(GMStripLeft(AEMailaddress, '<' + cWhiteSpace), '>' + cWhiteSpace) + '>';
end;




{ --------------------------------- }
{ ---- Authentication Routines ---- }
{ --------------------------------- }

function SmtpAuthLogin(const AProtokol: TGMINetProtocolBase; const ATransportLayer: ISequentialStream; const AUsername, APassword: TGMString): Boolean;
const cStrRoutineName = 'SmtpAuthLogin';
var cmdResponse: TCmdResponse;
begin
  Result := False;
  if AProtokol = nil then Exit;
  cmdResponse := AProtokol.ExecCommandStr(ATransportLayer, 'AUTH LOGIN', cStrDigits);
  if cmdResponse.Code[1] <> '3' then Exit;
  vfGMTrace(cStrCommand  + ': ' + '*hidden*', AProtokol.ProtocolDisplayName);
  cmdResponse := AProtokol.ExecCommandStr(ATransportLayer, GMEncodeBase64Str(AUsername), cStrDigits, cStrRoutineName, False);
  if cmdResponse.Code[1] <> '3' then Exit;
  vfGMTrace(cStrCommand  + ': ' + '*hidden*', AProtokol.ProtocolDisplayName);
  cmdResponse := AProtokol.ExecCommandStr(ATransportLayer, GMEncodeBase64Str(APassword), cStrDigits, cStrRoutineName, False);
  Result := cmdResponse.Code[1] = '2';
end;

function SmtpAuthPlain(const AProtokol: TGMINetProtocolBase; const ATransportLayer: ISequentialStream; const AUsername, APassword: TGMString): Boolean;
const cStrRoutineName = 'SmtpAuthPlain';
var cmdResponse: TCmdResponse;
begin
  Result := False;
  if AProtokol = nil then Exit;
  vfGMTrace(cStrCommand  + ': ' + 'AUTH PLAIN *hidden*', AProtokol.ProtocolDisplayName);
  cmdResponse := AProtokol.ExecCommandStr(ATransportLayer, 'AUTH PLAIN ' + GMEncodeBase64Str(#0 + AUsername + #0 + APassword), cStrDigits, cStrRoutineName, False);
  Result := cmdResponse.Code[1] = '2';
end;

{$IFDEF AUTH_CRAM_MD5}
function AuthCramMd5(const ASmtp: TGMSmtpClient; const AUsername, APassword: TGMString): Boolean;
var cmdResponse: TCmdResponse; line, authVal: ansistring;
begin
  Result := False;
  cmdResponse := ASmtp.ExecCommandStr(ASmtp.FTransportLayer, 'AUTH CRAM-MD5', cStrDigits);
  if cmdResponse.Code[1] <> '3' then Exit;
  line := cmdResponse.Text[Low(cmdResponse.Text)];
  authVal := AUsername + ' ' + GMStrToHexStr(GMHmacMd5(GMDecodeBase64Str(Copy(line, 5, Length(line) - 4)), APassword));
  cmdResponse := ASmtp.ExecCommandStr(ASmtp.FNetStream, GMEncodeBase64Str(authVal), cStrDigits);
  Result := cmdResponse.Code[1] = '2';
end;
{$ENDIF}


{ ----------------------- }
{ ---- TGMSmtpClient ---- }
{ ----------------------- }

type

  TAuthFunc = function (const AProtokol: TGMINetProtocolBase; const ATransportLayer: ISequentialStream; const AUsername, APassword: TGMString): Boolean;

  TAuthData = record
    Verb: TGMString;
    Func: TAuthFunc;
  end;

const

  cAuthData: array [TSmtpAuthKind] of TAuthData = (
    {$IFDEF AUTH_CRAM_MD5}(Verb: 'CRAM-MD5'; Func: AuthCramMd5),{$ENDIF}
    (Verb: 'PLAIN'; Func: SmtpAuthPlain),
    (Verb: 'LOGIN'; Func: SmtpAuthLogin));


constructor TGMSmtpClient.Create(const ARefLifeTime: Boolean);
begin
  inherited Create(ARefLifeTime);
  FServerMaxMsgSize := cUnlimitedMsgSize;
end;

constructor TGMSmtpClient.Create(const ATransportLayer: ISequentialStream; const AOwnAddr: TGMString; const ARefLifeTime: Boolean);
begin
  Create(ARefLifeTime); // ATransportLayer
  FOwnAddr := GMStrip(AOwnAddr);
  if Length(FOwnAddr) <= 0 then FOwnAddr := GMThisComputerName;
  FOwnAddr := GMDeleteChars(FOwnAddr, cWhiteSpace);
  FTransportLayer := ATransportLayer;
end;

destructor TGMSmtpClient.Destroy;
begin
  try Quit; except end; // <- never raise in destructors!
  inherited;
end;

function TGMSmtpClient.Obj: TGMSmtpClient;
begin
  Result := Self;
end;

function TGMSmtpClient.ProtocolDisplayName: TGMString;
begin
  Result := 'SMTP';
end;

procedure TGMSmtpClient.Quit;
begin
  ExecCommandStr(FTransportLayer, 'QUIT', '2');
  FHelloMsgHandshakeDone := False;
end;

procedure TGMSmtpClient.Reset;
begin
  ExecCommandStr(FTransportLayer, 'RSET', '2');
end;

function TGMSmtpClient.IsHeaderTermLine(const ALine: AnsiString): Boolean;
begin
  Result := (Length(ALine) <= 0) or (ALine = cMailTerm);
end;

function TGMSmtpClient.Help(const ATopic: TGMString): TGMString;
var cmdResponse: TCmdResponse; cmd: AnsiString;
begin
  cmd := 'HELP';
  if Length(ATopic) > 0 then cmd := cmd + ' ' + ATopic;
  cmdResponse := ExecCommandStr(FTransportLayer, cmd, '2');
  Result := GMStrArrayAsText(cmdResponse.Text);
end;

procedure TGMSmtpClient.ParseForExtensions(const ALines: TGMStringArray);
const cSep = cWhiteSpace + '^°!%&/\()=?{}[]*+~#-.:,;|<>'; cExtAUTH = 'AUTH'; cExtSIZE = 'SIZE'; cExt8BitMime = '8BITMIME';
var i, chPos: PtrInt; token, extVerb: TGMString; a: TSmtpAuthKind;
begin
  FAuthKinds := [];
  FServerMaxMsgSize := cUnlimitedMsgSize;
  F8BitMime := False;
  for i:=Low(ALines) to High(ALines) do
   begin
    chPos := 4;
    extVerb := GMNextWord(chPos, ALines[i], cWhiteSpace);
//  if GMFindToken(ALines[i], cExtAUTH, chPos, cSep) then
    if GMSameText(extVerb, cExtAUTH) then
     begin
//    Inc(chPos, Length(cExtAUTH));
      repeat
       token := GMNextWord(chPos, ALines[i], cSep);
       if Length(token) > 0 then
        for a:=Low(cAuthData) to High(cAuthData) do
         if GMSameText(token, cAuthData[a].Verb) then Include(FAuthKinds, a);
      until length(token) <= 0;
     end
    else
    if GMSameText(extVerb, cExtSIZE) then
     begin
//    Inc(chPos, Length(cExtSIZE));
      FServerMaxMsgSize := GMStrToInt(GMMakeDezInt(GMNextWord(chPos, ALines[i], cSep), cUnlimitedMsgSize));
     end
    else
    if GMSameText(extVerb, cExt8BitMime) then F8BitMime := True;
   end;
end;

procedure TGMSmtpClient.DoHelloMsgHandshake;
var cmdResponse: TCmdResponse;
begin
  if FHelloMsgHandshakeDone then Exit;
  ExecCommandStr(FTransportLayer, '', '2'); // <- retrieve greeting message from server
  FAuthKinds := [];
  cmdResponse := ExecCommandStr(FTransportLayer, 'EHLO '+FOwnAddr, cStrDigits);
  try
   if cmdResponse.Code[1] = '2' then
    ParseForExtensions(cmdResponse.Text)
   else
    ExecCommandStr(FTransportLayer, 'HELO '+FOwnAddr, '2');
  finally
   FHelloMsgHandshakeDone := True;
  end;
end;

procedure TGMSmtpClient.Login(const AUsername, APassword: TGMString);
const cStrMethodName = 'Login';
var a: TSmtpAuthKind; authenticated: Boolean;
begin
  DoHelloMsgHandshake;

  authenticated := False;
  for a:=Low(a) to High(a) do
   if (a in FAuthKinds) and Assigned(cAuthData[a].Func) then
    begin
     authenticated := cAuthData[a].Func(Self, FTransportLayer, AUsername, APassword);
     if authenticated then Break;
    end;

  if (FAuthKinds <> []) and not authenticated then raise EGMSmtpException.ObjError(RStrAuthFailed, Self, cStrMethodName);
end;

function SendLine(const ALine: TGMString; const ALastLine: Boolean; const AData: Pointer): Boolean;
const cStrRoutineName = 'SendLine';
var chPos, i: Integer; ansiLine: AnsiString; smtp: TGMSmtpClient;
begin
  Result := True;
  if (AData = nil) or not (TObject(AData) is TGMSmtpClient) then Exit;

  smtp := TObject(AData) as TGMSmtpClient;

  ansiLine := ALine;

  if not smtp.F8BitMime then for i:=1 to Length(ansiLine) do ansiLine[i] := Chr(Ord(ansiLine[i]) and $7f);

  chPos := 1;
  repeat
   if chPos <= Length(ansiLine) then
    begin
     if ansiLine[chPos] = cMailTerm then GMSafeIStreamWrite(smtp.FTransportLayer, PAnsiChar(cMailTerm), Length(cMailTerm), cStrRoutineName);
     GMSafeIStreamWrite(smtp.FTransportLayer, @ansiLine[chPos], Max(0, Min(cMaxMailLineLen, Length(ansiLine) - chPos + 1)), cStrRoutineName);
    end;
   GMSafeIStreamWrite(smtp.FTransportLayer, PAnsiChar(CRLF), Length(CRLF), cStrRoutineName);
   Inc(chPos, cMaxMailLineLen);
  until chPos > Length(ansiLine);
//ln := ansiLine + CRLF;
//if (Length(ln) > 0) and (ln[1] = '.') then ln := '.' + ln;
//GMSafeIStreamWrite(smtp.FTransportLayer, PAnsiChar(ln), Length(ln), cStrRoutineName);
end;

procedure TGMSmtpClient.SendTextBody(const AText: TGMString);
begin
  GMBreakLines(AText, SendLine, Self, True);
end;

procedure TGMSmtpClient.SendMail(AFrom, ATo, ASubject, AText: TGMString);
const cStrMethodName = 'SendMail';
var headers, cmdFrom, term: AnsiString; msgSize: LongInt;
begin
  DoHelloMsgHandshake;

  AFrom := GMStrip(AFrom, '<>');
  ATo := GMStrip(ATo, '<>');

  GMAddINetHeader(HeadersToSend, 'Subject', ASubject);
  GMAddINetHeader(HeadersToSend, 'Date', GMEncodeUtcToINetTime(GMLocalTimeToUTC(Now), Self));
  GMAddINetHeader(HeadersToSend, 'From', AFrom);
  GMAddINetHeader(HeadersToSend, 'To', ATo);

  headers := GMHeadersAsString(HeadersToSend) + CRLF;

  cmdFrom := 'MAIL FROM:' + GMQuoteEMailaddress(AFrom);
  if F8BitMime then cmdFrom := cmdFrom + ' BODY=8BITMIME';
  if FServerMaxMsgSize >= 0 then
   begin
    msgSize := Length(headers) + Length(AText);
    if (FServerMaxMsgSize > 0) and (msgSize > FServerMaxMsgSize) then
       raise EGMSmtpException.ObjError(GMFormat(RStrMsgTooLarge, [msgSize, FServerMaxMsgSize]), Self, cStrMethodName);
    cmdFrom := cmdFrom + ' SIZE=' + IntToStr(msgSize);
   end;

  ExecCommandStr(FTransportLayer, cmdFrom, '2');
  ExecCommandStr(FTransportLayer, 'RCPT TO:' + GMQuoteEMailaddress(ATo), '2');
  ExecCommandStr(FTransportLayer, 'DATA', '3');

  vfGMTrace(headers, ProtocolDisplayName);
  GMSafeIStreamWrite(FTransportLayer, PAnsiChar(headers), Length(headers), cStrMethodName);

  vfGMTrace(GMMakeSingleLine(Atext, '<CRLF>', True), ProtocolDisplayName);
  if Length(AText) > 0 then SendTextBody(CRLF + AText);

  term := '.' + CRLF;
  GMSafeIStreamWrite(FTransportLayer, PAnsiChar(term), Length(term), cStrMethodName);

  ExecCommandStr(FTransportLayer, '', '2');
end;


end.