├── client.go ├── examples └── report.go └── readme.md /client.go: -------------------------------------------------------------------------------- 1 | package spamc 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | //Error Types 16 | 17 | const EX_OK = 0 //no problems 18 | const EX_USAGE = 64 // command line usage error 19 | const EX_DATAERR = 65 // data format error 20 | const EX_NOINPUT = 66 // cannot open input 21 | const EX_NOUSER = 67 // addressee unknown 22 | const EX_NOHOST = 68 // host name unknown 23 | const EX_UNAVAILABLE = 69 // service unavailable 24 | const EX_SOFTWARE = 70 // internal software error 25 | const EX_OSERR = 71 // system error (e.g., can't fork) 26 | const EX_OSFILE = 72 // critical OS file missing 27 | const EX_CANTCREAT = 73 // can't create (user) output file 28 | const EX_IOERR = 74 // input/output error 29 | const EX_TEMPFAIL = 75 // temp failure; user is invited to retry 30 | const EX_PROTOCOL = 76 // remote error in protocol 31 | const EX_NOPERM = 77 // permission denied 32 | const EX_CONFIG = 78 // configuration error 33 | const EX_TIMEOUT = 79 // read timeout 34 | 35 | //map of default spamD protocol errors v1.5 36 | var SpamDError = map[int]string{ 37 | EX_USAGE: "Command line usage error", 38 | EX_DATAERR: "Data format error", 39 | EX_NOINPUT: "Cannot open input", 40 | EX_NOUSER: "Addressee unknown", 41 | EX_NOHOST: "Host name unknown", 42 | EX_UNAVAILABLE: "Service unavailable", 43 | EX_SOFTWARE: "Internal software error", 44 | EX_OSERR: "System error", 45 | EX_OSFILE: "Critical OS file missing", 46 | EX_CANTCREAT: "Can't create (user) output file", 47 | EX_IOERR: "Input/output error", 48 | EX_TEMPFAIL: "Temp failure; user is invited to retry", 49 | EX_PROTOCOL: "Remote error in protocol", 50 | EX_NOPERM: "Permission denied", 51 | EX_CONFIG: "Configuration error", 52 | EX_TIMEOUT: "Read timeout", 53 | } 54 | 55 | //Default parameters 56 | const PROTOCOL_VERSION = "1.5" 57 | const DEFAULT_TIMEOUT = 10 58 | 59 | //Command types 60 | const CHECK = "CHECK" 61 | const SYMBOLS = "SYMBOLS" 62 | const REPORT = "REPORT" 63 | const REPORT_IGNOREWARNING = "REPORT_IGNOREWARNING" 64 | const REPORT_IFSPAM = "REPORT_IFSPAM" 65 | const SKIP = "SKIP" 66 | const PING = "PING" 67 | const TELL = "TELL" 68 | const PROCESS = "PROCESS" 69 | const HEADERS = "HEADERS" 70 | 71 | //Learn types 72 | const LEARN_SPAM = "SPAM" 73 | const LEARN_HAM = "HAM" 74 | const LEARN_NOTSPAM = "NOTSPAM" 75 | const LEARN_NOT_SPAM = "NOT_SPAM" 76 | const LEARN_FORGET = "FORGET" 77 | 78 | //Test Types 79 | const TEST_INFO = "info" 80 | const TEST_BODY = "body" 81 | const TEST_RAWBODY = "rawbody" 82 | const TEST_HEADER = "header" 83 | const TEST_FULL = "full" 84 | const TEST_URI = "uri" 85 | const TEST_TXT = "text" 86 | 87 | //only for parse use !important 88 | const SPLIT = "§" 89 | const TABLE_MARK = "----" 90 | 91 | //Types 92 | type Client struct { 93 | ConnTimoutSecs int 94 | ProtocolVersion string 95 | Host string 96 | User string 97 | } 98 | 99 | //Default response struct 100 | type SpamDOut struct { 101 | Code int 102 | Message string 103 | Vars map[string]interface{} 104 | } 105 | 106 | //Default callback to SpanD response 107 | type FnCallback func(*bufio.Reader) (*SpamDOut, error) 108 | 109 | //new instance of Client 110 | func New(host string, timeout int) *Client { 111 | return &Client{timeout, PROTOCOL_VERSION, host, ""} 112 | } 113 | 114 | func (s *Client) SetUnixUser(user string) { 115 | s.User = user 116 | } 117 | 118 | // Return a confirmation that spamd is alive. 119 | func (s *Client) Ping() (r *SpamDOut, err error) { 120 | return s.simpleCall(PING, []string{}) 121 | } 122 | 123 | // Just check if the passed message is spam or not and return score 124 | func (s *Client) Check(msgpars ...string) (reply *SpamDOut, err error) { 125 | return s.simpleCall(CHECK, msgpars) 126 | } 127 | 128 | //Ignore this message -- client opened connection then changed its mind 129 | func (s *Client) Skip(msgpars ...string) (reply *SpamDOut, err error) { 130 | return s.simpleCall(SKIP, msgpars) 131 | } 132 | 133 | //Check if message is spam or not, and return score plus list of symbols hit 134 | func (s *Client) Symbols(msgpars ...string) (reply *SpamDOut, err error) { 135 | return s.simpleCall(SYMBOLS, msgpars) 136 | } 137 | 138 | //Check if message is spam or not, and return score plus report 139 | func (s *Client) Report(msgpars ...string) (reply *SpamDOut, err error) { 140 | return s.simpleCall(REPORT, msgpars) 141 | } 142 | 143 | //Check if message is spam or not, and return score plus report 144 | func (s *Client) ReportIgnoreWarning(msgpars ...string) (reply *SpamDOut, err error) { 145 | return s.simpleCall(REPORT_IGNOREWARNING, msgpars) 146 | } 147 | 148 | //Check if message is spam or not, and return score plus report if the message is spam 149 | func (s *Client) ReportIfSpam(msgpars ...string) (reply *SpamDOut, err error) { 150 | return s.simpleCall(REPORT_IFSPAM, msgpars) 151 | } 152 | 153 | //Process this message and return a modified message - on deloy 154 | func (s *Client) Process(msgpars ...string) (reply *SpamDOut, err error) { 155 | return s.simpleCall(PROCESS, msgpars) 156 | } 157 | 158 | //Same as PROCESS, but return only modified headers, not body (new in protocol 1.4) 159 | func (s *Client) Headers(msgpars ...string) (reply *SpamDOut, err error) { 160 | return s.simpleCall(HEADERS, msgpars) 161 | } 162 | 163 | //Sign the message as spam 164 | func (s *Client) ReportingSpam(msgpars ...string) (reply *SpamDOut, err error) { 165 | headers := map[string]string{ 166 | "Message-class": "spam", 167 | "Set": "local,remote", 168 | } 169 | return s.Tell(msgpars, &headers) 170 | } 171 | 172 | //Sign the message as false-positive 173 | func (s *Client) RevokeSpam(msgpars ...string) (reply *SpamDOut, err error) { 174 | headers := map[string]string{ 175 | "Message-class": "ham", 176 | "Set": "local,remote", 177 | } 178 | return s.Tell(msgpars, &headers) 179 | } 180 | 181 | //Learn if a message is spam or not 182 | func (s *Client) Learn(learnType string, msgpars ...string) (reply *SpamDOut, err error) { 183 | headers := make(map[string]string) 184 | switch strings.ToUpper(learnType) { 185 | case LEARN_SPAM: 186 | headers["Message-class"] = "spam" 187 | headers["Set"] = "local" 188 | case LEARN_HAM, LEARN_NOTSPAM, LEARN_NOT_SPAM: 189 | headers["Message-class"] = "ham" 190 | headers["Set"] = "local" 191 | case LEARN_FORGET: 192 | headers["Remove"] = "local" 193 | default: 194 | err = errors.New("Learn Type Not Found") 195 | return 196 | } 197 | return s.Tell(msgpars, &headers) 198 | } 199 | 200 | //wrapper to simple calls 201 | func (s *Client) simpleCall(cmd string, msgpars []string) (reply *SpamDOut, err error) { 202 | return s.call(cmd, msgpars, func(data *bufio.Reader) (r *SpamDOut, e error) { 203 | r, e = processResponse(cmd, data) 204 | if r.Code == EX_OK { 205 | e = nil 206 | } 207 | return 208 | }, nil) 209 | } 210 | 211 | //external wrapper to simple call 212 | func (s *Client) SimpleCall(cmd string, msgpars ...string) (reply *SpamDOut, err error) { 213 | return s.simpleCall(strings.ToUpper(cmd), msgpars) 214 | } 215 | 216 | //Tell what type of we are to process and what should be done 217 | //with that message. This includes setting or removing a local 218 | //or a remote database (learning, reporting, forgetting, revoking) 219 | func (s *Client) Tell(msgpars []string, headers *map[string]string) (reply *SpamDOut, err error) { 220 | return s.call(TELL, msgpars, func(data *bufio.Reader) (r *SpamDOut, e error) { 221 | r, e = processResponse(TELL, data) 222 | 223 | if r.Code == EX_UNAVAILABLE { 224 | e = errors.New("TELL commands are not enabled, set the --allow-tell switch.") 225 | return 226 | } 227 | if r.Code == EX_OK { 228 | e = nil 229 | return 230 | } 231 | return 232 | }, headers) 233 | } 234 | 235 | //here a TCP socket is created to call SPAMD 236 | func (s *Client) call(cmd string, msgpars []string, onData FnCallback, extraHeaders *map[string]string) (reply *SpamDOut, err error) { 237 | 238 | if extraHeaders == nil { 239 | extraHeaders = &map[string]string{} 240 | } 241 | 242 | switch len(msgpars) { 243 | case 1: 244 | if s.User != "" { 245 | x := *extraHeaders 246 | x["User"] = s.User 247 | *extraHeaders = x 248 | } 249 | case 2: 250 | x := *extraHeaders 251 | x["User"] = msgpars[1] 252 | *extraHeaders = x 253 | default: 254 | if cmd != PING { 255 | err = errors.New("Message parameters wrong size") 256 | } else { 257 | msgpars = []string{""} 258 | } 259 | return 260 | } 261 | 262 | if cmd == REPORT_IGNOREWARNING { 263 | cmd = REPORT 264 | } 265 | 266 | // Create a new connection 267 | stream, err := net.Dial("tcp", s.Host) 268 | 269 | if err != nil { 270 | err = errors.New("Connection dial error to spamd: " + err.Error()) 271 | return 272 | } 273 | // Set connection timeout 274 | timeout := time.Now().Add(time.Duration(s.ConnTimoutSecs) * time.Duration(time.Second)) 275 | errTimeout := stream.SetDeadline(timeout) 276 | if errTimeout != nil { 277 | err = errors.New("Connection to spamd Timed Out:" + errTimeout.Error()) 278 | return 279 | } 280 | defer stream.Close() 281 | 282 | // Create Command to Send to spamd 283 | cmd += " SPAMC/" + s.ProtocolVersion + "\r\n" 284 | cmd += "Content-length: " + fmt.Sprintf("%v\r\n", len(msgpars[0])+2) 285 | //Process Extra Headers if Any 286 | if len(*extraHeaders) > 0 { 287 | for hname, hvalue := range *extraHeaders { 288 | cmd = cmd + hname + ": " + hvalue + "\r\n" 289 | } 290 | } 291 | cmd += "\r\n" + msgpars[0] + "\r\n\r\n" 292 | 293 | _, errwrite := stream.Write([]byte(cmd)) 294 | if errwrite != nil { 295 | err = errors.New("spamd returned a error: " + errwrite.Error()) 296 | return 297 | } 298 | 299 | // Execute onData callback throwing the buffer like parameter 300 | reply, err = onData(bufio.NewReader(stream)) 301 | return 302 | } 303 | 304 | //SpamD reply processor 305 | func processResponse(cmd string, data *bufio.Reader) (returnObj *SpamDOut, err error) { 306 | defer func() { 307 | data.UnreadByte() 308 | }() 309 | 310 | returnObj = new(SpamDOut) 311 | returnObj.Code = -1 312 | //read the first line 313 | line, _, _ := data.ReadLine() 314 | lineStr := string(line) 315 | 316 | r := regexp.MustCompile(`(?i)SPAMD\/([0-9\.]+)\s([0-9]+)\s([0-9A-Z_]+)`) 317 | var result = r.FindStringSubmatch(lineStr) 318 | if len(result) < 4 { 319 | if cmd != "SKIP" { 320 | err = errors.New("spamd unreconized reply:" + lineStr) 321 | } else { 322 | returnObj.Code = EX_OK 323 | returnObj.Message = "SKIPPED" 324 | } 325 | return 326 | } 327 | returnObj.Code, _ = strconv.Atoi(result[2]) 328 | returnObj.Message = result[3] 329 | 330 | //verify a mapped error... 331 | if SpamDError[returnObj.Code] != "" { 332 | err = errors.New(SpamDError[returnObj.Code]) 333 | returnObj.Vars = make(map[string]interface{}) 334 | returnObj.Vars["error_description"] = SpamDError[returnObj.Code] 335 | return 336 | } 337 | returnObj.Vars = make(map[string]interface{}) 338 | 339 | //start didSet 340 | if cmd == TELL { 341 | returnObj.Vars["didSet"] = false 342 | returnObj.Vars["didRemove"] = false 343 | for { 344 | line, _, err = data.ReadLine() 345 | 346 | if err == io.EOF || err != nil { 347 | if err == io.EOF { 348 | err = nil 349 | } 350 | break 351 | } 352 | if strings.Contains(string(line), "DidRemove") { 353 | returnObj.Vars["didRemove"] = true 354 | } 355 | if strings.Contains(string(line), "DidSet") { 356 | returnObj.Vars["didSet"] = true 357 | } 358 | 359 | } 360 | return 361 | } 362 | //read the second line 363 | line, _, err = data.ReadLine() 364 | 365 | //finish here if line is empty 366 | if len(line) == 0 { 367 | if err == io.EOF { 368 | err = nil 369 | } 370 | return 371 | } 372 | 373 | //ignore content-length header.. 374 | lineStr = string(line) 375 | switch cmd { 376 | 377 | case SYMBOLS, CHECK, REPORT, REPORT_IFSPAM, REPORT_IGNOREWARNING, PROCESS, HEADERS: 378 | 379 | switch cmd { 380 | case SYMBOLS, REPORT, REPORT_IFSPAM, REPORT_IGNOREWARNING, PROCESS, HEADERS: 381 | //ignore content-length header.. 382 | line, _, err = data.ReadLine() 383 | lineStr = string(line) 384 | } 385 | 386 | r := regexp.MustCompile(`(?i)Spam:\s(True|False|Yes|No)\s;\s([0-9\.]+)\s\/\s([0-9\.]+)`) 387 | var result = r.FindStringSubmatch(lineStr) 388 | 389 | if len(result) > 0 { 390 | returnObj.Vars["isSpam"] = false 391 | switch result[1][0:1] { 392 | case "T", "t", "Y", "y": 393 | returnObj.Vars["isSpam"] = true 394 | } 395 | returnObj.Vars["spamScore"], _ = strconv.ParseFloat(result[2], 64) 396 | returnObj.Vars["baseSpamScore"], _ = strconv.ParseFloat(result[3], 64) 397 | } 398 | 399 | switch cmd { 400 | case PROCESS, HEADERS: 401 | lines := "" 402 | for { 403 | line, _, err = data.ReadLine() 404 | if err == io.EOF || err != nil { 405 | if err == io.EOF { 406 | err = nil 407 | } 408 | return 409 | } 410 | lines += string(line) + "\r\n" 411 | returnObj.Vars["body"] = lines 412 | } 413 | return 414 | case SYMBOLS: 415 | //ignore line break... 416 | data.ReadLine() 417 | //read 418 | line, _, err = data.ReadLine() 419 | returnObj.Vars["symbolList"] = strings.Split(string(line), ",") 420 | 421 | case REPORT, REPORT_IFSPAM, REPORT_IGNOREWARNING: 422 | //ignore line break... 423 | data.ReadLine() 424 | 425 | for { 426 | line, _, err = data.ReadLine() 427 | 428 | if len(line) > 0 { 429 | lineStr = string(line) 430 | 431 | //TXT Table found, prepare to parse.. 432 | if lineStr[0:4] == TABLE_MARK { 433 | 434 | section := []map[string]interface{}{} 435 | tt := 0 436 | for { 437 | line, _, err = data.ReadLine() 438 | //Stop read the text table if last line or Void line 439 | if err == io.EOF || err != nil || len(line) == 0 { 440 | if err == io.EOF { 441 | err = nil 442 | } 443 | break 444 | } 445 | //Parsing 446 | lineStr = string(line) 447 | spc := 2 448 | if lineStr[0:1] == "-" { 449 | spc = 1 450 | } 451 | lineStr = strings.Replace(lineStr, " ", SPLIT, spc) 452 | lineStr = strings.Replace(lineStr, " ", SPLIT, 1) 453 | if spc > 1 { 454 | lineStr = " " + lineStr[2:] 455 | } 456 | x := strings.Split(lineStr, SPLIT) 457 | if lineStr[1:3] == SPLIT { 458 | section[tt-1]["message"] = fmt.Sprintf("%v %v", section[tt-1]["message"], strings.TrimSpace(lineStr[5:])) 459 | } else { 460 | if len(x) != 0 { 461 | message := strings.TrimSpace(x[2]) 462 | score, _ := strconv.ParseFloat(strings.TrimSpace(x[0]), 64) 463 | 464 | section = append(section, map[string]interface{}{ 465 | "score": score, 466 | "symbol": x[1], 467 | "message": message, 468 | }) 469 | 470 | tt++ 471 | } 472 | } 473 | } 474 | if REPORT_IGNOREWARNING == cmd { 475 | nsection := []map[string]interface{}{} 476 | for _, c := range section { 477 | if c["score"].(float64) != 0 { 478 | nsection = append(nsection, c) 479 | } 480 | } 481 | section = nsection 482 | } 483 | 484 | returnObj.Vars["report"] = section 485 | break 486 | } 487 | } 488 | 489 | if err == io.EOF || err != nil { 490 | if err == io.EOF { 491 | err = nil 492 | } 493 | break 494 | } 495 | } 496 | } 497 | } 498 | 499 | if err != io.EOF { 500 | for { 501 | line, _, err = data.ReadLine() 502 | if err == io.EOF || err != nil { 503 | if err == io.EOF { 504 | err = nil 505 | } 506 | break 507 | } 508 | } 509 | } 510 | return 511 | } 512 | -------------------------------------------------------------------------------- /examples/report.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "go-spamc" 6 | ) 7 | 8 | func main() { 9 | 10 | html := "TestHello world. I'm not a Spam, don't kill me spamassassin!" 11 | 12 | client := spamc.New("127.0.0.1:783", 10) 13 | 14 | //the 2nd parameter is optional, you can set who (the unix user) do the call 15 | //looks like client.Report(html, "saintienn") 16 | 17 | reply, err := client.Report(html) 18 | 19 | if err == nil { 20 | fmt.Println(reply) 21 | } else { 22 | fmt.Println(reply, err) 23 | } 24 | 25 | } 26 | 27 | /* Example Response 28 | { 29 | Code: 0, 30 | Message: 'EX_OK', 31 | Vars:{ 32 | isSpam: true, 33 | spamScore: 6.9, 34 | baseSpamScore: 5, 35 | report:[ 36 | { 37 | "score": score, 38 | "symbol": x[1], 39 | "message": message, 40 | } 41 | ] 42 | } 43 | } 44 | */ 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # go-spamc 2 | 3 | go-spamc is a golang package that connects to spamassassin's spamd daemon. 4 | Is a code port of nodejs module node-spamc(https://github.com/coxeh/node-spamc) 5 | 6 | Thanks for your amazing code Carl Glaysher ;) 7 | 8 | 9 | 10 | You are able to: 11 | 12 | - Check a message for a spam score and return back what spamassassin matched on 13 | - Ability to send messages to spamassassin to learn from 14 | - Ability to do everything that `spamc` is capable of 15 | 16 | ## Methods Available 17 | 18 | - `Check` checks a message for a spam score and returns an object of information 19 | - `Symbols` like `check` but also returns what the message matched on 20 | - `Report` like `symbols` but matches also includes a small description 21 | - `ReportIfSpam` only returns a result if message is spam 22 | - `ReportIgnoreWarning` like report but matches only symbols with score > 0 "New" 23 | - `Process` like `check` but also returns a processed message with extra headers 24 | - `Headers` like `check` but also returns the message headers in a array 25 | - `Learn` abilty to parse a message to spamassassin and learn it as spam or ham 26 | - `ReportingSpam` ability to tell spamassassin that the message is spam 27 | - `RevokeSpam` abilty to tell spamassassin that the message is not spam 28 | 29 | 30 | ## Example 31 | example.go 32 | 33 | package main 34 | 35 | import ( 36 | "fmt" 37 | "spamc" 38 | ) 39 | 40 | func main() { 41 | 42 | html := "Hello world. I'm not a Spam, don't kill me SpamAssassin!" 43 | client := spamc.New("127.0.0.1:783",10) 44 | 45 | //the 2nd parameter is optional, you can set who (the unix user) do the call 46 | reply, _ := client.Check(html, "saintienn") 47 | 48 | fmt.Println(reply.Code) 49 | fmt.Println(reply.Message) 50 | fmt.Println(reply.Vars) 51 | } 52 | 53 | 54 | 55 | --------------------------------------------------------------------------------