diff --git a/cert/ca.go b/cert/ca.go new file mode 100644 index 0000000..f07eeee --- /dev/null +++ b/cert/ca.go @@ -0,0 +1,52 @@ +package cert + +import ( + "b612.me/starcrypto" + "crypto" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "os" +) + +func MakeCert(caKey any, caCrt *x509.Certificate, csr *x509.Certificate, pub any) ([]byte, error) { + der, err := x509.CreateCertificate(rand.Reader, csr, caCrt, pub, caKey) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + certBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + } + pemData := pem.EncodeToMemory(certBlock) + return pemData, nil +} + +func LoadCA(caKeyPath, caCertPath, KeyPwd string) (crypto.PrivateKey, *x509.Certificate, error) { + caKeyBytes, err := os.ReadFile(caKeyPath) + if err != nil { + return nil, nil, err + } + caCertBytes, err := os.ReadFile(caCertPath) + if err != nil { + return nil, nil, err + } + caKey, err := starcrypto.DecodePrivateKey(caKeyBytes, KeyPwd) + if err != nil { + return nil, nil, err + } + block, _ := pem.Decode(caCertBytes) + if block == nil || (block.Type != "CERTIFICATE" && block.Type != "CERTIFICATE REQUEST") { + return nil, nil, errors.New("Failed to decode PEM block containing the certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, err + } + return caKey, cert, nil +} diff --git a/cert/cert.go b/cert/cert.go new file mode 100644 index 0000000..61a5093 --- /dev/null +++ b/cert/cert.go @@ -0,0 +1,637 @@ +package cert + +import ( + "b612.me/starlog" + "crypto/dsa" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "software.sslmate.com/src/go-pkcs12" + "strings" +) + +func ParseCert(data []byte, pwd string) { + { + pems, err := pkcs12.ToPEM(data, pwd) + if err == nil { + for _, v := range pems { + switch v.Type { + case "CERTIFICATE": + cert, err := x509.ParseCertificate(v.Bytes) + if err != nil { + continue + } + starlog.Green("这是一个PKCS12文件\n") + starlog.Green("-----证书信息-----\n\n") + starlog.Green("证书版本:%d\n", cert.Version) + starlog.Green("证书序列号:%d\n", cert.SerialNumber) + starlog.Green("证书签发者:%s\n", cert.Issuer) + starlog.Green("证书开始时间:%s\n", cert.NotBefore) + starlog.Green("证书结束时间:%s\n", cert.NotAfter) + starlog.Green("证书扩展:%+v\n", cert.Extensions) + starlog.Green("证书签名算法:%s\n", cert.SignatureAlgorithm) + starlog.Green("证书签名:%x\n", cert.Signature) + starlog.Green("证书公钥:%+v\n", cert.PublicKey) + starlog.Green("证书公钥算法:%s\n", cert.PublicKeyAlgorithm) + starlog.Green("证书密钥用法:%v\n", keyUsageToStrings(cert.KeyUsage)) + starlog.Green("证书扩展密钥用法:%v\n", extKeyUsageToStrings(cert.ExtKeyUsage)) + starlog.Green("证书是否CA:%t\n", cert.IsCA) + starlog.Green("证书最大路径长度:%d\n", cert.MaxPathLen) + starlog.Green("证书最大路径长度是否为0:%t\n", cert.MaxPathLenZero) + starlog.Green("证书是否根证书:%t\n", cert.BasicConstraintsValid) + starlog.Green("证书国家:%+v\n", cert.Subject.Country) + starlog.Green("证书省份:%+v\n", cert.Subject.Province) + starlog.Green("证书城市:%+v\n", cert.Subject.Locality) + starlog.Green("证书组织:%+v\n", cert.Subject.Organization) + starlog.Green("证书组织单位:%+v\n", cert.Subject.OrganizationalUnit) + starlog.Green("证书通用名称:%s\n", cert.Subject.CommonName) + starlog.Green("证书DNS:%+v\n", cert.DNSNames) + starlog.Green("证书主题:%s\n", cert.Subject.String()) + starlog.Green("-----公钥信息-----\n\n") + pub := cert.PublicKey + switch n := pub.(type) { + case *rsa.PublicKey: + starlog.Green("公钥算法为RSA\n") + starlog.Green("公钥位数:%d\n", n.Size()) + starlog.Green("公钥长度:%d\n", n.N.BitLen()) + starlog.Green("公钥指数:%d\n", n.E) + case *ecdsa.PublicKey: + starlog.Green("公钥算法为ECDSA\n") + starlog.Green("公钥位数:%d\n", n.Curve.Params().BitSize) + starlog.Green("公钥曲线:%s\n", n.Curve.Params().Name) + starlog.Green("公钥长度:%d\n", n.Params().BitSize) + starlog.Green("公钥公钥X:%d\n", n.X) + starlog.Green("公钥公钥Y:%d\n", n.Y) + case *dsa.PublicKey: + starlog.Green("公钥算法为DSA\n") + starlog.Green("公钥公钥Y:%d\n", n.Y) + case *ecdh.PublicKey: + starlog.Green("公钥算法为ECDH\n") + case *ed25519.PublicKey: + starlog.Green("公钥算法为ED25519\n") + default: + starlog.Green("未知公钥类型\n") + } + case "PRIVATE KEY": + priv, err := x509.ParsePKCS8PrivateKey(v.Bytes) + if err != nil { + priv, err = x509.ParsePKCS1PrivateKey(v.Bytes) + if err != nil { + priv, err = x509.ParseECPrivateKey(v.Bytes) + if err != nil { + starlog.Errorf("解析私钥错误:%s\n", err) + continue + } else { + starlog.Green("这是一个ECDSA私钥\n") + } + } else { + starlog.Green("这是一个PKCS1私钥\n") + } + } else { + starlog.Green("这是一个PKCS8私钥\n") + } + starlog.Green("-----私钥信息-----\n\n") + switch n := priv.(type) { + case *rsa.PrivateKey: + starlog.Green("这是一个RSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Size()) + starlog.Green("私钥长度:%d\n", n.N.BitLen()) + starlog.Green("私钥指数:%d\n", n.E) + starlog.Green("私钥系数:%d\n", n.D) + starlog.Green("私钥质数p:%d\n", n.Primes[0]) + starlog.Green("私钥质数q:%d\n", n.Primes[1]) + starlog.Green("私钥系数dP:%d\n", n.Precomputed.Dp) + starlog.Green("私钥系数dQ:%d\n", n.Precomputed.Dq) + starlog.Green("私钥系数qInv:%d\n", n.Precomputed.Qinv) + case *ecdsa.PrivateKey: + starlog.Green("这是一个ECDSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Curve.Params().BitSize) + starlog.Green("私钥曲线:%s\n", n.Curve.Params().Name) + starlog.Green("私钥长度:%d\n", n.Params().BitSize) + starlog.Green("私钥系数:%d\n", n.D) + starlog.Green("私钥公钥X:%d\n", n.PublicKey.X) + starlog.Green("私钥公钥Y:%d\n", n.PublicKey.Y) + case *dsa.PrivateKey: + starlog.Green("这是一个DSA私钥\n") + starlog.Green("私钥系数:%d\n", n.X) + starlog.Green("私钥公钥Y:%d\n", n.Y) + case *ed25519.PrivateKey: + starlog.Green("这是一个ED25519私钥\n") + case *ecdh.PrivateKey: + starlog.Green("这是一个ECDH私钥\n") + default: + starlog.Green("未知私钥类型\n") + } + } + + } + return + } + } + idx := 0 + for { + idx++ + block, rest := pem.Decode(data) + if block == nil { + if idx == 1 { + starlog.Errorf("未知文件类型\n") + } + return + } + fmt.Println("\n--------------------------------\n") + data = rest + rt := 0 + switch block.Type { + case "CERTIFICATE": + rt = 1 + starlog.Green("这是一个证书文件\n") + fallthrough + case "CERTIFICATE REQUEST": + if rt == 0 { + starlog.Green("这是一个证书请求文件\n") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + starlog.Errorf("解析证书错误:%s\n", err) + continue + } + starlog.Green("证书版本:%d\n", cert.Version) + starlog.Green("证书序列号:%d\n", cert.SerialNumber) + starlog.Green("证书签发者:%s\n", cert.Issuer) + starlog.Green("证书开始时间:%s\n", cert.NotBefore) + starlog.Green("证书结束时间:%s\n", cert.NotAfter) + starlog.Green("证书扩展:%+v\n", cert.Extensions) + starlog.Green("证书签名算法:%s\n", cert.SignatureAlgorithm) + starlog.Green("证书签名:%x\n", cert.Signature) + starlog.Green("证书公钥:%+v\n", cert.PublicKey) + starlog.Green("证书公钥算法:%s\n", cert.PublicKeyAlgorithm) + starlog.Green("证书密钥用法:%v\n", keyUsageToStrings(cert.KeyUsage)) + starlog.Green("证书扩展密钥用法:%v\n", extKeyUsageToStrings(cert.ExtKeyUsage)) + starlog.Green("证书是否CA:%t\n", cert.IsCA) + starlog.Green("证书最大路径长度:%d\n", cert.MaxPathLen) + starlog.Green("证书最大路径长度是否为0:%t\n", cert.MaxPathLenZero) + starlog.Green("证书是否根证书:%t\n", cert.BasicConstraintsValid) + starlog.Green("证书国家:%+v\n", cert.Subject.Country) + starlog.Green("证书省份:%+v\n", cert.Subject.Province) + starlog.Green("证书城市:%+v\n", cert.Subject.Locality) + starlog.Green("证书组织:%+v\n", cert.Subject.Organization) + starlog.Green("证书组织单位:%+v\n", cert.Subject.OrganizationalUnit) + starlog.Green("证书通用名称:%s\n", cert.Subject.CommonName) + starlog.Green("证书DNS:%+v\n", cert.DNSNames) + starlog.Green("证书主题:%s\n", cert.Subject.String()) + pub := cert.PublicKey + switch n := pub.(type) { + case *rsa.PublicKey: + starlog.Green("公钥算法为RSA\n") + starlog.Green("公钥位数:%d\n", n.Size()) + starlog.Green("公钥长度:%d\n", n.N.BitLen()) + starlog.Green("公钥指数:%d\n", n.E) + case *ecdsa.PublicKey: + starlog.Green("公钥算法为ECDSA\n") + starlog.Green("公钥位数:%d\n", n.Curve.Params().BitSize) + starlog.Green("公钥曲线:%s\n", n.Curve.Params().Name) + starlog.Green("公钥长度:%d\n", n.Params().BitSize) + starlog.Green("公钥公钥X:%d\n", n.X) + starlog.Green("公钥公钥Y:%d\n", n.Y) + case *dsa.PublicKey: + starlog.Green("公钥算法为DSA\n") + starlog.Green("公钥公钥Y:%d\n", n.Y) + case *ecdh.PublicKey: + starlog.Green("公钥算法为ECDH\n") + case *ed25519.PublicKey: + starlog.Green("公钥算法为ED25519\n") + default: + starlog.Green("未知公钥类型\n") + } + continue + case "PRIVATE KEY": + starlog.Infof("这是一个私钥文件\n") + priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + priv, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + priv, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + starlog.Errorf("解析私钥错误:%s\n", err) + continue + } else { + starlog.Green("这是一个ECDSA私钥\n") + } + } else { + starlog.Green("这是一个PKCS1私钥\n") + } + } else { + starlog.Green("这是一个PKCS8私钥\n") + } + switch n := priv.(type) { + case *rsa.PrivateKey: + starlog.Green("这是一个RSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Size()) + starlog.Green("私钥长度:%d\n", n.N.BitLen()) + starlog.Green("私钥指数:%d\n", n.E) + starlog.Green("私钥系数:%d\n", n.D) + starlog.Green("私钥质数p:%d\n", n.Primes[0]) + starlog.Green("私钥质数q:%d\n", n.Primes[1]) + starlog.Green("私钥系数dP:%d\n", n.Precomputed.Dp) + starlog.Green("私钥系数dQ:%d\n", n.Precomputed.Dq) + starlog.Green("私钥系数qInv:%d\n", n.Precomputed.Qinv) + case *ecdsa.PrivateKey: + starlog.Green("这是一个ECDSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Curve.Params().BitSize) + starlog.Green("私钥曲线:%s\n", n.Curve.Params().Name) + starlog.Green("私钥长度:%d\n", n.Params().BitSize) + starlog.Green("私钥系数:%d\n", n.D) + starlog.Green("私钥公钥X:%d\n", n.PublicKey.X) + starlog.Green("私钥公钥Y:%d\n", n.PublicKey.Y) + case *dsa.PrivateKey: + starlog.Green("这是一个DSA私钥\n") + starlog.Green("私钥系数:%d\n", n.X) + starlog.Green("私钥公钥Y:%d\n", n.Y) + case *ed25519.PrivateKey: + starlog.Green("这是一个ED25519私钥\n") + case *ecdh.PrivateKey: + starlog.Green("这是一个ECDH私钥\n") + default: + starlog.Green("未知私钥类型\n") + } + continue + + case "PUBLIC KEY": + starlog.Infof("这是一个公钥文件\n") + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + pub, err = x509.ParsePKCS1PublicKey(block.Bytes) + starlog.Green("这是一个PKCS1公钥\n") + } else { + starlog.Green("这是一个PKIX公钥\n") + } + switch n := pub.(type) { + case *rsa.PublicKey: + starlog.Green("这是一个RSA公钥\n") + starlog.Green("公钥位数:%d\n", n.Size()) + starlog.Green("公钥长度:%d\n", n.N.BitLen()) + starlog.Green("公钥指数:%d\n", n.E) + case *ecdsa.PublicKey: + starlog.Green("这是一个ECDSA公钥\n") + starlog.Green("公钥位数:%d\n", n.Curve.Params().BitSize) + starlog.Green("公钥曲线:%s\n", n.Curve.Params().Name) + starlog.Green("公钥长度:%d\n", n.Params().BitSize) + starlog.Green("公钥公钥X:%d\n", n.X) + starlog.Green("公钥公钥Y:%d\n", n.Y) + case *ecdh.PublicKey: + starlog.Green("这是一个ECDH公钥\n") + case *ed25519.PublicKey: + starlog.Green("这是一个ED25519公钥\n") + case *dsa.PublicKey: + starlog.Green("这是一个DSA公钥\n") + starlog.Green("公钥公钥Y:%d\n", n.Y) + default: + starlog.Green("未知公钥类型\n") + } + return + default: + starlog.Infof("未知证书文件类型\n") + } + } +} + +func keyUsageToStrings(keyUsage x509.KeyUsage) string { + var usages []string + + if keyUsage&x509.KeyUsageDigitalSignature != 0 { + usages = append(usages, "Digital Signature") + } + if keyUsage&x509.KeyUsageContentCommitment != 0 { + usages = append(usages, "Content Commitment") + } + if keyUsage&x509.KeyUsageKeyEncipherment != 0 { + usages = append(usages, "Key Encipherment") + } + if keyUsage&x509.KeyUsageDataEncipherment != 0 { + usages = append(usages, "Data Encipherment") + } + if keyUsage&x509.KeyUsageKeyAgreement != 0 { + usages = append(usages, "Key Agreement") + } + if keyUsage&x509.KeyUsageCertSign != 0 { + usages = append(usages, "Certificate Signing") + } + if keyUsage&x509.KeyUsageCRLSign != 0 { + usages = append(usages, "CRL Signing") + } + if keyUsage&x509.KeyUsageEncipherOnly != 0 { + usages = append(usages, "Encipher Only") + } + if keyUsage&x509.KeyUsageDecipherOnly != 0 { + usages = append(usages, "Decipher Only") + } + + return strings.Join(usages, ", ") +} + +func extKeyUsageToStrings(extKeyUsages []x509.ExtKeyUsage) string { + var usages []string + + for _, extKeyUsage := range extKeyUsages { + switch extKeyUsage { + case x509.ExtKeyUsageAny: + usages = append(usages, "Any") + case x509.ExtKeyUsageServerAuth: + usages = append(usages, "Server Authentication") + case x509.ExtKeyUsageClientAuth: + usages = append(usages, "Client Authentication") + case x509.ExtKeyUsageCodeSigning: + usages = append(usages, "Code Signing") + case x509.ExtKeyUsageEmailProtection: + usages = append(usages, "Email Protection") + case x509.ExtKeyUsageIPSECEndSystem: + usages = append(usages, "IPSEC End System") + case x509.ExtKeyUsageIPSECTunnel: + usages = append(usages, "IPSEC Tunnel") + case x509.ExtKeyUsageIPSECUser: + usages = append(usages, "IPSEC User") + case x509.ExtKeyUsageTimeStamping: + usages = append(usages, "Time Stamping") + case x509.ExtKeyUsageOCSPSigning: + usages = append(usages, "OCSP Signing") + case x509.ExtKeyUsageMicrosoftServerGatedCrypto: + usages = append(usages, "Microsoft Server Gated Crypto") + case x509.ExtKeyUsageNetscapeServerGatedCrypto: + usages = append(usages, "Netscape Server Gated Crypto") + default: + usages = append(usages, fmt.Sprintf("Unknown(%d)", extKeyUsage)) + } + } + + return strings.Join(usages, ", ") +} + +func GetCert(data []byte, pwd string) ([]any, []x509.Certificate, error) { + var common []any + var certs []x509.Certificate + { + pems, err := pkcs12.ToPEM(data, pwd) + if err == nil { + for _, v := range pems { + switch v.Type { + case "CERTIFICATE": + cert, err := x509.ParseCertificate(v.Bytes) + if err != nil { + continue + } + starlog.Green("这是一个PKCS12文件\n") + pub := cert.PublicKey + switch pub.(type) { + case *rsa.PublicKey: + starlog.Green("公钥算法为RSA\n") + case *ecdsa.PublicKey: + starlog.Green("公钥算法为ECDSA\n") + case *dsa.PublicKey: + starlog.Green("公钥算法为DSA\n") + case *ecdh.PublicKey: + starlog.Green("公钥算法为ECDH\n") + case *ed25519.PublicKey: + starlog.Green("公钥算法为ED25519\n") + default: + starlog.Green("未知公钥类型\n") + } + common = append(common, pub) + certs = append(certs, *cert) + case "PRIVATE KEY": + priv, err := x509.ParsePKCS8PrivateKey(v.Bytes) + if err != nil { + priv, err = x509.ParsePKCS1PrivateKey(v.Bytes) + if err != nil { + priv, err = x509.ParseECPrivateKey(v.Bytes) + if err != nil { + starlog.Errorf("解析私钥错误:%s\n", err) + continue + } else { + starlog.Green("这是一个ECDSA私钥\n") + } + } else { + starlog.Green("这是一个PKCS1私钥\n") + } + } else { + starlog.Green("这是一个PKCS8私钥\n") + } + starlog.Green("-----私钥信息-----\n\n") + switch n := priv.(type) { + case *rsa.PrivateKey: + starlog.Green("这是一个RSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Size()) + case *ecdsa.PrivateKey: + starlog.Green("这是一个ECDSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Curve.Params().BitSize) + case *dsa.PrivateKey: + starlog.Green("这是一个DSA私钥\n") + case *ed25519.PrivateKey: + starlog.Green("这是一个ED25519私钥\n") + case *ecdh.PrivateKey: + starlog.Green("这是一个ECDH私钥\n") + default: + starlog.Green("未知私钥类型\n") + } + common = append(common, priv) + } + + } + return common, certs, nil + } + } + idx := 0 + for { + idx++ + block, rest := pem.Decode(data) + if block == nil { + if idx == 1 { + starlog.Errorf("未知文件类型\n") + return common, certs, errors.New("未知文件类型") + } + return common, certs, nil + } + data = rest + rt := 0 + switch block.Type { + case "CERTIFICATE": + rt = 1 + starlog.Green("这是一个证书文件\n") + fallthrough + case "CERTIFICATE REQUEST": + if rt == 0 { + starlog.Green("这是一个证书请求文件\n") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + starlog.Errorf("解析证书错误:%s\n", err) + continue + } + common = append(common, cert.PublicKey) + certs = append(certs, *cert) + pub := cert.PublicKey + switch pub.(type) { + case *rsa.PublicKey: + starlog.Green("公钥算法为RSA\n") + case *ecdsa.PublicKey: + starlog.Green("公钥算法为ECDSA\n") + case *dsa.PublicKey: + starlog.Green("公钥算法为DSA\n") + case *ecdh.PublicKey: + starlog.Green("公钥算法为ECDH\n") + case *ed25519.PublicKey: + starlog.Green("公钥算法为ED25519\n") + default: + starlog.Green("未知公钥类型\n") + } + continue + case "PRIVATE KEY": + starlog.Infof("这是一个私钥文件\n") + priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + priv, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + priv, err = x509.ParseECPrivateKey(block.Bytes) + if err != nil { + starlog.Errorf("解析私钥错误:%s\n", err) + continue + } else { + starlog.Green("这是一个ECDSA私钥\n") + } + } else { + starlog.Green("这是一个PKCS1私钥\n") + } + } else { + starlog.Green("这是一个PKCS8私钥\n") + } + switch n := priv.(type) { + case *rsa.PrivateKey: + starlog.Green("这是一个RSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Size()) + case *ecdsa.PrivateKey: + starlog.Green("这是一个ECDSA私钥\n") + starlog.Green("私钥位数:%d\n", n.Curve.Params().BitSize) + case *dsa.PrivateKey: + starlog.Green("这是一个DSA私钥\n") + case *ed25519.PrivateKey: + starlog.Green("这是一个ED25519私钥\n") + case *ecdh.PrivateKey: + starlog.Green("这是一个ECDH私钥\n") + default: + starlog.Green("未知私钥类型\n") + } + common = append(common, priv) + continue + + case "PUBLIC KEY": + starlog.Infof("这是一个公钥文件\n") + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + pub, err = x509.ParsePKCS1PublicKey(block.Bytes) + starlog.Green("这是一个PKCS1公钥\n") + } else { + starlog.Green("这是一个PKIX公钥\n") + } + common = append(common, pub) + switch n := pub.(type) { + case *rsa.PublicKey: + starlog.Green("这是一个RSA公钥\n") + starlog.Green("公钥位数:%d\n", n.Size()) + case *ecdsa.PublicKey: + starlog.Green("这是一个ECDSA公钥\n") + starlog.Green("公钥位数:%d\n", n.Curve.Params().BitSize) + + case *dsa.PublicKey: + starlog.Green("这是一个DSA公钥\n") + case *ecdh.PublicKey: + starlog.Green("这是一个ECDH公钥\n") + case *ed25519.PublicKey: + starlog.Green("这是一个ED25519公钥\n") + default: + starlog.Green("未知公钥类型\n") + } + return common, certs, nil + default: + starlog.Infof("未知证书文件类型\n") + } + } +} + +func Pkcs8(data []byte, pwd string, originName string, outpath string) error { + keys, _, err := GetCert(data, pwd) + if err != nil { + return err + } + for _, v := range keys { + if v == nil { + continue + } + switch n := v.(type) { + case *ecdsa.PrivateKey, *rsa.PrivateKey, *dsa.PrivateKey, *ed25519.PrivateKey, *ecdh.PrivateKey: + data, err = x509.MarshalPKCS8PrivateKey(n) + if err != nil { + return err + } + err = os.WriteFile(outpath+"/"+originName+".pkcs8", pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: data}), 0644) + if err != nil { + return err + } else { + starlog.Green("已将私钥保存到%s\n", outpath+"/"+originName+".pkcs8") + } + case *ecdsa.PublicKey, *rsa.PublicKey, *dsa.PublicKey, *ed25519.PublicKey, *ecdh.PublicKey: + data, err = x509.MarshalPKIXPublicKey(n) + if err != nil { + return err + } + err = os.WriteFile(outpath+"/"+originName+".pub.pkcs8", pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: data}), 0644) + if err != nil { + return err + } else { + starlog.Green("已将公钥保存到%s\n", outpath+"/"+originName+".pub.pkcs8") + } + } + } + return nil +} + +func Pkcs1(data []byte, pwd string, originName string, outpath string) error { + keys, _, err := GetCert(data, pwd) + if err != nil { + return err + } + for _, v := range keys { + if v == nil { + continue + } + switch n := v.(type) { + case *rsa.PrivateKey: + data = x509.MarshalPKCS1PrivateKey(n) + if err != nil { + return err + } + err = os.WriteFile(outpath+"/"+originName+".pkcs1", pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: data}), 0644) + if err != nil { + return err + } else { + starlog.Green("已将私钥保存到%s\n", outpath+"/"+originName+".pkcs8") + } + case *rsa.PublicKey: + data = x509.MarshalPKCS1PublicKey(n) + if err != nil { + return err + } + err = os.WriteFile(outpath+"/"+originName+".pub.pkcs1", pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: data}), 0644) + if err != nil { + return err + } else { + starlog.Green("已将公钥保存到%s\n", outpath+"/"+originName+".pub.pkcs1") + } + } + } + return nil +} diff --git a/cert/cmd.go b/cert/cmd.go new file mode 100644 index 0000000..c50ab24 --- /dev/null +++ b/cert/cmd.go @@ -0,0 +1,220 @@ +package cert + +import ( + "b612.me/starcrypto" + "b612.me/stario" + "b612.me/starlog" + "fmt" + "github.com/spf13/cobra" + "os" + "path/filepath" + "time" +) + +var country, province, city, org, orgUnit, name string +var dnsName []string +var start, end time.Time +var startStr, endStr string +var savefolder string +var promptMode bool +var isCa bool +var maxPathLenZero bool +var maxPathLen int + +var caKey string +var caCert string +var csr string +var pubKey string +var caKeyPwd string +var passwd string + +var Cmd = &cobra.Command{ + Use: "cert", + Short: "证书生成与解析", + Long: "证书生成与解析", +} + +var CmdCsr = &cobra.Command{ + Use: "csr", + Short: "生成证书请求", + Long: "生成证书请求", + Run: func(cmd *cobra.Command, args []string) { + var err error + if promptMode { + if country == "" { + country = stario.MessageBox("请输入国家:", "").MustString() + } + if province == "" { + province = stario.MessageBox("请输入省份:", "").MustString() + } + if city == "" { + city = stario.MessageBox("请输入城市:", "").MustString() + } + if org == "" { + org = stario.MessageBox("请输入组织:", "").MustString() + } + if orgUnit == "" { + orgUnit = stario.MessageBox("请输入组织单位:", "").MustString() + } + if name == "" { + name = stario.MessageBox("请输入通用名称:", "").MustString() + } + if dnsName == nil { + dnsName = stario.MessageBox("请输入dns名称,用逗号分割:", "").MustSliceString(",") + } + if startStr == "" { + startStr = stario.MessageBox("请输入开始时间:", "").MustString() + } + if endStr == "" { + endStr = stario.MessageBox("请输入结束时间:", "").MustString() + } + } + start, err = time.Parse(time.RFC3339, startStr) + if err != nil { + starlog.Errorln("开始时间格式错误,格式:2006-01-02T15:04:05Z07:00", err) + os.Exit(1) + } + end, err = time.Parse(time.RFC3339, endStr) + if err != nil { + starlog.Errorln("结束时间格式错误,格式:2006-01-02T15:04:05Z07:00", err) + os.Exit(1) + } + csr := outputCsr(GenerateCsr(country, province, city, org, orgUnit, name, dnsName, start, end, isCa, maxPathLenZero, maxPathLen)) + err = os.WriteFile(savefolder+"/"+name+".csr", csr, 0644) + if err != nil { + starlog.Errorln("保存csr文件错误", err) + os.Exit(1) + } + starlog.Infoln("保存csr文件成功", savefolder+"/"+name+".csr") + }, +} + +var CmdGen = &cobra.Command{ + Use: "gen", + Short: "生成证书", + Long: "生成证书", + Run: func(cmd *cobra.Command, args []string) { + if caKey == "" { + starlog.Errorln("CA私钥不能为空") + os.Exit(1) + } + if caCert == "" { + starlog.Errorln("CA证书不能为空") + os.Exit(1) + } + if csr == "" { + starlog.Errorln("证书请求不能为空") + os.Exit(1) + } + if pubKey == "" { + starlog.Errorln("证书公钥不能为空") + os.Exit(1) + } + caKeyRaw, caCertRaw, err := LoadCA(caKey, caCert, caKeyPwd) + if err != nil { + starlog.Errorln("加载CA错误", err) + os.Exit(1) + } + csrRaw, err := LoadCsr(csr) + if err != nil { + starlog.Errorln("加载证书请求错误", err) + os.Exit(1) + } + pubKeyByte, err := os.ReadFile(pubKey) + if err != nil { + starlog.Errorln("加载公钥错误", err) + os.Exit(1) + } + pubKeyRaw, err := starcrypto.DecodePublicKey(pubKeyByte) + if err != nil { + starlog.Errorln("解析公钥错误", err) + os.Exit(1) + } + cert, err := MakeCert(caKeyRaw, caCertRaw, csrRaw, pubKeyRaw) + if err != nil { + starlog.Errorln("生成证书错误", err) + os.Exit(1) + } + err = os.WriteFile(savefolder+"/"+csrRaw.Subject.CommonName+".crt", cert, 0644) + if err != nil { + starlog.Errorln("保存证书错误", err) + os.Exit(1) + + } + starlog.Infoln("保存证书成功", savefolder+"/"+csrRaw.Subject.CommonName+".crt") + }, +} + +var CmdParse = &cobra.Command{ + Use: "parse", + Short: "解析证书", + Long: "解析证书", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + starlog.Errorln("请输入证书文件") + os.Exit(1) + } + for _, v := range args { + data, err := os.ReadFile(v) + if err != nil { + starlog.Errorln("读取证书错误", err) + continue + } + ParseCert(data, passwd) + fmt.Println("\n-------" + v + "解析完毕---------\n") + } + }, +} + +func init() { + Cmd.AddCommand(CmdCsr) + CmdCsr.Flags().BoolVarP(&promptMode, "prompt", "P", false, "是否交互模式") + CmdCsr.Flags().StringVarP(&country, "country", "c", "", "国家") + CmdCsr.Flags().StringVarP(&province, "province", "p", "", "省份") + CmdCsr.Flags().StringVarP(&city, "city", "t", "", "城市") + CmdCsr.Flags().StringVarP(&org, "org", "o", "", "组织") + CmdCsr.Flags().StringVarP(&orgUnit, "orgUnit", "u", "", "组织单位") + CmdCsr.Flags().StringVarP(&name, "name", "n", "", "通用名称") + CmdCsr.Flags().StringSliceVarP(&dnsName, "dnsName", "d", nil, "dns名称") + CmdCsr.Flags().StringVarP(&startStr, "start", "S", time.Now().Format(time.RFC3339), "开始时间,格式:2006-01-02T15:04:05Z07:00") + CmdCsr.Flags().StringVarP(&endStr, "end", "E", time.Now().AddDate(1, 0, 0).Format(time.RFC3339), "结束时间,格式:2006-01-02T15:04:05Z07:00") + CmdCsr.Flags().StringVarP(&savefolder, "savefolder", "s", "./", "保存文件夹") + CmdCsr.Flags().BoolVarP(&isCa, "isCa", "A", false, "是否是CA") + CmdCsr.Flags().BoolVarP(&maxPathLenZero, "maxPathLenZero", "z", false, "允许最大路径长度为0") + CmdCsr.Flags().IntVarP(&maxPathLen, "maxPathLen", "m", 0, "最大路径长度") + + CmdGen.Flags().StringVarP(&caKey, "caKey", "k", "", "CA私钥") + CmdGen.Flags().StringVarP(&caCert, "caCert", "C", "", "CA证书") + CmdGen.Flags().StringVarP(&csr, "csr", "r", "", "证书请求") + CmdGen.Flags().StringVarP(&pubKey, "pubKey", "P", "", "证书公钥") + CmdGen.Flags().StringVarP(&savefolder, "savefolder", "s", "./", "保存文件夹") + CmdGen.Flags().StringVarP(&caKeyPwd, "caKeyPwd", "p", "", "CA私钥密码") + Cmd.AddCommand(CmdGen) + + CmdParse.Flags().StringVarP(&passwd, "passwd", "p", "", "证书密码") + Cmd.AddCommand(CmdParse) + CmdPkcs8.Flags().StringVarP(&passwd, "passwd", "p", "", "证书密码") + CmdPkcs8.Flags().StringVarP(&savefolder, "savefolder", "s", "./", "保存文件夹") + Cmd.AddCommand(CmdPkcs8) +} + +var CmdPkcs8 = &cobra.Command{ + Use: "pkcs8", + Short: "pkcs8转换", + Long: "pkcs8转换", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + starlog.Errorln("请输入证书文件") + os.Exit(1) + } + for _, v := range args { + data, err := os.ReadFile(v) + if err != nil { + starlog.Errorln("读取证书错误", err) + continue + } + Pkcs8(data, passwd, filepath.Base(v), savefolder) + fmt.Println("\n-------" + v + "转换完毕---------\n") + } + }, +} diff --git a/cert/csr.go b/cert/csr.go new file mode 100644 index 0000000..90c3a6c --- /dev/null +++ b/cert/csr.go @@ -0,0 +1,83 @@ +package cert + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "net" + "os" + "time" +) + +func GenerateCsr(country, province, city, org, orgUnit, name string, dnsName []string, start, end time.Time, isCa bool, maxPathLenZero bool, maxPathLen int) *x509.Certificate { + var trueDNS []string + var trueIp []net.IP + for _, v := range dnsName { + ip := net.ParseIP(v) + if ip == nil { + trueDNS = append(trueDNS, v) + continue + } + trueIp = append(trueIp, ip) + } + ku := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + eku := x509.ExtKeyUsageServerAuth + if isCa { + ku = x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement | x509.KeyUsageDigitalSignature + eku = x509.ExtKeyUsageAny + } + return &x509.Certificate{ + Version: 3, + SerialNumber: big.NewInt(time.Now().Unix()), + Subject: pkix.Name{ + Country: s2s(country), + Province: s2s(province), + Locality: s2s(city), + Organization: s2s((org)), + OrganizationalUnit: s2s(orgUnit), + CommonName: name, + }, + DNSNames: trueDNS, + IPAddresses: trueIp, + NotBefore: start, + NotAfter: end, + BasicConstraintsValid: true, + IsCA: isCa, + MaxPathLen: maxPathLen, + MaxPathLenZero: maxPathLenZero, + KeyUsage: ku, + ExtKeyUsage: []x509.ExtKeyUsage{eku}, + } +} + +func outputCsr(csr *x509.Certificate) []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csr.Raw, + }) +} + +func s2s(str string) []string { + if len(str) == 0 { + return nil + } + return []string{str} +} + +func LoadCsr(csrPath string) (*x509.Certificate, error) { + csrBytes, err := os.ReadFile(csrPath) + if err != nil { + return nil, err + } + block, _ := pem.Decode(csrBytes) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, errors.New("Failed to decode PEM block containing the certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + return cert, nil +} diff --git a/go.mod b/go.mod index 6831b74..bb35849 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,14 @@ require ( b612.me/starcrypto v0.0.4 b612.me/stario v0.0.9 b612.me/starlog v1.3.3 + b612.me/starnet v0.1.8 b612.me/staros v1.1.7 b612.me/starssh v0.0.2 b612.me/startext v0.0.0-20220314043758-22c6d5e5b1cd b612.me/wincmd v0.0.3 github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 + github.com/emersion/go-smtp v0.20.2 github.com/goftp/file-driver v0.0.0-20180502053751-5d604a0fc0c9 github.com/goftp/server v0.0.0-20200708154336-f64f7c2d8a42 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 @@ -22,15 +24,14 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/spf13/cobra v1.8.0 github.com/things-go/go-socks5 v0.0.5 + software.sslmate.com/src/go-pkcs12 v0.4.0 ) require ( b612.me/starmap v1.2.4 // indirect - b612.me/starnet v0.1.8 // indirect b612.me/win32api v0.0.2 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - github.com/emersion/go-smtp v0.20.2 // indirect github.com/jlaffaye/ftp v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/pkg/sftp v1.13.4 // indirect diff --git a/go.sum b/go.sum index 3f6b01f..165a993 100644 --- a/go.sum +++ b/go.sum @@ -133,3 +133,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/httpreverse/service.go b/httpreverse/service.go index 0f743ba..70b152b 100644 --- a/httpreverse/service.go +++ b/httpreverse/service.go @@ -17,7 +17,7 @@ import ( "time" ) -var version = "2.0.1" +var version = "2.1.0" func (h *ReverseConfig) Run() error { err := h.init() diff --git a/httpserver/server.go b/httpserver/server.go index 440fa79..5e9dfb7 100644 --- a/httpserver/server.go +++ b/httpserver/server.go @@ -22,7 +22,7 @@ import ( "time" ) -var version = "2.0.1" +var version = "2.1.0" type HttpServerCfgs func(cfg *HttpServerCfg) diff --git a/keygen/cmd.go b/keygen/cmd.go index 769bead..8a1c789 100644 --- a/keygen/cmd.go +++ b/keygen/cmd.go @@ -4,6 +4,8 @@ import ( "b612.me/starcrypto" "b612.me/starlog" "b612.me/staros" + "crypto/ecdsa" + "crypto/rsa" "github.com/spf13/cobra" "os" "time" @@ -18,6 +20,8 @@ var path string var key string var outpath string +var sshPub bool + func init() { Cmd.Flags().StringVarP(&k.Type, "type", "t", "rsa", "Key Type: rsa, ecdsa") Cmd.Flags().StringVarP(&k.Encrypt, "encrypt", "e", "", "Encrypt Key with Password (not recommended)") @@ -39,6 +43,11 @@ func init() { CmdEn.Flags().StringVarP(&outpath, "outpath", "o", "./newkey", "new key file output path") Cmd.AddCommand(CmdEn) + + CmdPub.Flags().StringVarP(&path, "path", "p", "", "private key file path") + CmdPub.Flags().StringVarP(&outpath, "outpath", "o", "./public.key", "public key file output path") + CmdPub.Flags().BoolVarP(&sshPub, "ssh", "s", false, "output ssh public key") + Cmd.AddCommand(CmdPub) } var Cmd = &cobra.Command{ @@ -102,3 +111,56 @@ var CmdEn = &cobra.Command{ starlog.Infoln("new key saved to", outpath) }, } + +var CmdPub = &cobra.Command{ + Use: "pub", + Short: "通过私钥生成公钥", + Run: func(cmd *cobra.Command, args []string) { + var pub any + if !staros.Exists(path) { + starlog.Errorln("file not exists") + os.Exit(1) + } + data, err := os.ReadFile(path) + if err != nil { + starlog.Errorln("read file error:", err) + os.Exit(1) + } + priv, err := starcrypto.DecodePrivateKey(data, key) + if err != nil { + starlog.Errorln("decode private key error:", err) + os.Exit(1) + } + switch n := priv.(type) { + case *rsa.PrivateKey: + starlog.Infoln("found rsa private key") + pub = n.Public() + case *ecdsa.PrivateKey: + starlog.Infoln("found ecdsa private key") + pub = n.Public() + default: + starlog.Errorln("unknown private key type") + os.Exit(1) + } + if sshPub { + data, err = starcrypto.EncodeSSHPublicKey(pub) + if err != nil { + starlog.Errorln("encode ssh public key error:", err) + os.Exit(1) + } + } else { + data, err = starcrypto.EncodePublicKey(pub) + if err != nil { + starlog.Errorln("encode public key error:", err) + os.Exit(1) + } + } + starlog.Infoln("public key:", string(data)) + err = os.WriteFile(outpath, data, 0644) + if err != nil { + starlog.Errorln("write public key error:", err) + os.Exit(1) + } + starlog.Infoln("public key saved to", outpath) + }, +} diff --git a/main.go b/main.go index a874ebe..a145687 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "b612.me/apps/b612/base85" "b612.me/apps/b612/base91" "b612.me/apps/b612/calc" + "b612.me/apps/b612/cert" "b612.me/apps/b612/detach" "b612.me/apps/b612/df" "b612.me/apps/b612/dfinder" @@ -22,6 +23,7 @@ import ( "b612.me/apps/b612/net" "b612.me/apps/b612/rmt" "b612.me/apps/b612/search" + "b612.me/apps/b612/smtpclient" "b612.me/apps/b612/smtpserver" "b612.me/apps/b612/socks5" "b612.me/apps/b612/split" @@ -37,7 +39,7 @@ import ( var cmdRoot = &cobra.Command{ Use: "b612", - Version: "2.1.0.alpha", + Version: "2.1.0.beta", } func init() { @@ -45,7 +47,8 @@ func init() { cmdRoot.AddCommand(tcping.Cmd, uac.Cmd, httpserver.Cmd, httpreverse.Cmd, base64.Cmd, base85.Cmd, base91.Cmd, attach.Cmd, detach.Cmd, df.Cmd, dfinder.Cmd, ftp.Cmd, generate.Cmd, hash.Cmd, image.Cmd, merge.Cmd, search.Cmd, split.Cmd, vic.Cmd, - calc.Cmd, net.Cmd, rmt.Cmds, rmt.Cmdc, keygen.Cmd, dns.Cmd, whois.Cmd, socks5.Cmd, httproxy.Cmd, smtpserver.Cmd) + calc.Cmd, net.Cmd, rmt.Cmds, rmt.Cmdc, keygen.Cmd, dns.Cmd, whois.Cmd, socks5.Cmd, httproxy.Cmd, smtpserver.Cmd, smtpclient.Cmd, + cert.Cmd) } func main() { diff --git a/smtpclient/email/LICENSE b/smtpclient/email/LICENSE new file mode 100644 index 0000000..678f42d --- /dev/null +++ b/smtpclient/email/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 Jordan Wright + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/smtpclient/email/email.go b/smtpclient/email/email.go new file mode 100644 index 0000000..57d1b53 --- /dev/null +++ b/smtpclient/email/email.go @@ -0,0 +1,809 @@ +// Package email is designed to provide an "email interface for humans." +// Designed to be robust and flexible, the email package aims to make sending email easy without getting in the way. +package email + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "math" + "math/big" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + MaxLineLength = 76 // MaxLineLength is the maximum line length per RFC 2045 + defaultContentType = "text/plain; charset=us-ascii" // defaultContentType is the default Content-Type according to RFC 2045, section 5.2 +) + +// ErrMissingBoundary is returned when there is no boundary given for a multipart entity +var ErrMissingBoundary = errors.New("No boundary found for multipart entity") + +// ErrMissingContentType is returned when there is no "Content-Type" header for a MIME entity +var ErrMissingContentType = errors.New("No Content-Type found for MIME entity") + +// Email is the type used for email messages +type Email struct { + ReplyTo []string + From string + To []string + Bcc []string + Cc []string + Subject string + Text []byte // Plaintext message (optional) + HTML []byte // Html message (optional) + Sender string // override From as SMTP envelope sender (optional) + Headers textproto.MIMEHeader + Attachments []*Attachment + ReadReceipt []string +} + +// part is a copyable representation of a multipart.Part +type part struct { + header textproto.MIMEHeader + body []byte +} + +// NewEmail creates an Email, and returns the pointer to it. +func NewEmail() *Email { + return &Email{Headers: textproto.MIMEHeader{}} +} + +// trimReader is a custom io.Reader that will trim any leading +// whitespace, as this can cause email imports to fail. +type trimReader struct { + rd io.Reader + trimmed bool +} + +// Read trims off any unicode whitespace from the originating reader +func (tr *trimReader) Read(buf []byte) (int, error) { + n, err := tr.rd.Read(buf) + if err != nil { + return n, err + } + if !tr.trimmed { + t := bytes.TrimLeftFunc(buf[:n], unicode.IsSpace) + tr.trimmed = true + n = copy(buf, t) + } + return n, err +} + +func handleAddressList(v []string) []string { + res := []string{} + for _, a := range v { + w := strings.Split(a, ",") + for _, addr := range w { + decodedAddr, err := (&mime.WordDecoder{}).DecodeHeader(strings.TrimSpace(addr)) + if err == nil { + res = append(res, decodedAddr) + } else { + res = append(res, addr) + } + } + } + return res +} + +// NewEmailFromReader reads a stream of bytes from an io.Reader, r, +// and returns an email struct containing the parsed data. +// This function expects the data in RFC 5322 format. +func NewEmailFromReader(r io.Reader) (*Email, error) { + e := NewEmail() + s := &trimReader{rd: r} + tp := textproto.NewReader(bufio.NewReader(s)) + // Parse the main headers + hdrs, err := tp.ReadMIMEHeader() + if err != nil { + return e, err + } + // Set the subject, to, cc, bcc, and from + for h, v := range hdrs { + switch h { + case "Subject": + e.Subject = v[0] + subj, err := (&mime.WordDecoder{}).DecodeHeader(e.Subject) + if err == nil && len(subj) > 0 { + e.Subject = subj + } + delete(hdrs, h) + case "To": + e.To = handleAddressList(v) + delete(hdrs, h) + case "Cc": + e.Cc = handleAddressList(v) + delete(hdrs, h) + case "Bcc": + e.Bcc = handleAddressList(v) + delete(hdrs, h) + case "Reply-To": + e.ReplyTo = handleAddressList(v) + delete(hdrs, h) + case "From": + e.From = v[0] + fr, err := (&mime.WordDecoder{}).DecodeHeader(e.From) + if err == nil && len(fr) > 0 { + e.From = fr + } + delete(hdrs, h) + } + } + e.Headers = hdrs + body := tp.R + // Recursively parse the MIME parts + ps, err := parseMIMEParts(e.Headers, body) + if err != nil { + return e, err + } + for _, p := range ps { + if ct := p.header.Get("Content-Type"); ct == "" { + return e, ErrMissingContentType + } + ct, _, err := mime.ParseMediaType(p.header.Get("Content-Type")) + if err != nil { + return e, err + } + // Check if part is an attachment based on the existence of the Content-Disposition header with a value of "attachment". + if cd := p.header.Get("Content-Disposition"); cd != "" { + cd, params, err := mime.ParseMediaType(p.header.Get("Content-Disposition")) + if err != nil { + return e, err + } + filename, filenameDefined := params["filename"] + if cd == "attachment" || (cd == "inline" && filenameDefined) { + _, err = e.Attach(bytes.NewReader(p.body), filename, ct) + if err != nil { + return e, err + } + continue + } + } + switch { + case ct == "text/plain": + e.Text = p.body + case ct == "text/html": + e.HTML = p.body + } + } + return e, nil +} + +// parseMIMEParts will recursively walk a MIME entity and return a []mime.Part containing +// each (flattened) mime.Part found. +// It is important to note that there are no limits to the number of recursions, so be +// careful when parsing unknown MIME structures! +func parseMIMEParts(hs textproto.MIMEHeader, b io.Reader) ([]*part, error) { + var ps []*part + // If no content type is given, set it to the default + if _, ok := hs["Content-Type"]; !ok { + hs.Set("Content-Type", defaultContentType) + } + ct, params, err := mime.ParseMediaType(hs.Get("Content-Type")) + if err != nil { + return ps, err + } + // If it's a multipart email, recursively parse the parts + if strings.HasPrefix(ct, "multipart/") { + if _, ok := params["boundary"]; !ok { + return ps, ErrMissingBoundary + } + mr := multipart.NewReader(b, params["boundary"]) + for { + var buf bytes.Buffer + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return ps, err + } + if _, ok := p.Header["Content-Type"]; !ok { + p.Header.Set("Content-Type", defaultContentType) + } + subct, _, err := mime.ParseMediaType(p.Header.Get("Content-Type")) + if err != nil { + return ps, err + } + if strings.HasPrefix(subct, "multipart/") { + sps, err := parseMIMEParts(p.Header, p) + if err != nil { + return ps, err + } + ps = append(ps, sps...) + } else { + var reader io.Reader + reader = p + const cte = "Content-Transfer-Encoding" + if p.Header.Get(cte) == "base64" { + reader = base64.NewDecoder(base64.StdEncoding, reader) + } + // Otherwise, just append the part to the list + // Copy the part data into the buffer + if _, err := io.Copy(&buf, reader); err != nil { + return ps, err + } + ps = append(ps, &part{body: buf.Bytes(), header: p.Header}) + } + } + } else { + // If it is not a multipart email, parse the body content as a single "part" + switch hs.Get("Content-Transfer-Encoding") { + case "quoted-printable": + b = quotedprintable.NewReader(b) + case "base64": + b = base64.NewDecoder(base64.StdEncoding, b) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, b); err != nil { + return ps, err + } + ps = append(ps, &part{body: buf.Bytes(), header: hs}) + } + return ps, nil +} + +// Attach is used to attach content from an io.Reader to the email. +// Required parameters include an io.Reader, the desired filename for the attachment, and the Content-Type +// The function will return the created Attachment for reference, as well as nil for the error, if successful. +func (e *Email) Attach(r io.Reader, filename string, c string) (a *Attachment, err error) { + var buffer bytes.Buffer + if _, err = io.Copy(&buffer, r); err != nil { + return + } + at := &Attachment{ + Filename: filename, + ContentType: c, + Header: textproto.MIMEHeader{}, + Content: buffer.Bytes(), + } + e.Attachments = append(e.Attachments, at) + return at, nil +} + +// AttachFile is used to attach content to the email. +// It attempts to open the file referenced by filename and, if successful, creates an Attachment. +// This Attachment is then appended to the slice of Email.Attachments. +// The function will then return the Attachment for reference, as well as nil for the error, if successful. +func (e *Email) AttachFile(filename string) (a *Attachment, err error) { + f, err := os.Open(filename) + if err != nil { + return + } + defer f.Close() + + ct := mime.TypeByExtension(filepath.Ext(filename)) + basename := filepath.Base(filename) + return e.Attach(f, basename, ct) +} + +// msgHeaders merges the Email's various fields and custom headers together in a +// standards compliant way to create a MIMEHeader to be used in the resulting +// message. It does not alter e.Headers. +// +// "e"'s fields To, Cc, From, Subject will be used unless they are present in +// e.Headers. Unless set in e.Headers, "Date" will filled with the current time. +func (e *Email) msgHeaders() (textproto.MIMEHeader, error) { + res := make(textproto.MIMEHeader, len(e.Headers)+6) + if e.Headers != nil { + for _, h := range []string{"Reply-To", "To", "Cc", "From", "Subject", "Date", "Message-Id", "MIME-Version"} { + if v, ok := e.Headers[h]; ok { + res[h] = v + } + } + } + // Set headers if there are values. + if _, ok := res["Reply-To"]; !ok && len(e.ReplyTo) > 0 { + res.Set("Reply-To", strings.Join(e.ReplyTo, ", ")) + } + if _, ok := res["To"]; !ok && len(e.To) > 0 { + res.Set("To", strings.Join(e.To, ", ")) + } + if _, ok := res["Cc"]; !ok && len(e.Cc) > 0 { + res.Set("Cc", strings.Join(e.Cc, ", ")) + } + if _, ok := res["Subject"]; !ok && e.Subject != "" { + res.Set("Subject", e.Subject) + } + if _, ok := res["Message-Id"]; !ok { + id, err := generateMessageID() + if err != nil { + return nil, err + } + res.Set("Message-Id", id) + } + // Date and From are required headers. + if _, ok := res["From"]; !ok { + res.Set("From", e.From) + } + if _, ok := res["Date"]; !ok { + res.Set("Date", time.Now().Format(time.RFC1123Z)) + } + if _, ok := res["MIME-Version"]; !ok { + res.Set("MIME-Version", "1.0") + } + for field, vals := range e.Headers { + if _, ok := res[field]; !ok { + res[field] = vals + } + } + return res, nil +} + +func writeMessage(buff io.Writer, msg []byte, multipart bool, mediaType string, w *multipart.Writer) error { + if multipart { + header := textproto.MIMEHeader{ + "Content-Type": {mediaType + "; charset=UTF-8"}, + "Content-Transfer-Encoding": {"quoted-printable"}, + } + if _, err := w.CreatePart(header); err != nil { + return err + } + } + + qp := quotedprintable.NewWriter(buff) + // Write the text + if _, err := qp.Write(msg); err != nil { + return err + } + return qp.Close() +} + +func (e *Email) categorizeAttachments() (htmlRelated, others []*Attachment) { + for _, a := range e.Attachments { + if a.HTMLRelated { + htmlRelated = append(htmlRelated, a) + } else { + others = append(others, a) + } + } + return +} + +// Bytes converts the Email object to a []byte representation, including all needed MIMEHeaders, boundaries, etc. +func (e *Email) Bytes() ([]byte, error) { + // TODO: better guess buffer size + buff := bytes.NewBuffer(make([]byte, 0, 4096)) + + headers, err := e.msgHeaders() + if err != nil { + return nil, err + } + + htmlAttachments, otherAttachments := e.categorizeAttachments() + if len(e.HTML) == 0 && len(htmlAttachments) > 0 { + return nil, errors.New("there are HTML attachments, but no HTML body") + } + + var ( + isMixed = len(otherAttachments) > 0 + isAlternative = len(e.Text) > 0 && len(e.HTML) > 0 + isRelated = len(e.HTML) > 0 && len(htmlAttachments) > 0 + ) + + var w *multipart.Writer + if isMixed || isAlternative || isRelated { + w = multipart.NewWriter(buff) + } + switch { + case isMixed: + headers.Set("Content-Type", "multipart/mixed;\r\n boundary="+w.Boundary()) + case isAlternative: + headers.Set("Content-Type", "multipart/alternative;\r\n boundary="+w.Boundary()) + case isRelated: + headers.Set("Content-Type", "multipart/related;\r\n boundary="+w.Boundary()) + case len(e.HTML) > 0: + headers.Set("Content-Type", "text/html; charset=UTF-8") + headers.Set("Content-Transfer-Encoding", "quoted-printable") + default: + headers.Set("Content-Type", "text/plain; charset=UTF-8") + headers.Set("Content-Transfer-Encoding", "quoted-printable") + } + headerToBytes(buff, headers) + _, err = io.WriteString(buff, "\r\n") + if err != nil { + return nil, err + } + + // Check to see if there is a Text or HTML field + if len(e.Text) > 0 || len(e.HTML) > 0 { + var subWriter *multipart.Writer + + if isMixed && isAlternative { + // Create the multipart alternative part + subWriter = multipart.NewWriter(buff) + header := textproto.MIMEHeader{ + "Content-Type": {"multipart/alternative;\r\n boundary=" + subWriter.Boundary()}, + } + if _, err := w.CreatePart(header); err != nil { + return nil, err + } + } else { + subWriter = w + } + // Create the body sections + if len(e.Text) > 0 { + // Write the text + if err := writeMessage(buff, e.Text, isMixed || isAlternative, "text/plain", subWriter); err != nil { + return nil, err + } + } + if len(e.HTML) > 0 { + messageWriter := subWriter + var relatedWriter *multipart.Writer + if (isMixed || isAlternative) && len(htmlAttachments) > 0 { + relatedWriter = multipart.NewWriter(buff) + header := textproto.MIMEHeader{ + "Content-Type": {"multipart/related;\r\n boundary=" + relatedWriter.Boundary()}, + } + if _, err := subWriter.CreatePart(header); err != nil { + return nil, err + } + + messageWriter = relatedWriter + } else if isRelated && len(htmlAttachments) > 0 { + relatedWriter = w + messageWriter = w + } + // Write the HTML + if err := writeMessage(buff, e.HTML, isMixed || isAlternative || isRelated, "text/html", messageWriter); err != nil { + return nil, err + } + if len(htmlAttachments) > 0 { + for _, a := range htmlAttachments { + a.setDefaultHeaders() + ap, err := relatedWriter.CreatePart(a.Header) + if err != nil { + return nil, err + } + // Write the base64Wrapped content to the part + base64Wrap(ap, a.Content) + } + + if isMixed || isAlternative { + relatedWriter.Close() + } + } + } + if isMixed && isAlternative { + if err := subWriter.Close(); err != nil { + return nil, err + } + } + } + // Create attachment part, if necessary + for _, a := range otherAttachments { + a.setDefaultHeaders() + ap, err := w.CreatePart(a.Header) + if err != nil { + return nil, err + } + // Write the base64Wrapped content to the part + base64Wrap(ap, a.Content) + } + if isMixed || isAlternative || isRelated { + if err := w.Close(); err != nil { + return nil, err + } + } + return buff.Bytes(), nil +} + +// Send an email using the given host and SMTP auth (optional), returns any error thrown by smtp.SendMail +// This function merges the To, Cc, and Bcc fields and calls the smtp.SendMail function using the Email.Bytes() output as the message +func (e *Email) Send(addr string, a smtp.Auth) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return errors.New("Must specify at least one From address and one To address") + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + return smtp.SendMail(addr, a, sender, to, raw) +} + +// Select and parse an SMTP envelope sender address. Choose Email.Sender if set, or fallback to Email.From. +func (e *Email) parseSender() (string, error) { + if e.Sender != "" { + sender, err := mail.ParseAddress(e.Sender) + if err != nil { + return "", err + } + return sender.Address, nil + } else { + from, err := mail.ParseAddress(e.From) + if err != nil { + return "", err + } + return from.Address, nil + } +} + +// SendWithTLS sends an email over tls with an optional TLS config. +// +// The TLS Config is helpful if you need to connect to a host that is used an untrusted +// certificate. +func (e *Email) SendWithTLS(addr string, a smtp.Auth, t *tls.Config) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return errors.New("Must specify at least one From address and one To address") + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + + conn, err := tls.Dial("tcp", addr, t) + if err != nil { + return err + } + + c, err := smtp.NewClient(conn, t.ServerName) + if err != nil { + return err + } + defer c.Close() + if err = c.Hello("localhost"); err != nil { + return err + } + + if a != nil { + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(a); err != nil { + return err + } + } + } + if err = c.Mail(sender); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = w.Write(raw) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} + +// SendWithStartTLS sends an email over TLS using STARTTLS with an optional TLS config. +// +// The TLS Config is helpful if you need to connect to a host that is used an untrusted +// certificate. +func (e *Email) SendWithStartTLS(addr string, a smtp.Auth, t *tls.Config) error { + // Merge the To, Cc, and Bcc fields + to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc)) + to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) + for i := 0; i < len(to); i++ { + addr, err := mail.ParseAddress(to[i]) + if err != nil { + return err + } + to[i] = addr.Address + } + // Check to make sure there is at least one recipient and one "From" address + if e.From == "" || len(to) == 0 { + return errors.New("Must specify at least one From address and one To address") + } + sender, err := e.parseSender() + if err != nil { + return err + } + raw, err := e.Bytes() + if err != nil { + return err + } + + // Taken from the standard library + // https://github.com/golang/go/blob/master/src/net/smtp/smtp.go#L328 + c, err := smtp.Dial(addr) + if err != nil { + return err + } + defer c.Close() + if err = c.Hello("localhost"); err != nil { + return err + } + // Use TLS if available + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(t); err != nil { + return err + } + } + + if a != nil { + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(a); err != nil { + return err + } + } + } + if err = c.Mail(sender); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = w.Write(raw) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} + +// Attachment is a struct representing an email attachment. +// Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question +type Attachment struct { + Filename string + ContentType string + Header textproto.MIMEHeader + Content []byte + HTMLRelated bool +} + +func (at *Attachment) setDefaultHeaders() { + contentType := "application/octet-stream" + if len(at.ContentType) > 0 { + contentType = at.ContentType + } + at.Header.Set("Content-Type", contentType) + + if len(at.Header.Get("Content-Disposition")) == 0 { + disposition := "attachment" + if at.HTMLRelated { + disposition = "inline" + } + at.Header.Set("Content-Disposition", fmt.Sprintf("%s;\r\n filename=\"%s\"", disposition, at.Filename)) + } + if len(at.Header.Get("Content-ID")) == 0 { + at.Header.Set("Content-ID", fmt.Sprintf("<%s>", at.Filename)) + } + if len(at.Header.Get("Content-Transfer-Encoding")) == 0 { + at.Header.Set("Content-Transfer-Encoding", "base64") + } +} + +// base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars) +// The output is then written to the specified io.Writer +func base64Wrap(w io.Writer, b []byte) { + // 57 raw bytes per 76-byte base64 line. + const maxRaw = 57 + // Buffer for each line, including trailing CRLF. + buffer := make([]byte, MaxLineLength+len("\r\n")) + copy(buffer[MaxLineLength:], "\r\n") + // Process raw chunks until there's no longer enough to fill a line. + for len(b) >= maxRaw { + base64.StdEncoding.Encode(buffer, b[:maxRaw]) + w.Write(buffer) + b = b[maxRaw:] + } + // Handle the last chunk of bytes. + if len(b) > 0 { + out := buffer[:base64.StdEncoding.EncodedLen(len(b))] + base64.StdEncoding.Encode(out, b) + out = append(out, "\r\n"...) + w.Write(out) + } +} + +// headerToBytes renders "header" to "buff". If there are multiple values for a +// field, multiple "Field: value\r\n" lines will be emitted. +func headerToBytes(buff io.Writer, header textproto.MIMEHeader) { + for field, vals := range header { + for _, subval := range vals { + // bytes.Buffer.Write() never returns an error. + io.WriteString(buff, field) + io.WriteString(buff, ": ") + // Write the encoded header if needed + switch { + case field == "Content-Type" || field == "Content-Disposition": + buff.Write([]byte(subval)) + case field == "From" || field == "To" || field == "Cc" || field == "Bcc": + participants := strings.Split(subval, ",") + for i, v := range participants { + addr, err := mail.ParseAddress(v) + if err != nil { + continue + } + participants[i] = addr.String() + } + buff.Write([]byte(strings.Join(participants, ", "))) + default: + buff.Write([]byte(mime.QEncoding.Encode("UTF-8", subval))) + } + io.WriteString(buff, "\r\n") + } + } +} + +var maxBigInt = big.NewInt(math.MaxInt64) + +// generateMessageID generates and returns a string suitable for an RFC 2822 +// compliant Message-ID, e.g.: +// <1444789264909237300.3464.1819418242800517193@DESKTOP01> +// +// The following parameters are used to generate a Message-ID: +// - The nanoseconds since Epoch +// - The calling PID +// - A cryptographically random int64 +// - The sending hostname +func generateMessageID() (string, error) { + t := time.Now().UnixNano() + pid := os.Getpid() + rint, err := rand.Int(rand.Reader, maxBigInt) + if err != nil { + return "", err + } + h, err := os.Hostname() + // If we can't get the hostname, we'll use localhost + if err != nil { + h = "localhost.localdomain" + } + msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, h) + return msgid, nil +} diff --git a/smtpclient/email/email_test.go b/smtpclient/email/email_test.go new file mode 100644 index 0000000..b6d62d2 --- /dev/null +++ b/smtpclient/email/email_test.go @@ -0,0 +1,933 @@ +package email + +import ( + "fmt" + "strings" + "testing" + + "bufio" + "bytes" + "crypto/rand" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/smtp" + "net/textproto" +) + +func prepareEmail() *Email { + e := NewEmail() + e.From = "Jordan Wright " + e.To = []string{"test@example.com"} + e.Bcc = []string{"test_bcc@example.com"} + e.Cc = []string{"test_cc@example.com"} + e.Subject = "Awesome Subject" + return e +} + +func basicTests(t *testing.T, e *Email) *mail.Message { + raw, err := e.Bytes() + if err != nil { + t.Fatal("Failed to render message: ", e) + } + + msg, err := mail.ReadMessage(bytes.NewBuffer(raw)) + if err != nil { + t.Fatal("Could not parse rendered message: ", err) + } + + expectedHeaders := map[string]string{ + "To": "", + "From": "\"Jordan Wright\" ", + "Cc": "", + "Subject": "Awesome Subject", + } + + for header, expected := range expectedHeaders { + if val := msg.Header.Get(header); val != expected { + t.Errorf("Wrong value for message header %s: %v != %v", header, expected, val) + } + } + return msg +} + +func TestEmailText(t *testing.T) { + e := prepareEmail() + e.Text = []byte("Text Body is, of course, supported!\n") + + msg := basicTests(t, e) + + // Were the right headers set? + ct := msg.Header.Get("Content-type") + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatal("Content-type header is invalid: ", ct) + } else if mt != "text/plain" { + t.Fatalf("Content-type expected \"text/plain\", not %v", mt) + } +} + +func TestEmailWithHTMLAttachments(t *testing.T) { + e := prepareEmail() + + // Set plain text to exercise "mime/alternative" + e.Text = []byte("Text Body is, of course, supported!\n") + + e.HTML = []byte("This is a text.") + + // Set HTML attachment to exercise "mime/related". + attachment, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "image/png; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + attachment.HTMLRelated = true + + b, err := e.Bytes() + if err != nil { + t.Fatal("Could not serialize e-mail:", err) + } + + // Print the bytes for ocular validation and make sure no errors. + //fmt.Println(string(b)) + + // TODO: Verify the attachments. + s := &trimReader{rd: bytes.NewBuffer(b)} + tp := textproto.NewReader(bufio.NewReader(s)) + // Parse the main headers + hdrs, err := tp.ReadMIMEHeader() + if err != nil { + t.Fatal("Could not parse the headers:", err) + } + // Recursively parse the MIME parts + ps, err := parseMIMEParts(hdrs, tp.R) + if err != nil { + t.Fatal("Could not parse the MIME parts recursively:", err) + } + + plainTextFound := false + htmlFound := false + imageFound := false + if expected, actual := 3, len(ps); actual != expected { + t.Error("Unexpected number of parts. Expected:", expected, "Was:", actual) + } + for _, part := range ps { + // part has "header" and "body []byte" + cd := part.header.Get("Content-Disposition") + ct := part.header.Get("Content-Type") + if strings.Contains(ct, "image/png") && strings.HasPrefix(cd, "inline") { + imageFound = true + } + if strings.Contains(ct, "text/html") { + htmlFound = true + } + if strings.Contains(ct, "text/plain") { + plainTextFound = true + } + } + + if !plainTextFound { + t.Error("Did not find plain text part.") + } + if !htmlFound { + t.Error("Did not find HTML part.") + } + if !imageFound { + t.Error("Did not find image part.") + } +} + +func TestEmailWithHTMLAttachmentsHTMLOnly(t *testing.T) { + e := prepareEmail() + + e.HTML = []byte("This is a text.") + + // Set HTML attachment to exercise "mime/related". + attachment, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "image/png; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + attachment.HTMLRelated = true + + b, err := e.Bytes() + if err != nil { + t.Fatal("Could not serialize e-mail:", err) + } + + // Print the bytes for ocular validation and make sure no errors. + //fmt.Println(string(b)) + + // TODO: Verify the attachments. + s := &trimReader{rd: bytes.NewBuffer(b)} + tp := textproto.NewReader(bufio.NewReader(s)) + // Parse the main headers + hdrs, err := tp.ReadMIMEHeader() + if err != nil { + t.Fatal("Could not parse the headers:", err) + } + + if !strings.HasPrefix(hdrs.Get("Content-Type"), "multipart/related") { + t.Error("Envelope Content-Type is not multipart/related: ", hdrs["Content-Type"]) + } + + // Recursively parse the MIME parts + ps, err := parseMIMEParts(hdrs, tp.R) + if err != nil { + t.Fatal("Could not parse the MIME parts recursively:", err) + } + + htmlFound := false + imageFound := false + if expected, actual := 2, len(ps); actual != expected { + t.Error("Unexpected number of parts. Expected:", expected, "Was:", actual) + } + for _, part := range ps { + // part has "header" and "body []byte" + ct := part.header.Get("Content-Type") + if strings.Contains(ct, "image/png") { + imageFound = true + } + if strings.Contains(ct, "text/html") { + htmlFound = true + } + } + + if !htmlFound { + t.Error("Did not find HTML part.") + } + if !imageFound { + t.Error("Did not find image part.") + } +} + +func TestEmailHTML(t *testing.T) { + e := prepareEmail() + e.HTML = []byte("

Fancy Html is supported, too!

\n") + + msg := basicTests(t, e) + + // Were the right headers set? + ct := msg.Header.Get("Content-type") + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatalf("Content-type header is invalid: %#v", ct) + } else if mt != "text/html" { + t.Fatalf("Content-type expected \"text/html\", not %v", mt) + } +} + +func TestEmailTextAttachment(t *testing.T) { + e := prepareEmail() + e.Text = []byte("Text Body is, of course, supported!\n") + _, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "text/plain; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + + msg := basicTests(t, e) + + // Were the right headers set? + ct := msg.Header.Get("Content-type") + mt, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatal("Content-type header is invalid: ", ct) + } else if mt != "multipart/mixed" { + t.Fatalf("Content-type expected \"multipart/mixed\", not %v", mt) + } + b := params["boundary"] + if b == "" { + t.Fatalf("Invalid or missing boundary parameter: %#v", b) + } + if len(params) != 1 { + t.Fatal("Unexpected content-type parameters") + } + + // Is the generated message parsable? + mixed := multipart.NewReader(msg.Body, params["boundary"]) + + text, err := mixed.NextPart() + if err != nil { + t.Fatalf("Could not find text component of email: %s", err) + } + + // Does the text portion match what we expect? + mt, _, err = mime.ParseMediaType(text.Header.Get("Content-type")) + if err != nil { + t.Fatal("Could not parse message's Content-Type") + } else if mt != "text/plain" { + t.Fatal("Message missing text/plain") + } + plainText, err := ioutil.ReadAll(text) + if err != nil { + t.Fatal("Could not read plain text component of message: ", err) + } + if !bytes.Equal(plainText, []byte("Text Body is, of course, supported!\r\n")) { + t.Fatalf("Plain text is broken: %#q", plainText) + } + + // Check attachments. + _, err = mixed.NextPart() + if err != nil { + t.Fatalf("Could not find attachment component of email: %s", err) + } + + if _, err = mixed.NextPart(); err != io.EOF { + t.Error("Expected only text and one attachment!") + } +} + +func TestEmailTextHtmlAttachment(t *testing.T) { + e := prepareEmail() + e.Text = []byte("Text Body is, of course, supported!\n") + e.HTML = []byte("

Fancy Html is supported, too!

\n") + _, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "text/plain; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + + msg := basicTests(t, e) + + // Were the right headers set? + ct := msg.Header.Get("Content-type") + mt, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatal("Content-type header is invalid: ", ct) + } else if mt != "multipart/mixed" { + t.Fatalf("Content-type expected \"multipart/mixed\", not %v", mt) + } + b := params["boundary"] + if b == "" { + t.Fatal("Unexpected empty boundary parameter") + } + if len(params) != 1 { + t.Fatal("Unexpected content-type parameters") + } + + // Is the generated message parsable? + mixed := multipart.NewReader(msg.Body, params["boundary"]) + + text, err := mixed.NextPart() + if err != nil { + t.Fatalf("Could not find text component of email: %s", err) + } + + // Does the text portion match what we expect? + mt, params, err = mime.ParseMediaType(text.Header.Get("Content-type")) + if err != nil { + t.Fatal("Could not parse message's Content-Type") + } else if mt != "multipart/alternative" { + t.Fatal("Message missing multipart/alternative") + } + mpReader := multipart.NewReader(text, params["boundary"]) + part, err := mpReader.NextPart() + if err != nil { + t.Fatal("Could not read plain text component of message: ", err) + } + plainText, err := ioutil.ReadAll(part) + if err != nil { + t.Fatal("Could not read plain text component of message: ", err) + } + if !bytes.Equal(plainText, []byte("Text Body is, of course, supported!\r\n")) { + t.Fatalf("Plain text is broken: %#q", plainText) + } + + // Check attachments. + _, err = mixed.NextPart() + if err != nil { + t.Fatalf("Could not find attachment component of email: %s", err) + } + + if _, err = mixed.NextPart(); err != io.EOF { + t.Error("Expected only text and one attachment!") + } +} + +func TestEmailAttachment(t *testing.T) { + e := prepareEmail() + _, err := e.Attach(bytes.NewBufferString("Rad attachment"), "rad.txt", "text/plain; charset=utf-8") + if err != nil { + t.Fatal("Could not add an attachment to the message: ", err) + } + msg := basicTests(t, e) + + // Were the right headers set? + ct := msg.Header.Get("Content-type") + mt, params, err := mime.ParseMediaType(ct) + if err != nil { + t.Fatal("Content-type header is invalid: ", ct) + } else if mt != "multipart/mixed" { + t.Fatalf("Content-type expected \"multipart/mixed\", not %v", mt) + } + b := params["boundary"] + if b == "" { + t.Fatal("Unexpected empty boundary parameter") + } + if len(params) != 1 { + t.Fatal("Unexpected content-type parameters") + } + + // Is the generated message parsable? + mixed := multipart.NewReader(msg.Body, params["boundary"]) + + // Check attachments. + a, err := mixed.NextPart() + if err != nil { + t.Fatalf("Could not find attachment component of email: %s", err) + } + + if !strings.HasPrefix(a.Header.Get("Content-Disposition"), "attachment") { + t.Fatalf("Content disposition is not attachment: %s", a.Header.Get("Content-Disposition")) + } + + if _, err = mixed.NextPart(); err != io.EOF { + t.Error("Expected only one attachment!") + } +} + +func TestHeaderEncoding(t *testing.T) { + cases := []struct { + field string + have string + want string + }{ + { + field: "From", + have: "Needs Encóding , Only ASCII ", + want: "=?utf-8?q?Needs_Enc=C3=B3ding?= , \"Only ASCII\" \r\n", + }, + { + field: "To", + have: "Keith Moore , Keld Jørn Simonsen ", + want: "\"Keith Moore\" , =?utf-8?q?Keld_J=C3=B8rn_Simonsen?= \r\n", + }, + { + field: "Cc", + have: "Needs Encóding , \"Test :)\" ", + want: "=?utf-8?q?Needs_Enc=C3=B3ding?= , \"Test :)\" \r\n", + }, + { + field: "Subject", + have: "Subject with a 🐟", + want: "=?UTF-8?q?Subject_with_a_=F0=9F=90=9F?=\r\n", + }, + { + field: "Subject", + have: "Subject with only ASCII", + want: "Subject with only ASCII\r\n", + }, + } + buff := &bytes.Buffer{} + for _, c := range cases { + header := make(textproto.MIMEHeader) + header.Add(c.field, c.have) + buff.Reset() + headerToBytes(buff, header) + want := fmt.Sprintf("%s: %s", c.field, c.want) + got := buff.String() + if got != want { + t.Errorf("invalid utf-8 header encoding. \nwant:%#v\ngot: %#v", want, got) + } + } +} + +func TestEmailFromReader(t *testing.T) { + ex := &Email{ + Subject: "Test Subject", + To: []string{"Jordan Wright ", "also@example.com"}, + From: "Jordan Wright ", + ReplyTo: []string{"Jordan Wright "}, + Cc: []string{"one@example.com", "Two "}, + Bcc: []string{"three@example.com", "Four "}, + Text: []byte("This is a test email with HTML Formatting. It also has very long lines so\nthat the content must be wrapped if using quoted-printable decoding.\n"), + HTML: []byte("
This is a test email with HTML Formatting.\u00a0It also has very long lines so that the content must be wrapped if using quoted-printable decoding.
\n"), + } + raw := []byte(` + MIME-Version: 1.0 +Subject: Test Subject +From: Jordan Wright +Reply-To: Jordan Wright +To: Jordan Wright , also@example.com +Cc: one@example.com, Two +Bcc: three@example.com, Four +Content-Type: multipart/alternative; boundary=001a114fb3fc42fd6b051f834280 + +--001a114fb3fc42fd6b051f834280 +Content-Type: text/plain; charset=UTF-8 + +This is a test email with HTML Formatting. It also has very long lines so +that the content must be wrapped if using quoted-printable decoding. + +--001a114fb3fc42fd6b051f834280 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a test email with HTML Formatting.=C2=A0It = +also has very long lines so that the content must be wrapped if using quote= +d-printable decoding.
+ +--001a114fb3fc42fd6b051f834280--`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error creating email %s", err.Error()) + } + if e.Subject != ex.Subject { + t.Fatalf("Incorrect subject. %#q != %#q", e.Subject, ex.Subject) + } + if !bytes.Equal(e.Text, ex.Text) { + t.Fatalf("Incorrect text: %#q != %#q", e.Text, ex.Text) + } + if !bytes.Equal(e.HTML, ex.HTML) { + t.Fatalf("Incorrect HTML: %#q != %#q", e.HTML, ex.HTML) + } + if e.From != ex.From { + t.Fatalf("Incorrect \"From\": %#q != %#q", e.From, ex.From) + } + if len(e.To) != len(ex.To) { + t.Fatalf("Incorrect number of \"To\" addresses: %v != %v", len(e.To), len(ex.To)) + } + if e.To[0] != ex.To[0] { + t.Fatalf("Incorrect \"To[0]\": %#q != %#q", e.To[0], ex.To[0]) + } + if e.To[1] != ex.To[1] { + t.Fatalf("Incorrect \"To[1]\": %#q != %#q", e.To[1], ex.To[1]) + } + if len(e.Cc) != len(ex.Cc) { + t.Fatalf("Incorrect number of \"Cc\" addresses: %v != %v", len(e.Cc), len(ex.Cc)) + } + if e.Cc[0] != ex.Cc[0] { + t.Fatalf("Incorrect \"Cc[0]\": %#q != %#q", e.Cc[0], ex.Cc[0]) + } + if e.Cc[1] != ex.Cc[1] { + t.Fatalf("Incorrect \"Cc[1]\": %#q != %#q", e.Cc[1], ex.Cc[1]) + } + if len(e.Bcc) != len(ex.Bcc) { + t.Fatalf("Incorrect number of \"Bcc\" addresses: %v != %v", len(e.Bcc), len(ex.Bcc)) + } + if e.Bcc[0] != ex.Bcc[0] { + t.Fatalf("Incorrect \"Bcc[0]\": %#q != %#q", e.Cc[0], ex.Cc[0]) + } + if e.Bcc[1] != ex.Bcc[1] { + t.Fatalf("Incorrect \"Bcc[1]\": %#q != %#q", e.Bcc[1], ex.Bcc[1]) + } + if len(e.ReplyTo) != len(ex.ReplyTo) { + t.Fatalf("Incorrect number of \"Reply-To\" addresses: %v != %v", len(e.ReplyTo), len(ex.ReplyTo)) + } + if e.ReplyTo[0] != ex.ReplyTo[0] { + t.Fatalf("Incorrect \"ReplyTo\": %#q != %#q", e.ReplyTo[0], ex.ReplyTo[0]) + } +} + +func TestNonAsciiEmailFromReader(t *testing.T) { + ex := &Email{ + Subject: "Test Subject", + To: []string{"Anaïs "}, + Cc: []string{"Patrik Fältström "}, + From: "Mrs Valérie Dupont ", + Text: []byte("This is a test message!"), + } + raw := []byte(` + MIME-Version: 1.0 +Subject: =?UTF-8?Q?Test Subject?= +From: Mrs =?ISO-8859-1?Q?Val=C3=A9rie=20Dupont?= +To: =?utf-8?q?Ana=C3=AFs?= +Cc: =?ISO-8859-1?Q?Patrik_F=E4ltstr=F6m?= +Content-type: text/plain; charset=ISO-8859-1 + +This is a test message!`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error creating email %s", err.Error()) + } + if e.Subject != ex.Subject { + t.Fatalf("Incorrect subject. %#q != %#q", e.Subject, ex.Subject) + } + if e.From != ex.From { + t.Fatalf("Incorrect \"From\": %#q != %#q", e.From, ex.From) + } + if e.To[0] != ex.To[0] { + t.Fatalf("Incorrect \"To\": %#q != %#q", e.To, ex.To) + } + if e.Cc[0] != ex.Cc[0] { + t.Fatalf("Incorrect \"Cc\": %#q != %#q", e.Cc, ex.Cc) + } +} + +func TestNonMultipartEmailFromReader(t *testing.T) { + ex := &Email{ + Text: []byte("This is a test message!"), + Subject: "Example Subject (no MIME Type)", + Headers: textproto.MIMEHeader{}, + } + ex.Headers.Add("Content-Type", "text/plain; charset=us-ascii") + ex.Headers.Add("Message-ID", "") + raw := []byte(`From: "Foo Bar" +Content-Type: text/plain +To: foobar@example.com +Subject: Example Subject (no MIME Type) +Message-ID: + +This is a test message!`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error creating email %s", err.Error()) + } + if ex.Subject != e.Subject { + t.Errorf("Incorrect subject. %#q != %#q\n", ex.Subject, e.Subject) + } + if !bytes.Equal(ex.Text, e.Text) { + t.Errorf("Incorrect body. %#q != %#q\n", ex.Text, e.Text) + } + if ex.Headers.Get("Message-ID") != e.Headers.Get("Message-ID") { + t.Errorf("Incorrect message ID. %#q != %#q\n", ex.Headers.Get("Message-ID"), e.Headers.Get("Message-ID")) + } +} + +func TestBase64EmailFromReader(t *testing.T) { + ex := &Email{ + Subject: "Test Subject", + To: []string{"Jordan Wright "}, + From: "Jordan Wright ", + Text: []byte("This is a test email with HTML Formatting. It also has very long lines so that the content must be wrapped if using quoted-printable decoding."), + HTML: []byte("
This is a test email with HTML Formatting.\u00a0It also has very long lines so that the content must be wrapped if using quoted-printable decoding.
\n"), + } + raw := []byte(` + MIME-Version: 1.0 +Subject: Test Subject +From: Jordan Wright +To: Jordan Wright +Content-Type: multipart/alternative; boundary=001a114fb3fc42fd6b051f834280 + +--001a114fb3fc42fd6b051f834280 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHRlc3QgZW1haWwgd2l0aCBIVE1MIEZvcm1hdHRpbmcuIEl0IGFsc28gaGFzIHZl +cnkgbG9uZyBsaW5lcyBzbyB0aGF0IHRoZSBjb250ZW50IG11c3QgYmUgd3JhcHBlZCBpZiB1c2lu +ZyBxdW90ZWQtcHJpbnRhYmxlIGRlY29kaW5nLg== + +--001a114fb3fc42fd6b051f834280 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a test email with HTML Formatting.=C2=A0It = +also has very long lines so that the content must be wrapped if using quote= +d-printable decoding.
+ +--001a114fb3fc42fd6b051f834280--`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error creating email %s", err.Error()) + } + if e.Subject != ex.Subject { + t.Fatalf("Incorrect subject. %#q != %#q", e.Subject, ex.Subject) + } + if !bytes.Equal(e.Text, ex.Text) { + t.Fatalf("Incorrect text: %#q != %#q", e.Text, ex.Text) + } + if !bytes.Equal(e.HTML, ex.HTML) { + t.Fatalf("Incorrect HTML: %#q != %#q", e.HTML, ex.HTML) + } + if e.From != ex.From { + t.Fatalf("Incorrect \"From\": %#q != %#q", e.From, ex.From) + } +} + +func TestAttachmentEmailFromReader(t *testing.T) { + ex := &Email{ + Subject: "Test Subject", + To: []string{"Jordan Wright "}, + From: "Jordan Wright ", + Text: []byte("Simple text body"), + HTML: []byte("
Simple HTML body
\n"), + } + a, err := ex.Attach(bytes.NewReader([]byte("Let's just pretend this is raw JPEG data.")), "cat.jpeg", "image/jpeg") + if err != nil { + t.Fatalf("Error attaching image %s", err.Error()) + } + b, err := ex.Attach(bytes.NewReader([]byte("Let's just pretend this is raw JPEG data.")), "cat-inline.jpeg", "image/jpeg") + if err != nil { + t.Fatalf("Error attaching inline image %s", err.Error()) + } + raw := []byte(` +From: Jordan Wright +Date: Thu, 17 Oct 2019 08:55:37 +0100 +Mime-Version: 1.0 +Content-Type: multipart/mixed; + boundary=35d10c2224bd787fe700c2c6f4769ddc936eb8a0b58e9c8717e406c5abb7 +To: Jordan Wright +Subject: Test Subject + +--35d10c2224bd787fe700c2c6f4769ddc936eb8a0b58e9c8717e406c5abb7 +Content-Type: multipart/alternative; + boundary=b10ca5b1072908cceb667e8968d3af04503b7ab07d61c9f579c15b416d7c + +--b10ca5b1072908cceb667e8968d3af04503b7ab07d61c9f579c15b416d7c +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Simple text body +--b10ca5b1072908cceb667e8968d3af04503b7ab07d61c9f579c15b416d7c +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + +
Simple HTML body
+ +--b10ca5b1072908cceb667e8968d3af04503b7ab07d61c9f579c15b416d7c-- + +--35d10c2224bd787fe700c2c6f4769ddc936eb8a0b58e9c8717e406c5abb7 +Content-Disposition: attachment; + filename="cat.jpeg" +Content-Id: +Content-Transfer-Encoding: base64 +Content-Type: image/jpeg + +TGV0J3MganVzdCBwcmV0ZW5kIHRoaXMgaXMgcmF3IEpQRUcgZGF0YS4= + +--35d10c2224bd787fe700c2c6f4769ddc936eb8a0b58e9c8717e406c5abb7 +Content-Disposition: inline; + filename="cat-inline.jpeg" +Content-Id: +Content-Transfer-Encoding: base64 +Content-Type: image/jpeg + +TGV0J3MganVzdCBwcmV0ZW5kIHRoaXMgaXMgcmF3IEpQRUcgZGF0YS4= + +--35d10c2224bd787fe700c2c6f4769ddc936eb8a0b58e9c8717e406c5abb7--`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error creating email %s", err.Error()) + } + if e.Subject != ex.Subject { + t.Fatalf("Incorrect subject. %#q != %#q", e.Subject, ex.Subject) + } + if !bytes.Equal(e.Text, ex.Text) { + t.Fatalf("Incorrect text: %#q != %#q", e.Text, ex.Text) + } + if !bytes.Equal(e.HTML, ex.HTML) { + t.Fatalf("Incorrect HTML: %#q != %#q", e.HTML, ex.HTML) + } + if e.From != ex.From { + t.Fatalf("Incorrect \"From\": %#q != %#q", e.From, ex.From) + } + if len(e.Attachments) != 2 { + t.Fatalf("Incorrect number of attachments %d != %d", len(e.Attachments), 1) + } + if e.Attachments[0].Filename != a.Filename { + t.Fatalf("Incorrect attachment filename %s != %s", e.Attachments[0].Filename, a.Filename) + } + if !bytes.Equal(e.Attachments[0].Content, a.Content) { + t.Fatalf("Incorrect attachment content %#q != %#q", e.Attachments[0].Content, a.Content) + } + if e.Attachments[1].Filename != b.Filename { + t.Fatalf("Incorrect attachment filename %s != %s", e.Attachments[1].Filename, b.Filename) + } + if !bytes.Equal(e.Attachments[1].Content, b.Content) { + t.Fatalf("Incorrect attachment content %#q != %#q", e.Attachments[1].Content, b.Content) + } +} + +func ExampleGmail() { + e := NewEmail() + e.From = "Jordan Wright " + e.To = []string{"test@example.com"} + e.Bcc = []string{"test_bcc@example.com"} + e.Cc = []string{"test_cc@example.com"} + e.Subject = "Awesome Subject" + e.Text = []byte("Text Body is, of course, supported!\n") + e.HTML = []byte("

Fancy Html is supported, too!

\n") + e.Send("smtp.gmail.com:587", smtp.PlainAuth("", e.From, "password123", "smtp.gmail.com")) +} + +func ExampleAttach() { + e := NewEmail() + e.AttachFile("test.txt") +} + +func Test_base64Wrap(t *testing.T) { + file := "I'm a file long enough to force the function to wrap a\n" + + "couple of lines, but I stop short of the end of one line and\n" + + "have some padding dangling at the end." + encoded := "SSdtIGEgZmlsZSBsb25nIGVub3VnaCB0byBmb3JjZSB0aGUgZnVuY3Rpb24gdG8gd3JhcCBhCmNv\r\n" + + "dXBsZSBvZiBsaW5lcywgYnV0IEkgc3RvcCBzaG9ydCBvZiB0aGUgZW5kIG9mIG9uZSBsaW5lIGFu\r\n" + + "ZApoYXZlIHNvbWUgcGFkZGluZyBkYW5nbGluZyBhdCB0aGUgZW5kLg==\r\n" + + var buf bytes.Buffer + base64Wrap(&buf, []byte(file)) + if !bytes.Equal(buf.Bytes(), []byte(encoded)) { + t.Fatalf("Encoded file does not match expected: %#q != %#q", string(buf.Bytes()), encoded) + } +} + +// *Since the mime library in use by ```email``` is now in the stdlib, this test is deprecated +func Test_quotedPrintEncode(t *testing.T) { + var buf bytes.Buffer + text := []byte("Dear reader!\n\n" + + "This is a test email to try and capture some of the corner cases that exist within\n" + + "the quoted-printable encoding.\n" + + "There are some wacky parts like =, and this input assumes UNIX line breaks so\r\n" + + "it can come out a little weird. Also, we need to support unicode so here's a fish: 🐟\n") + expected := []byte("Dear reader!\r\n\r\n" + + "This is a test email to try and capture some of the corner cases that exist=\r\n" + + " within\r\n" + + "the quoted-printable encoding.\r\n" + + "There are some wacky parts like =3D, and this input assumes UNIX line break=\r\n" + + "s so\r\n" + + "it can come out a little weird. Also, we need to support unicode so here's=\r\n" + + " a fish: =F0=9F=90=9F\r\n") + qp := quotedprintable.NewWriter(&buf) + if _, err := qp.Write(text); err != nil { + t.Fatal("quotePrintEncode: ", err) + } + if err := qp.Close(); err != nil { + t.Fatal("Error closing writer", err) + } + if b := buf.Bytes(); !bytes.Equal(b, expected) { + t.Errorf("quotedPrintEncode generated incorrect results: %#q != %#q", b, expected) + } +} + +func TestMultipartNoContentType(t *testing.T) { + raw := []byte(`From: Mikhail Gusarov +To: notmuch@notmuchmail.org +References: <20091117190054.GU3165@dottiness.seas.harvard.edu> +Date: Wed, 18 Nov 2009 01:02:38 +0600 +Message-ID: <87iqd9rn3l.fsf@vertex.dottedmag> +MIME-Version: 1.0 +Subject: Re: [notmuch] Working with Maildir storage? +Content-Type: multipart/mixed; boundary="===============1958295626==" + +--===============1958295626== +Content-Type: multipart/signed; boundary="=-=-="; + micalg=pgp-sha1; protocol="application/pgp-signature" + +--=-=-= +Content-Transfer-Encoding: quoted-printable + +Twas brillig at 14:00:54 17.11.2009 UTC-05 when lars@seas.harvard.edu did g= +yre and gimble: + +--=-=-= +Content-Type: application/pgp-signature + +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.9 (GNU/Linux) + +iQIcBAEBAgAGBQJLAvNOAAoJEJ0g9lA+M4iIjLYQAKp0PXEgl3JMOEBisH52AsIK +=/ksP +-----END PGP SIGNATURE----- +--=-=-=-- + +--===============1958295626== +Content-Type: text/plain; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Disposition: inline + +Testing! +--===============1958295626==-- +`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error when parsing email %s", err.Error()) + } + if !bytes.Equal(e.Text, []byte("Testing!")) { + t.Fatalf("Error incorrect text: %#q != %#q\n", e.Text, "Testing!") + } +} + +func TestNoMultipartHTMLContentTypeBase64Encoding(t *testing.T) { + raw := []byte(`MIME-Version: 1.0 +From: no-reply@example.com +To: tester@example.org +Date: 7 Jan 2021 03:07:44 -0800 +Subject: Hello +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: base64 +Message-Id: <20210107110744.547DD70532@example.com> + +PGh0bWw+PGhlYWQ+PHRpdGxlPnRlc3Q8L3RpdGxlPjwvaGVhZD48Ym9keT5IZWxsbyB3 +b3JsZCE8L2JvZHk+PC9odG1sPg== +`) + e, err := NewEmailFromReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("Error when parsing email %s", err.Error()) + } + if !bytes.Equal(e.HTML, []byte("testHello world!")) { + t.Fatalf("Error incorrect text: %#q != %#q\n", e.Text, "...") + } +} + +// *Since the mime library in use by ```email``` is now in the stdlib, this test is deprecated +func Test_quotedPrintDecode(t *testing.T) { + text := []byte("Dear reader!\r\n\r\n" + + "This is a test email to try and capture some of the corner cases that exist=\r\n" + + " within\r\n" + + "the quoted-printable encoding.\r\n" + + "There are some wacky parts like =3D, and this input assumes UNIX line break=\r\n" + + "s so\r\n" + + "it can come out a little weird. Also, we need to support unicode so here's=\r\n" + + " a fish: =F0=9F=90=9F\r\n") + expected := []byte("Dear reader!\r\n\r\n" + + "This is a test email to try and capture some of the corner cases that exist within\r\n" + + "the quoted-printable encoding.\r\n" + + "There are some wacky parts like =, and this input assumes UNIX line breaks so\r\n" + + "it can come out a little weird. Also, we need to support unicode so here's a fish: 🐟\r\n") + qp := quotedprintable.NewReader(bytes.NewReader(text)) + got, err := ioutil.ReadAll(qp) + if err != nil { + t.Fatal("quotePrintDecode: ", err) + } + + if !bytes.Equal(got, expected) { + t.Errorf("quotedPrintDecode generated incorrect results: %#q != %#q", got, expected) + } +} + +func Benchmark_base64Wrap(b *testing.B) { + // Reasonable base case; 128K random bytes + file := make([]byte, 128*1024) + if _, err := rand.Read(file); err != nil { + panic(err) + } + for i := 0; i <= b.N; i++ { + base64Wrap(ioutil.Discard, file) + } +} + +func TestParseSender(t *testing.T) { + var cases = []struct { + e Email + want string + haserr bool + }{ + { + Email{From: "from@test.com"}, + "from@test.com", + false, + }, + { + Email{Sender: "sender@test.com", From: "from@test.com"}, + "sender@test.com", + false, + }, + { + Email{Sender: "bad_address_sender"}, + "", + true, + }, + { + Email{Sender: "good@sender.com", From: "bad_address_from"}, + "good@sender.com", + false, + }, + } + + for i, testcase := range cases { + got, err := testcase.e.parseSender() + if got != testcase.want || (err != nil) != testcase.haserr { + t.Errorf(`%d: got %s != want %s or error "%t" != "%t"`, i+1, got, testcase.want, err != nil, testcase.haserr) + } + } +} diff --git a/smtpclient/email/pool.go b/smtpclient/email/pool.go new file mode 100644 index 0000000..67f224a --- /dev/null +++ b/smtpclient/email/pool.go @@ -0,0 +1,367 @@ +package email + +import ( + "crypto/tls" + "errors" + "io" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "sync" + "syscall" + "time" +) + +type Pool struct { + addr string + auth smtp.Auth + max int + created int + clients chan *client + rebuild chan struct{} + mut *sync.Mutex + lastBuildErr *timestampedErr + closing chan struct{} + tlsConfig *tls.Config + helloHostname string +} + +type client struct { + *smtp.Client + failCount int +} + +type timestampedErr struct { + err error + ts time.Time +} + +const maxFails = 4 + +var ( + ErrClosed = errors.New("pool closed") + ErrTimeout = errors.New("timed out") +) + +func NewPool(address string, count int, auth smtp.Auth, opt_tlsConfig ...*tls.Config) (pool *Pool, err error) { + pool = &Pool{ + addr: address, + auth: auth, + max: count, + clients: make(chan *client, count), + rebuild: make(chan struct{}), + closing: make(chan struct{}), + mut: &sync.Mutex{}, + } + if len(opt_tlsConfig) == 1 { + pool.tlsConfig = opt_tlsConfig[0] + } else if host, _, e := net.SplitHostPort(address); e != nil { + return nil, e + } else { + pool.tlsConfig = &tls.Config{ServerName: host} + } + return +} + +// go1.1 didn't have this method +func (c *client) Close() error { + return c.Text.Close() +} + +// SetHelloHostname optionally sets the hostname that the Go smtp.Client will +// use when doing a HELLO with the upstream SMTP server. By default, Go uses +// "localhost" which may not be accepted by certain SMTP servers that demand +// an FQDN. +func (p *Pool) SetHelloHostname(h string) { + p.helloHostname = h +} + +func (p *Pool) get(timeout time.Duration) *client { + select { + case c := <-p.clients: + return c + default: + } + + if p.created < p.max { + p.makeOne() + } + + var deadline <-chan time.Time + if timeout >= 0 { + deadline = time.After(timeout) + } + + for { + select { + case c := <-p.clients: + return c + case <-p.rebuild: + p.makeOne() + case <-deadline: + return nil + case <-p.closing: + return nil + } + } +} + +func shouldReuse(err error) bool { + // certainly not perfect, but might be close: + // - EOF: clearly, the connection went down + // - textproto.Errors were valid SMTP over a valid connection, + // but resulted from an SMTP error response + // - textproto.ProtocolErrors result from connections going down, + // invalid SMTP, that sort of thing + // - syscall.Errno is probably down connection/bad pipe, but + // passed straight through by textproto instead of becoming a + // ProtocolError + // - if we don't recognize the error, don't reuse the connection + // A false positive will probably fail on the Reset(), and even if + // not will eventually hit maxFails. + // A false negative will knock over (and trigger replacement of) a + // conn that might have still worked. + if err == io.EOF { + return false + } + switch err.(type) { + case *textproto.Error: + return true + case *textproto.ProtocolError, textproto.ProtocolError: + return false + case syscall.Errno: + return false + default: + return false + } +} + +func (p *Pool) replace(c *client) { + p.clients <- c +} + +func (p *Pool) inc() bool { + if p.created >= p.max { + return false + } + + p.mut.Lock() + defer p.mut.Unlock() + + if p.created >= p.max { + return false + } + p.created++ + return true +} + +func (p *Pool) dec() { + p.mut.Lock() + p.created-- + p.mut.Unlock() + + select { + case p.rebuild <- struct{}{}: + default: + } +} + +func (p *Pool) makeOne() { + go func() { + if p.inc() { + if c, err := p.build(); err == nil { + p.clients <- c + } else { + p.lastBuildErr = ×tampedErr{err, time.Now()} + p.dec() + } + } + }() +} + +func startTLS(c *client, t *tls.Config) (bool, error) { + if ok, _ := c.Extension("STARTTLS"); !ok { + return false, nil + } + + if err := c.StartTLS(t); err != nil { + return false, err + } + + return true, nil +} + +func addAuth(c *client, auth smtp.Auth) (bool, error) { + if ok, _ := c.Extension("AUTH"); !ok { + return false, nil + } + + if err := c.Auth(auth); err != nil { + return false, err + } + + return true, nil +} + +func (p *Pool) build() (*client, error) { + cl, err := smtp.Dial(p.addr) + if err != nil { + return nil, err + } + + // Is there a custom hostname for doing a HELLO with the SMTP server? + if p.helloHostname != "" { + cl.Hello(p.helloHostname) + } + + c := &client{cl, 0} + + if _, err := startTLS(c, p.tlsConfig); err != nil { + c.Close() + return nil, err + } + + if p.auth != nil { + if _, err := addAuth(c, p.auth); err != nil { + c.Close() + return nil, err + } + } + + return c, nil +} + +func (p *Pool) maybeReplace(err error, c *client) { + if err == nil { + c.failCount = 0 + p.replace(c) + return + } + + c.failCount++ + if c.failCount >= maxFails { + goto shutdown + } + + if !shouldReuse(err) { + goto shutdown + } + + if err := c.Reset(); err != nil { + goto shutdown + } + + p.replace(c) + return + +shutdown: + p.dec() + c.Close() +} + +func (p *Pool) failedToGet(startTime time.Time) error { + select { + case <-p.closing: + return ErrClosed + default: + } + + if p.lastBuildErr != nil && startTime.Before(p.lastBuildErr.ts) { + return p.lastBuildErr.err + } + + return ErrTimeout +} + +// Send sends an email via a connection pulled from the Pool. The timeout may +// be <0 to indicate no timeout. Otherwise reaching the timeout will produce +// and error building a connection that occurred while we were waiting, or +// otherwise ErrTimeout. +func (p *Pool) Send(e *Email, timeout time.Duration) (err error) { + start := time.Now() + c := p.get(timeout) + if c == nil { + return p.failedToGet(start) + } + + defer func() { + p.maybeReplace(err, c) + }() + + recipients, err := addressLists(e.To, e.Cc, e.Bcc) + if err != nil { + return + } + + msg, err := e.Bytes() + if err != nil { + return + } + + from, err := emailOnly(e.From) + if err != nil { + return + } + if err = c.Mail(from); err != nil { + return + } + + for _, recip := range recipients { + if err = c.Rcpt(recip); err != nil { + return + } + } + + w, err := c.Data() + if err != nil { + return + } + if _, err = w.Write(msg); err != nil { + return + } + + err = w.Close() + + return +} + +func emailOnly(full string) (string, error) { + addr, err := mail.ParseAddress(full) + if err != nil { + return "", err + } + return addr.Address, nil +} + +func addressLists(lists ...[]string) ([]string, error) { + length := 0 + for _, lst := range lists { + length += len(lst) + } + combined := make([]string, 0, length) + + for _, lst := range lists { + for _, full := range lst { + addr, err := emailOnly(full) + if err != nil { + return nil, err + } + combined = append(combined, addr) + } + } + + return combined, nil +} + +// Close immediately changes the pool's state so no new connections will be +// created, then gets and closes the existing ones as they become available. +func (p *Pool) Close() { + close(p.closing) + + for p.created > 0 { + c := <-p.clients + c.Quit() + p.dec() + } +} diff --git a/smtpclient/smtp.go b/smtpclient/smtp.go new file mode 100644 index 0000000..4f9697e --- /dev/null +++ b/smtpclient/smtp.go @@ -0,0 +1,132 @@ +package smtpclient + +import ( + "b612.me/apps/b612/smtpclient/email" + "b612.me/stario" + "b612.me/starlog" + "crypto/tls" + "github.com/spf13/cobra" + "mime" + "net/smtp" + "os" + "path/filepath" + "strings" +) + +var Cmd = &cobra.Command{ + Use: "smtpc", + Short: "smtp client", + Long: "smtp client", + Run: func(cmd *cobra.Command, args []string) { + run() + }, +} + +var from, subject, text string +var to, cc, bcc, attachments, replyTo []string +var useHTML bool +var user, pwd, server string +var skipInsecure, usingFile bool +var useTLS int + +func init() { + Cmd.Flags().BoolVarP(&usingFile, "file", "F", false, "using file") + Cmd.Flags().StringSliceVarP(&replyTo, "reply-to", "r", nil, "reply to") + Cmd.Flags().StringVarP(&from, "from", "f", "", "from") + Cmd.Flags().StringVarP(&subject, "subject", "s", "", "subject") + Cmd.Flags().StringVarP(&text, "text", "t", "", "text") + Cmd.Flags().StringSliceVarP(&to, "to", "T", nil, "to") + Cmd.Flags().StringSliceVarP(&cc, "cc", "C", nil, "cc") + Cmd.Flags().StringSliceVarP(&bcc, "bcc", "B", nil, "bcc") + Cmd.Flags().StringSliceVarP(&attachments, "attachments", "a", nil, "attachments") + Cmd.Flags().BoolVarP(&useHTML, "html", "H", false, "use html") + Cmd.Flags().StringVarP(&user, "user", "u", "", "user") + Cmd.Flags().StringVarP(&pwd, "pwd", "p", "", "password") + Cmd.Flags().StringVarP(&server, "server", "S", "127.0.0.1:25", "server") + Cmd.Flags().IntVarP(&useTLS, "tls", "l", 0, "use tls,1 means use tls,2 means use starttls,other means not use tls") + Cmd.Flags().BoolVarP(&skipInsecure, "skip-insecure", "i", false, "skip insecure") +} + +func run() { + { + for from == "" { + from = stario.MessageBox("Please input mail from:", "").MustString() + } + for len(to) == 0 { + to = stario.MessageBox("Please input mail to,split by ,:", "").MustSliceString(",") + } + for subject == "" { + subject = stario.MessageBox("Please input mail subject:", "").MustString() + } + for text == "" { + text = stario.MessageBox("Please input mail text:", "").MustString() + } + for server == "" { + server = stario.MessageBox("Please input mail server:", "").MustString() + } + } + + var mail = email.Email{ + ReplyTo: replyTo, + From: from, + To: to, + Bcc: bcc, + Cc: cc, + Subject: subject, + } + var txt []byte + var err error + if usingFile { + txt, err = os.ReadFile(text) + if err != nil { + starlog.Errorf("read file %s error:%s\n", text, err) + os.Exit(1) + } + } else { + txt = []byte(text) + } + if useHTML { + mail.HTML = txt + } else { + mail.Text = txt + } + if len(attachments) > 0 { + for _, v := range attachments { + if strings.TrimSpace(v) == "" { + continue + } + f, err := os.Open(v) + if err != nil { + starlog.Errorf("open attach file %s error:%s\n", v, err) + os.Exit(1) + } + stat, err := f.Stat() + if err != nil { + starlog.Errorf("stat attach file %s error:%s\n", v, err) + os.Exit(1) + } + _, err = mail.Attach(f, stat.Name(), mime.TypeByExtension(filepath.Ext(stat.Name()))) + if err != nil { + starlog.Errorf("attach file %s error:%s\n", v, err) + os.Exit(1) + } + } + } + var auth smtp.Auth + if user != "" && pwd != "" { + auth = smtp.PlainAuth("", user, pwd, server) + } + switch useTLS { + case 1: + err = mail.SendWithTLS(server, auth, &tls.Config{InsecureSkipVerify: skipInsecure}) + case 2: + err = mail.SendWithStartTLS(server, auth, &tls.Config{InsecureSkipVerify: skipInsecure}) + default: + err = mail.Send(server, auth) + } + if err != nil { + starlog.Errorf("send mail error:%s\n", err) + os.Exit(1) + } + starlog.Infof("send mail to %v success by server %s\n", to, server) +} diff --git a/smtpserver/mime_test.go b/smtpserver/mime_test.go new file mode 100644 index 0000000..5c4d3f7 --- /dev/null +++ b/smtpserver/mime_test.go @@ -0,0 +1,13 @@ +package smtpserver + +import ( + "fmt" + "mime" + "testing" +) + +func TestSession_Data(t *testing.T) { + s := `=?UTF-8?q?=E5=85=B7=E8=B6=B3=E8=99=AB=E7=94=B5=E8=B4=BA=E8=A6=81=E8=BE=8A?= =?UTF-8?q?=E6=A5=9Ejhkkj?=` + d := new(mime.WordDecoder) + fmt.Println(d.DecodeHeader(s)) +} diff --git a/smtpserver/smtp.go b/smtpserver/smtp.go index f682137..257044f 100644 --- a/smtpserver/smtp.go +++ b/smtpserver/smtp.go @@ -2,14 +2,20 @@ package smtpserver import ( "b612.me/starlog" + "b612.me/startext" "bytes" - "flag" + "crypto/tls" "fmt" "github.com/spf13/cobra" + "html" "io" "io/ioutil" + "mime" + "mime/quotedprintable" "net/mail" "os" + "strings" + "time" "github.com/emersion/go-smtp" ) @@ -19,9 +25,10 @@ var user, pass string var allowAnyuser bool var output string var domain string +var cert, key string var Cmd = &cobra.Command{ - Use: "smtp", + Use: "smtps", Short: "smtp server", Long: "smtp server", Run: func(cmd *cobra.Command, args []string) { @@ -37,6 +44,8 @@ func init() { Cmd.Flags().BoolVarP(&allowAnyuser, "allow-anyuser", "A", false, "allow any user") Cmd.Flags().StringVarP(&output, "output", "o", "", "output mail to html") Cmd.Flags().StringVarP(&domain, "domain", "d", "localhost", "smtp server domain") + Cmd.Flags().StringVarP(&cert, "cert", "c", "", "smtp server cert(TLS)") + Cmd.Flags().StringVarP(&key, "key", "k", "", "smtp server key(TLS)") } type backend struct{} @@ -85,24 +94,66 @@ func (s *session) Data(r io.Reader) error { } header := msg.Header subject := header.Get("Subject") + to := header.Get("To") cc := header.Get("Cc") - bcc := header.Get("Bcc") from := header.Get("From") // 获取发件人 - starlog.Println("From:", from) - starlog.Println("Subject:", subject) - starlog.Println("Cc:", cc) - starlog.Println("Bcc:", bcc) - - // Read the body + date := header.Get("Date") body, err := ioutil.ReadAll(msg.Body) if err != nil { return err } - starlog.Println("Body:", string(body)) + + var bodyStr string + + var d = new(mime.WordDecoder) + { + subject, err = d.DecodeHeader(subject) + if err != nil { + starlog.Errorf("Decode subject %s error:%s\n", subject, err) + return err + } + bodyStr, err = bodyDecode(string(body)) + if err != nil { + starlog.Errorf("Decode body %s error:%s\n", string(body), err) + return err + } + } + if startext.IsGBK([]byte(bodyStr)) { + tmp, err := startext.GBK2UTF8([]byte(bodyStr)) + if err == nil { + bodyStr = string(tmp) + } + } + starlog.Println("From:", from) + starlog.Println("Subject:", subject) + starlog.Println("To ALL:", s.to) + starlog.Println("To:", to) + starlog.Println("Cc:", cc) + starlog.Println("Body:", bodyStr) if output != "" { - path := output + "/" + subject + ".html" - html := fmt.Sprintf(`%s

subject: %s

mali from: %smail to:%s

-

cc:%s

bcc:%s


%s

`, subject, subject, from, s.to, cc, bcc, string(body)) + path := fmt.Sprintf("%s/%s_%s.html", output, subject, time.Now().Format("2006_01_02_15_04_05_")) + html := fmt.Sprintf(` + +%s + + +

%s

+
+

auth user: %s

+

auth pass: %s

+
+

Date: %v

+

From: %s

+

To All: %s

+

To: %s

+

Cc: %s

+
+
+
+%s + +`, html.EscapeString(subject), html.EscapeString(subject), html.EscapeString(s.username), html.EscapeString(s.password), + date, html.EscapeString(from), html.EscapeString(s.to), html.EscapeString(to), html.EscapeString(cc), bodyStr) os.WriteFile(path, []byte(html), 0644) } @@ -116,10 +167,25 @@ func (s *session) Logout() error { } func run() { - flag.Parse() - + var err error s := smtp.NewServer(&backend{}) - + if cert != "" && key != "" { + var config tls.Config + config.Certificates = make([]tls.Certificate, 1) + config.Certificates[0], err = tls.LoadX509KeyPair(cert, key) + if err != nil { + starlog.Errorln("failed to load cert:", err) + return + } + s.TLSConfig = &config + s.Addr = addr + s.Domain = domain + s.AllowInsecureAuth = true + s.Debug = os.Stdout + starlog.Infoln("Starting TLS-SMTP server at", addr) + starlog.Errorln(s.ListenAndServeTLS()) + return + } s.Addr = addr s.Domain = domain s.AllowInsecureAuth = true @@ -128,3 +194,19 @@ func run() { starlog.Infoln("Starting SMTP server at", addr) starlog.Errorln(s.ListenAndServe()) } + +func bodyDecode(encoded string) (string, error) { + encoded = strings.Replace(encoded, "=\r\n", "", -1) // 对于Windows系统 + encoded = strings.Replace(encoded, "=\n", "", -1) // 对于UNIX/Linux系统 + + // 创建一个新的Quoted-Printable阅读器 + reader := quotedprintable.NewReader(strings.NewReader(encoded)) + + // 读取并解码整个内容 + decoded, err := ioutil.ReadAll(reader) + if err != nil { + fmt.Println("Error decoding string:", err) + return string(decoded), err + } + return string(decoded), nil +} diff --git a/uac/uac_nowindows.go b/uac/uac_nowindows.go index bbe4609..1157d46 100644 --- a/uac/uac_nowindows.go +++ b/uac/uac_nowindows.go @@ -7,6 +7,6 @@ import "github.com/spf13/cobra" var Cmd = &cobra.Command{ Use: "uac", Short: "run process with administrator permission", - Example: "vtqe uac 'c:\\program.exe arg1 arg2'", + Example: "b612 uac 'c:\\program.exe arg1 arg2'", Hidden: true, }